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

- Published on

Sharing
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)
}
}
---