Scala Programming Guidesscala-ecosystemscala-best-practices

Testing — ScalaTest, Specs2, and Property-Based Testing | Scala Programming Guide

By Dmitri Meshin
Picture of the author
Published on
Testing — ScalaTest, Specs2, and Property-Based Testing - Scala Programming Guide

Test Frameworks and Styles

ScalaTest offers multiple DSLs for different testing preferences. Choose based on team taste and existing test patterns.

FunSuite: Simple and Straightforward

FunSuite is closest to xUnit-style testing:

import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.BeforeAndAfterEach
import com.mycompany.billing.Invoice

class InvoiceCalculatorTest extends AnyFunSuite with BeforeAndAfterEach {
  // Setup runs before each test
  var invoice: Invoice = _

  override def beforeEach(): Unit = {
    invoice = Invoice(
      id = "INV-2024-001",
      subtotal = BigDecimal("1000.00"),
      taxRate = BigDecimal("0.10"),
      discountPercent = BigDecimal("5.0")
    )
  }

  // Each test is a function - simple and readable
  test("should calculate total with tax and discount") {
    val total = invoice.calculateTotal()
    // Assertions use assert() from Scala stdlib
    assert(total == BigDecimal("1045.00"))
  }

  test("should apply volume discount for large invoices") {
    invoice = invoice.copy(subtotal = BigDecimal("50000.00"))
    val discount = invoice.getVolumeDiscount()
    assert(discount >= BigDecimal("2500.00"))
  }

  test("should handle zero tax rate") {
    invoice = invoice.copy(taxRate = BigDecimal("0.0"))
    val total = invoice.calculateTotal()
    assert(total == BigDecimal("950.00")) // 1000 - 50 discount
  }

  // Nested test organization
  test("validate customer credit limits") {
    // Given: invoice with high value
    val largeInvoice = invoice.copy(subtotal = BigDecimal("100000.00"))

    // When: checking credit limit
    val exceedsLimit = largeInvoice.exceedsCreditLimit(BigDecimal("50000.00"))

    // Then: it should exceed
    assert(exceedsLimit)
  }
}

FlatSpec: BDD-Style Specification

FlatSpec uses "flat" specification style (behavior-driven):

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class PaymentProcessorSpec extends AnyFlatSpec with Matchers {
  // "Subject" under test
  "A PaymentProcessor" should "process valid credit card payments" in {
    val processor = new PaymentProcessor()
    val result = processor.processCreditCard(
      cardNumber = "4532015112830366",
      amount = BigDecimal("99.99"),
      cvv = "123"
    )

    // Fluent matchers make assertions readable
    result.isSuccess should be(true)
    result.transactionId should not be empty
  }

  it should "reject expired cards" in {
    val processor = new PaymentProcessor()
    val result = processor.processCreditCard(
      cardNumber = "4532015112830366",
      amount = BigDecimal("99.99"),
      cvv = "123",
      expiryMonth = 1,
      expiryYear = 2020
    )

    result.isSuccess should be(false)
    result.error should contain("Card expired")
  }

  it should "apply fraud detection rules" in {
    val processor = new PaymentProcessor()

    // Multiple transactions in rapid succession
    processor.processCreditCard("4532015112830366", BigDecimal("1000.00"), "123")
    processor.processCreditCard("4532015112830366", BigDecimal("2000.00"), "123")
    val result = processor.processCreditCard(
      "4532015112830366",
      BigDecimal("3000.00"),
      "123"
    )

    result.isSuccess should be(false)
    result.error should include("fraud detection")
  }

  "A PaymentProcessor" should "handle concurrent requests safely" in {
    val processor = new PaymentProcessor()

    // Simulate 100 concurrent transactions
    val results = (1 to 100).par.map { i =>
      processor.processCreditCard(
        cardNumber = s"4532015112830${i % 100}",
        amount = BigDecimal(i * 10),
        cvv = "123"
      )
    }

    // All should succeed (or fail consistently)
    results.count(_.isSuccess) should be >= 90
  }
}

WordSpec: Nested Descriptive Tests

WordSpec allows nested descriptions for complex scenarios:

import org.scalatest.wordspec.AnyWordSpec
import org.scalatest.matchers.should.Matchers

class OrderServiceSpec extends AnyWordSpec with Matchers {
  "OrderService" when {
    "processing a new order" should {
      "create order with unique ID" in {
        val service = new OrderService()
        val order = Order(items = List(
          LineItem(productId = "WIDGET-1", quantity = 2)
        ))

        val created = service.createOrder(order)
        created.id should not be empty
      }

      "calculate total with tax" in {
        val service = new OrderService()
        val order = Order(
          items = List(
            LineItem(productId = "WIDGET-1", quantity = 2, pricePerUnit = 50.0)
          ),
          taxRate = 0.1
        )

        val total = service.calculateTotal(order)
        total shouldEqual 110.0  // 100 * 1.1
      }

      "reject orders with no items" in {
        val service = new OrderService()
        val invalidOrder = Order(items = List())

        val exception = intercept[IllegalArgumentException] {
          service.createOrder(invalidOrder)
        }
        exception.getMessage should include("at least one item")
      }
    }

    "processing a bulk order (>50 items)" should {
      "apply bulk discount" in {
        val service = new OrderService()
        val order = Order(
          items = (1 to 75).map { i =>
            LineItem(productId = s"ITEM-$i", quantity = 1, pricePerUnit = 10.0)
          }.toList
        )

        val discountedTotal = service.calculateTotal(order)
        discountedTotal should be < 750.0  // 10% discount applied
      }

      "prioritize in fulfillment queue" in {
        val service = new OrderService()
        val bulkOrder = createBulkOrder(100)
        val normalOrder = createNormalOrder(1)

        service.enqueueOrder(normalOrder)
        service.enqueueOrder(bulkOrder)

        // Bulk order should be first in queue
        service.nextOrderToProcess().id shouldEqual bulkOrder.id
      }
    }
  }

  private def createBulkOrder(itemCount: Int): Order =
    Order(items = (1 to itemCount).map(_ => LineItem("ITEM", 1, 10.0)).toList)

  private def createNormalOrder(itemCount: Int): Order =
    Order(items = (1 to itemCount).map(_ => LineItem("ITEM", 1, 10.0)).toList)
}

FreeSpec: Arbitrary Nesting

FreeSpec allows any nesting level with - (dash) operator:

import org.scalatest.freespec.AnyFreeSpec
import org.scalatest.matchers.should.Matchers

class InventorySpec extends AnyFreeSpec with Matchers {
  "Inventory management system" - {
    "when adding stock" - {
      "should increment quantity" in {
        val inventory = new Inventory()
        inventory.addStock("SKU-123", 100)
        inventory.getQuantity("SKU-123") shouldEqual 100
      }

      "should support multiple additions" in {
        val inventory = new Inventory()
        inventory.addStock("SKU-123", 50)
        inventory.addStock("SKU-123", 30)
        inventory.getQuantity("SKU-123") shouldEqual 80
      }

      "should create entry if not exists" in {
        val inventory = new Inventory()
        inventory.addStock("NEW-SKU", 1)
        inventory.contains("NEW-SKU") shouldEqual true
      }
    }

    "when removing stock" - {
      "should decrement quantity" in {
        val inventory = new Inventory()
        inventory.addStock("SKU-123", 100)
        inventory.removeStock("SKU-123", 30)
        inventory.getQuantity("SKU-123") shouldEqual 70
      }

      "should prevent negative stock" in {
        val inventory = new Inventory()
        inventory.addStock("SKU-123", 50)

        val exception = intercept[IllegalStateException] {
          inventory.removeStock("SKU-123", 100)
        }
        exception.getMessage should include("insufficient")
      }

      "should alert when below reorder point" in {
        val inventory = new Inventory()
        inventory.setReorderPoint("SKU-123", 20)
        inventory.addStock("SKU-123", 100)

        inventory.removeStock("SKU-123", 85)  // Now at 15, below 20

        val alerts = inventory.getLowStockAlerts()
        alerts should contain("SKU-123")
      }
    }
  }
}

Matchers: Beyond Basic Assertions

ScalaTest's matchers make tests readable and provide better error messages:

import org.scalatest.matchers.should.Matchers._

// Basic equality
value should equal(5)
value shouldEqual 5
value should be(5)
value shouldBe 5

// Collections
list should contain(3)
list should contain allOf(1, 2, 3)
list should contain inOrder(1, 2, 3)
list should have length 5
list should not be empty

// Strings
text should startWith("Error")
text should endWith("failed")
text should include("timeout")
text should fullyMatch regex "\\d{3}-\\d{4}".r

// Numeric comparisons
value should be < 10
value should be >= 0
value should be > 5.0 +- 0.1  // Within 0.1 of 5.0

// Option/Try matching
optValue should be(Some(42))
optValue shouldBe defined
optValue should contain(42)

// Exception testing
val thrown = intercept[IOException] {
  riskyOperation()
}
thrown.getMessage should include("file not found")

// Boolean assertions
condition shouldBe true
list.isEmpty should be(false)

// Custom matchers for domain logic
case class Account(balance: BigDecimal, overdraftLimit: BigDecimal)

// Implicit custom matcher
implicit def beInGoodStanding(account: Account): Boolean =
  account.balance >= 0 && account.balance > -account.overdraftLimit

account should be(inGoodStanding)

Test Fixtures and Setup/Teardown

Organize test data and lifecycle:

import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.{BeforeAndAfterEach, BeforeAndAfterAll}

class DatabaseServiceTest extends AnyFunSuite with BeforeAndAfterEach with BeforeAndAfterAll {
  // Shared mutable state - only use when necessary
  var testDb: TestDatabaseConnection = _
  var service: UserService = _

  // Runs once before all tests in this class
  override def beforeAll(): Unit = {
    testDb = TestDatabaseConnection.create()
    testDb.runMigrations()
  }

  // Runs before each test
  override def beforeEach(): Unit = {
    // Truncate tables to ensure clean state
    testDb.truncate("users")
    testDb.truncate("audit_log")

    service = new UserService(testDb)
  }

  // Runs after each test
  override def afterEach(): Unit = {
    // Reset any test-wide state
    service.clearCache()
  }

  // Runs once after all tests
  override def afterAll(): Unit = {
    testDb.close()
  }

  test("should create user in database") {
    val userId = service.createUser(
      email = "alice@example.com",
      name = "Alice Smith"
    )

    userId should not be empty
    val stored = testDb.getUserById(userId)
    stored.email shouldEqual "alice@example.com"
  }

  test("should prevent duplicate emails") {
    service.createUser("bob@example.com", "Bob")

    val exception = intercept[DuplicateKeyException] {
      service.createUser("bob@example.com", "Bob 2")
    }
    exception.getMessage should include("email")
  }
}

// Alternative: Fixture-based tests (prefer immutable)
class FixtureAwareTest extends AnyFunSuite {
  // Define reusable fixtures
  trait Fixture {
    val testUser = User(
      id = "test-123",
      email = "test@example.com",
      createdAt = Instant.now()
    )
    val service = new UserService(inmemoryDb)
  }

  test("should fetch user by email") {
    new Fixture {
      val result = service.findByEmail(testUser.email)
      result should contain(testUser)
    }
  }
}

Mocking with ScalaMock

ScalaMock provides compile-time safe mocking:

import org.scalamock.scalatest.MockFactory
import org.scalatest.wordspec.AnyWordSpec

// Interface to mock
trait PaymentGateway {
  def charge(amount: BigDecimal, cardToken: String): PaymentResult
  def refund(transactionId: String): RefundResult
}

class OrderServiceTest extends AnyWordSpec with MockFactory {
  "OrderService" when {
    "processing payment" should {
      "call payment gateway with correct amount" in {
        // Create a mock gateway
        val gateway = mock[PaymentGateway]

        // Set expectations - inAnyOrder, called exactly once, return value
        (gateway.charge _)
          .expects(BigDecimal("99.99"), "tok_visa")
          .returning(PaymentResult(
            success = true,
            transactionId = "txn_123"
          ))

        val service = new OrderService(gateway)
        val result = service.processOrder(
          Order(
            items = List(LineItem("ITEM", 1, 99.99)),
            cardToken = "tok_visa"
          )
        )

        result.isSuccess shouldBe true
      }

      "retry on temporary failure" in {
        val gateway = mock[PaymentGateway]

        // First call fails, second succeeds
        (gateway.charge _)
          .expects(*, *)
          .returning(PaymentResult(success = false, error = "timeout"))
          .once()

        (gateway.charge _)
          .expects(*, *)
          .returning(PaymentResult(
            success = true,
            transactionId = "txn_456"
          ))
          .once()

        val service = new OrderService(gateway) with RetryPolicy {
          val maxRetries = 2
        }

        val result = service.processOrder(createOrder())
        result.isSuccess shouldBe true
      }

      "refund on payment accepted but order fails" in {
        val gateway = mock[PaymentGateway]
        inSequence {
          // Expect charge first
          (gateway.charge _)
            .expects(*, *)
            .returning(PaymentResult(success = true, transactionId = "txn_789"))

          // Then refund when order processing fails
          (gateway.refund _)
            .expects("txn_789")
            .returning(RefundResult(success = true))
        }

        val service = new OrderService(gateway) {
          override def validateOrder(order: Order): Boolean = false
        }

        val result = service.processOrder(createOrder())
        result.refunded shouldBe true
      }
    }
  }
}

Property-Based Testing with ScalaCheck

Test properties that should hold for all inputs:

import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import org.scalacheck.{Gen, Prop, Properties}
import org.scalacheck.Prop._

// Property tests for a price calculator
object PriceCalculatorProperties extends Properties("PriceCalculator") {
  // Generator for valid prices (0 to 100000)
  val priceGen = Gen.choose[Double](0.0, 100000.0)

  // Generator for tax rates (0% to 25%)
  val taxRateGen = Gen.choose[Double](0.0, 0.25)

  // Property: total should never be less than subtotal
  property("total >= subtotal") = forAll(priceGen, taxRateGen) { (price, tax) =>
    val calculator = new PriceCalculator()
    val total = calculator.calculateTotal(
      subtotal = BigDecimal(price),
      taxRate = BigDecimal(tax)
    )
    total >= BigDecimal(price)
  }

  // Property: tax should be correctly calculated
  property("tax calculation is accurate") = forAll(priceGen, taxRateGen) { (price, tax) =>
    val calculator = new PriceCalculator()
    val result = calculator.calculateTotal(
      subtotal = BigDecimal(price),
      taxRate = BigDecimal(tax)
    )
    val expectedTax = BigDecimal(price) * BigDecimal(tax)
    result == (BigDecimal(price) + expectedTax)
  }

  // Property: idempotence - applying discount twice should fail
  property("discount applied only once") = forAll(priceGen) { price =>
    val calculator = new PriceCalculator()
    val onceDiscounted = calculator.applyDiscount(BigDecimal(price), 0.1)
    val twiceDiscounted = calculator.applyDiscount(onceDiscounted, 0.1)
    twiceDiscounted > onceDiscounted  // Second discount has less effect
  }
}

// Custom generators for domain-specific types
object OrderGenerators {
  val validSkuGen: Gen[String] = for {
    prefix <- Gen.oneOf("PROD", "SKU", "ITEM")
    number <- Gen.choose(1000, 9999)
  } yield s"$prefix-$number"

  val lineItemGen: Gen[LineItem] = for {
    sku <- validSkuGen
    quantity <- Gen.choose(1, 1000)
    price <- Gen.choose(1.0, 10000.0)
  } yield LineItem(sku, quantity, BigDecimal(price))

  val orderGen: Gen[Order] = for {
    itemCount <- Gen.choose(1, 20)
    items <- Gen.listOfN(itemCount, lineItemGen)
  } yield Order(items = items)

  // Arbitrary ensures ScalaCheck uses this generator by default
  implicit lazy val arbitraryOrder: org.scalacheck.Arbitrary[Order] =
    org.scalacheck.Arbitrary(orderGen)
}

// Integration with ScalaTest
class OrderCalculationProperties extends AnyFunSuite with Matchers {
  import OrderGenerators._

  test("order total equals sum of item totals") {
    val prop = forAll(orderGen) { order =>
      val service = new OrderService()
      val total = service.calculateTotal(order)

      val itemsTotal = order.items
        .map(item => BigDecimal(item.quantity) * item.price)
        .sum

      total == itemsTotal
    }

    // Runs property with 100 examples by default
    Prop.forAll(prop).check()
  }

  test("applying and removing discount is reversible") {
    val prop = forAll(priceGen, Gen.choose(0.0, 0.5)) { (price, discountRate) =>
      val calculator = new PriceCalculator()
      val afterDiscount = calculator.applyDiscount(
        BigDecimal(price),
        BigDecimal(discountRate)
      )
      val reversedDiscount = calculator.applyDiscount(
        afterDiscount,
        BigDecimal(discountRate) / (1 - BigDecimal(discountRate))  // Reverse formula
      )

      // Should be approximately equal (accounting for rounding)
      (reversedDiscount - BigDecimal(price)).abs < BigDecimal("0.01")
    }

    Prop.forAll(prop).check()
  }
}

// Shrinking: ScalaCheck automatically simplifies failing examples
property("all lists are sorted") = forAll { (list: List[Int]) =>
  val sorted = list.sorted
  sorted.length == list.length &&
  sorted.zipWithIndex.forall { case (elem, idx) =>
    idx == 0 || sorted(idx - 1) <= elem
  }
}
// If this fails, ScalaCheck shrinks the list to find the minimal failing case

Integration Testing

Separate integration tests from unit tests:

// In src/it/scala/
import org.scalatest.funsuite.AnyFunSuite
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.utility.DockerImageName

class UserServiceIntegrationTest extends AnyFunSuite {
  // Use TestContainers for isolated database
  val postgres = new PostgreSQLContainer(
    DockerImageName.parse("postgres:15")
  )

  override def beforeAll(): Unit = {
    postgres.start()
    // Setup database schema
    val conn = postgres.getJdbcUrl
    setupDatabase(conn)
  }

  override def afterAll(): Unit = {
    postgres.stop()
  }

  test("should persist user and retrieve from database") {
    val service = new UserService(postgres.getJdbcUrl)

    // Actual database operations
    val userId = service.createUser("alice@example.com", "Alice")
    val retrieved = service.getUserById(userId)

    assert(retrieved.email == "alice@example.com")
    assert(retrieved.id == userId)
  }

  test("should maintain referential integrity") {
    val service = new UserService(postgres.getJdbcUrl)

    val userId = service.createUser("bob@example.com", "Bob")
    service.addAddress(userId, Address("123 Main St", "NYC"))

    // Deleting user should cascade
    service.deleteUser(userId)
    assert(service.getAddresses(userId).isEmpty)
  }
}

---