Testing with ScalaTest and MUnit
Structure suites and assertions, use property-based testing with ScalaCheck, and run the right tests from sbt and Scala CLI.
MUnit and ScalaTest suites
// MUnit: small, fast, the default for Scala CLI projects
import munit.FunSuite
class MoneySuite extends FunSuite:
test("adds two amounts in the same currency"):
val a = Money(100, "EUR")
val b = Money(250, "EUR")
assertEquals((a + b).cents, 350L)
test("rejects a currency mismatch"):
intercept[IllegalArgumentException]:
Money(1, "EUR") + Money(1, "USD")
// async tests return a Future and MUnit awaits it
test("loads asynchronously"):
for
value <- Future.successful(42)
yield assertEquals(value, 42)
override def afterEach(context: AfterEach): Unit =
// clean up shared state between tests
super.afterEach(context)// ScalaTest: several styles; AnyFunSuite is the most readable
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class MoneySpec extends AnyFunSuite with Matchers:
test("addition preserves the currency") {
(Money(100, "EUR") + Money(50, "EUR")).currency shouldBe "EUR"
}
test("the zero instance is additive identity") {
Money(100, "EUR") + Money.Zero shouldBe Money(100, "EUR")
}
// fixture style for expensive setup
def withStore(f: Store => Unit): Unit =
val store = Store.inMemory()
try f(store) finally store.close()
- MUnit's
assertEqualsprints a diff on failure; ScalaTest'sshouldBerecords aTestFailedExceptionwith a source line. interceptfails the test if nothing is thrown, which is the point — an assertion-free catch is not a test.- Keep one behaviour per test. A test named "parses and validates and saves" tells you nothing when it goes red.
- Both frameworks run suites in parallel by default in sbt. Any shared mutable fixture must be per-suite, not a global object.
Property-based testing with ScalaCheck
import org.scalacheck.Prop.forAll
import org.scalacheck.{Gen, Properties}
object CodecProperties extends Properties("Codec"):
// a property is a claim that must hold for every generated input
property("decode inverts encode") = forAll { (s: String) =>
decode(encode(s)) == Right(s)
}
property("length is non-negative for every list") = forAll { (xs: List[Int]) =>
xs.length >= 0
}
// a custom generator, with a constraint baked in
val genOrder: Gen[Order] = for
sku <- Gen.alphaUpperStr.suchThat(_.nonEmpty)
quantity <- Gen.chooseNum(1, 1000)
price <- Gen.chooseNum[Long](1L, 1_000_00L)
yield Order(sku.take(8), quantity, price)
property("totals are never negative") = forAll(genOrder) { (o: Order) =>
o.total >= 0
}
property("a failing case is minimised") = forAll { (xs: List[Int]) =>
// when this fails, ScalaCheck shrinks to the smallest failing list
xs.sum == xs.foldLeft(0)(_ + _)
}| Tool | Strength | When it earns its keep |
|---|---|---|
| Example tests | Concrete, fast, easy to read | Default for business rules |
| Property tests | Finds the input you did not think of | Codecs, parsers, arithmetic, invariants |
| Shrinking | Reduces a failure to a minimal case | Turns a property failure into a bug report |
| Table-driven tests | One body, many inputs | Boundary values and known edge cases |
| Fixtures | Expensive setup reused | Database or HTTP integration suites |
// run inside sbt
// testOnly com.example.CodecProperties
// testOnly * -- -z "decode"
// Scala CLI
// scala-cli test .
// scala-cli test . --test-only com.example.CodecPropertiesWhat to test and what to leave
- Test behaviour at the boundary of your module, not every private method. Tests that reach inside break on every refactor and prove nothing about the contract.
- A property test is a specification: "decoding is the inverse of encoding" catches far more than twenty hand-written examples.
- Use a fake in place of a mock: an in-memory repository with the same interface exercises real logic and needs no verification vocabulary.
- Freeze time in any test that touches it by injecting a clock or a
TimeProvider-style abstraction. A test that fails at midnight is worse than no test. - Run the whole suite in CI with a deterministic seed reported on failure, so a rare property failure can be reproduced.
💡
When a property test fails, ScalaCheck shrinks the counterexample automatically and prints it. Copy that value into a normal example test before you fix the bug: it documents the regression and runs in milliseconds from then on.
FAQ
MUnit or ScalaTest?
MUnit for new projects and anything used with Scala CLI: less API surface, faster, and its diffs are good. ScalaTest if you need the matcher DSL, the many test styles or an existing suite to fit into.
Are property tests slow?
A default ScalaCheck property runs a hundred cases; that is fast for pure functions and hopeless for anything doing I/O. Keep I/O out of the property body and generate inputs, not side effects.
Related
Setting up Scala 3: Coursier, Scala CLI and sbt Option, Either and functional error handling
Last refreshed 2026-09-18.