Skip to content

05 · Testing Advanced

Level 2 covered writing individual example-based tests with ScalaTest. This module adds two techniques for when hand-picked examples aren't enough: property-based testing with ScalaCheck, which generates hundreds of inputs automatically, and mocking dependencies with Mockito so you can test one class in isolation from the collaborators it calls.

build.sbt
scalaVersion := "3.3.1"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % Test
libraryDependencies += "org.scalatestplus" %% "scalacheck-1-17" % "3.2.18.0" % Test
libraryDependencies += "org.mockito" % "mockito-core" % "5.11.0" % Test

Property-based testing with ScalaCheck

Example-based tests check specific inputs you thought of. A property describes something that should hold for every input, and ScalaCheck generates a wide range of random inputs (including edge cases like empty strings) to try to falsify it:

// src/main/scala/Lib.scala
def reverseString(s: String): String = s.reverse
// src/test/scala/LibSpec.scala
import org.scalatest.funsuite.AnyFunSuite
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
import org.scalacheck.Gen

class LibSpec extends AnyFunSuite with ScalaCheckPropertyChecks:
  test("reversing twice returns the original string") {
    forAll(Gen.alphaStr) { s =>
      assert(reverseString(reverseString(s)) == s)
    }
  }
sbt test
[info] LibSpec:
[info] - reversing twice returns the original string
[info] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 0

forAll(Gen.alphaStr) runs the block 100 times by default with different generated strings each time. Gen has building blocks for most shapes: Gen.alphaStr, Gen.choose(1, 100), Gen.listOf(Gen.posNum[Int]), and Gen.oneOf(a, b, c) for picking among fixed alternatives. You describe what kind of input to generate; ScalaCheck handles picking specific values (including nasty ones like empty lists or Int.MinValue).

The trap: properties that are trivially true

forAll(Gen.alphaStr) { s => assert(s == s) } passes 100/100 runs and tells you nothing. A useful property encodes an actual invariant of your code — "reversing twice is a no-op," "sorting is idempotent," "encoding then decoding returns the original" — not a tautology. If you can't state what should always be true about a function beyond "it doesn't crash," example-based tests are probably the better fit for it.

Mocking with Mockito

Testing OrderCalculator shouldn't require a real price database — only a stand-in for PriceService that returns canned answers, so the test exercises OrderCalculator's logic in isolation:

// src/main/scala/Lib.scala
trait PriceService:
  def priceOf(sku: String): Double

class OrderCalculator(prices: PriceService):
  def total(skus: List[String]): Double = skus.map(prices.priceOf).sum
import org.mockito.Mockito._

test("OrderCalculator sums mocked prices") {
  val mockPrices = mock(classOf[PriceService])
  when(mockPrices.priceOf("A")).thenReturn(10.0)
  when(mockPrices.priceOf("B")).thenReturn(5.0)

  val calc = new OrderCalculator(mockPrices)
  assert(calc.total(List("A", "B", "A")) == 25.0)

  verify(mockPrices, times(2)).priceOf("A")   // called twice, as expected
}

mock(classOf[PriceService]) creates a fake implementation where every method returns a default (0.0, null, etc.) unless you stub it with when(...).thenReturn(...). verify then asserts something about how the mock was called — here, that priceOf("A") was invoked exactly twice, matching the two "A"s in the input list.

The trap: mocking everything, testing nothing

Mocks are for dependencies your test doesn't care about — a database, a network client, a clock. Mocking the class under test itself, or mocking so much of a collaborator's behavior that the test just re-describes the implementation (when(mock.compute(1)).thenReturn(2) in a test literally titled "compute doubles its input"), produces a test that passes even after the real logic breaks, because it's only checking that the mock does what you told it to do. Prefer a real, cheap dependency (an in-memory list, the in-memory SQLite from the previous module) over a mock whenever one is available — mock what's genuinely expensive or non-deterministic (network calls, wall-clock time, external services) and nothing else.

Combining both

Properties and mocks aren't mutually exclusive — you can generate random inputs for a class that itself depends on a mock:

test("total is always non-negative for non-negative prices") {
  forAll(Gen.listOf(Gen.oneOf("A", "B"))) { skus =>
    val mockPrices = mock(classOf[PriceService])
    when(mockPrices.priceOf("A")).thenReturn(10.0)
    when(mockPrices.priceOf("B")).thenReturn(5.0)
    val calc = new OrderCalculator(mockPrices)
    assert(calc.total(skus) >= 0.0)
  }
}

How It Actually Works

ScalaCheck's property-based testing works by pairing a Gen[A] (a generator) with your property function: Gen[A] is, mechanically, a thin wrapper around a Random-seeded function that produces values of type A, often composed from smaller generators via map/flatMap (the same monadic shape as Option/Future — see Module 4). When you run forAll(genA) { a => property }, ScalaCheck doesn't just generate arbitrary values once — it runs dozens of them (default 100 per property), and specifically also probes "edge" values it knows are often bug-prone (0, negative numbers, empty collections) before falling back to pure randomness, weighted by a "size" parameter that grows across the run to also exercise larger inputs. When a run finds a failing case, ScalaCheck shrinks it: it repeatedly tries smaller/simpler values that still fail the property (fewer list elements, smaller numbers) until it can't shrink further, which is why failures report a minimal reproducing example instead of some large random input.

Mockito mocks work through runtime bytecode generation: mock[Repo] doesn't secretly create a real Repo — it uses a bytecode-generation library (historically CGLIB/ByteBuddy) to synthesize, at test-run time, a brand-new subclass (for a class) or dynamic proxy (for a trait/interface) of Repo whose every method is overridden to record the call it received and return a pre-programmed value (or a default like null/0 if unstubbed) instead of running any real logic. when(mock.find(1)) .thenReturn(...) works by first invoking the real method call (recorded by the mock's interceptor rather than executed), then attaching the return value to that recorded call signature for future invocations — which is why stubbing only works on methods the mocking library can actually override (final methods and most objects resist mocking, since there's no subclass point to intercept them through).

Cheat sheet

Need to... Use
Generate random test inputs Gen.alphaStr, Gen.choose(lo, hi), Gen.listOf(gen), Gen.oneOf(...)
Run a block against many generated inputs forAll(gen) { x => assert(...) } (mix in ScalaCheckPropertyChecks)
Create a fake dependency mock(classOf[Trait])
Make it return canned values when(mock.method(args)).thenReturn(value)
Assert how a mock was called verify(mock, times(n)).method(args)
Decide mock vs. real mock expensive/non-deterministic externals; use a real cheap object otherwise

Exercise

Write a property for OrderCalculator.total stating that adding one more SKU to the list never decreases the total, given non-negative mocked prices — generate a List[String] and one extra SKU with ScalaCheck, mock prices for all SKUs involved, and assert calc.total(skus :+ extra) >= calc.total(skus). Then write a mock-based test for a NotificationService trait with a send(msg: String): Boolean method: stub it to fail on the first call and succeed on a retry, and verify a ReliableSender class you write calls send twice when the first attempt returns false.