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 assertEquals prints a diff on failure; ScalaTest's shouldBe records a TestFailedException with a source line.
  • intercept fails 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)(_ + _)
  }
ToolStrengthWhen it earns its keep
Example testsConcrete, fast, easy to readDefault for business rules
Property testsFinds the input you did not think ofCodecs, parsers, arithmetic, invariants
ShrinkingReduces a failure to a minimal caseTurns a property failure into a bug report
Table-driven testsOne body, many inputsBoundary values and known edge cases
FixturesExpensive setup reusedDatabase 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.CodecProperties

What 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.

Setting up Scala 3: Coursier, Scala CLI and sbt Option, Either and functional error handling

Last refreshed 2026-09-18.