Skip to main content
Scala intermediate Lesson 8 of 10

Testing with MUnit and ScalaCheck

Run tests with one command, read a failure diff that actually helps, test futures without blocking, and let property-based testing find the case you did not think of.

Tests run with the same one-line tooling as everything else in this track: a using directive for the dependency and scala-cli test. No build file.

The code under test

// shipping.scala
case class Order(id: Int, country: String, status: String, amount: BigDecimal)

object Shipping:
  def cost(weightKg: Double, express: Boolean = false): BigDecimal =
    require(weightKg > 0, s"weight must be positive, got $weightKg")
    val base = if weightKg < 2 then BigDecimal("3.99") else BigDecimal("6.99")
    val adjusted = if weightKg >= 10 && !express then BigDecimal(0) else base
    (adjusted * (if express then BigDecimal("2.5") else BigDecimal(1)))
      .setScale(2, BigDecimal.RoundingMode.HALF_UP)

  def revenueByCountry(orders: List[Order]): Map[String, BigDecimal] =
    orders.filter(_.status == "completed").groupMapReduce(_.country)(_.amount)(_ + _)

A first suite

// shipping.test.scala
//> using dep org.scalameta::munit::1.0.3

class ShippingSuite extends munit.FunSuite:

  test("light parcels cost the base rate"):
    assertEquals(Shipping.cost(1.5), BigDecimal("3.99"))

  test("heavy standard parcels ship free"):
    assertEquals(Shipping.cost(12), BigDecimal("0.00"))

  test("express multiplies the base rate"):
    assertEquals(Shipping.cost(1.5, express = true), BigDecimal("9.98"))

  test("negative weight is rejected"):
    val e = intercept[IllegalArgumentException](Shipping.cost(-1))
    assert(e.getMessage.contains("must be positive"))
scala-cli test .
Compiling project (test, Scala 3.6.2, JVM (21))
Compiled project (test, Scala 3.6.2, JVM (21))
ShippingSuite:
  + light parcels cost the base rate 0.021s
  + heavy standard parcels ship free 0.001s
  + express multiplies the base rate 0.001s
  + negative weight is rejected 0.004s
Execution took 0.03s
4 tests, 4 passed

assertEquals is type-checked — comparing a BigDecimal with a String will not compile, which rules out a whole class of test that passes for the wrong reason.

Reading a failure

  test("express multiplies the base rate"):
    assertEquals(Shipping.cost(1.5, express = true), BigDecimal("9.99"))
ShippingSuite:
  + light parcels cost the base rate 0.019s
==> X ShippingSuite.express multiplies the base rate  0.014s munit.ComparisonFailException: shipping.test.scala:12
11:  test("express multiplies the base rate"):
12:    assertEquals(Shipping.cost(1.5, express = true), BigDecimal("9.99"))
13:
values are not the same
=> Obtained
9.98
=> Diff (- obtained, + expected)
-9.98
+9.99
4 tests, 3 passed, 1 failed

The source line, the obtained and expected values, and a diff. On collections the diff is where MUnit earns its place:

  test("revenue is grouped by country"):
    val orders = List(
      Order(1001, "GB", "completed", BigDecimal("25.50")),
      Order(1002, "US", "completed", BigDecimal("12.00")),
      Order(1003, "GB", "returned",  BigDecimal("40.00")),
    )
    assertEquals(
      Shipping.revenueByCountry(orders),
      Map("GB" -> BigDecimal("65.50"), "US" -> BigDecimal("12.00"))
    )
==> X ShippingSuite.revenue is grouped by country  0.031s munit.ComparisonFailException
values are not the same
=> Diff (- obtained, + expected)
 Map(
-  "GB" -> 25.50,
+  "GB" -> 65.50,
   "US" -> 12.00
 )
1 test, 0 passed, 1 failed

The test was wrong, not the code — the returned order should not count. A diff that names the differing key is the difference between a two-second fix and reading both maps by eye.

Add a clue to an assertion that would otherwise be cryptic:

    assert(total > 0, s"expected positive total for $orders")

Fixtures

class OrdersSuite extends munit.FunSuite:

  val tempFile = FunFixture[java.nio.file.Path](
    setup = _ => java.nio.file.Files.createTempFile("orders", ".csv"),
    teardown = f => java.nio.file.Files.deleteIfExists(f),
  )

  tempFile.test("writes a header row"): path =>
    java.nio.file.Files.writeString(path, "id,amount\n1001,25.50\n")
    val lines = java.nio.file.Files.readAllLines(path)
    assertEquals(lines.get(0), "id,amount")
    assertEquals(lines.size, 2)
OrdersSuite:
  + writes a header row 0.042s
1 test, 1 passed

teardown runs whether the test passes or fails, so a failing test still cleans up. For something expensive and shared, override beforeAll / afterAll instead — and keep it read-only, because MUnit runs suites in parallel by default.

Async tests

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

class AsyncSuite extends munit.FunSuite:

  def fetchTotal(customerId: Int): Future[BigDecimal] =
    Future(BigDecimal("77.50"))

  test("returns the customer total"):
    fetchTotal(1).map: total =>
      assertEquals(total, BigDecimal("77.50"))

  test("fails for an unknown customer"):
    val f = Future(throw new NoSuchElementException("no customer 99"))
    f.failed.map: e =>
      assertEquals(e.getMessage, "no customer 99")
AsyncSuite:
  + returns the customer total 0.104s
  + fails for an unknown customer 0.008s
2 tests, 2 passed

Return the Future and MUnit waits for it. No Await, and a failed future fails the test rather than passing silently — which is what happens if you forget the return and the test body evaluates to Unit.

Raise the limit for a genuinely slow test:

  override val munitTimeout = scala.concurrent.duration.Duration(60, "s")

Property-based tests

Example tests check the cases you thought of. Property tests check the ones you did not:

//> using dep org.scalameta::munit-scalacheck::1.0.0

import org.scalacheck.Prop.*
import org.scalacheck.Gen

class ShippingPropSuite extends munit.ScalaCheckSuite:

  property("cost is never negative"):
    forAll(Gen.posNum[Double]): w =>
      Shipping.cost(w) >= BigDecimal(0)

  property("express never costs less than standard"):
    forAll(Gen.choose(0.1, 9.9)): w =>
      Shipping.cost(w, express = true) >= Shipping.cost(w)

  property("revenue equals the sum of completed orders"):
    val genOrder = for
      id      <- Gen.posNum[Int]
      country <- Gen.oneOf("GB", "US", "NL")
      status  <- Gen.oneOf("completed", "returned", "pending")
      amount  <- Gen.choose(1, 10000).map(c => BigDecimal(c) / 100)
    yield Order(id, country, status, amount)

    forAll(Gen.listOf(genOrder)): orders =>
      val expected = orders.filter(_.status == "completed").map(_.amount).sum
      Shipping.revenueByCountry(orders).values.sum == expected
ShippingPropSuite:
  + cost is never negative 0.182s
  + express never costs less than standard 0.094s
  + revenue equals the sum of completed orders 0.311s
3 tests, 3 passed

Each property ran 100 generated cases. When one fails, ScalaCheck shrinks the input to the smallest example that still fails:

==> X ShippingPropSuite.express never costs less than standard  0.121s
Failing seed: 9kL2mQx8vT1nR4pY7wZ3aB6cD5eF0gH2jK8lM4nP1qS
falsified after 12 successful property evaluations.
> ARG_0: 10.0
> ARG_0_ORIGINAL: 7823.4419

The original counterexample was 7823.44; the shrunk one is 10.0 — the exact boundary where free shipping starts. That is the value a human would have written a test for, found automatically.

The failing seed reproduces the run:

scala-cli test . -- -Dscalacheck.seed=9kL2mQx8vT1nR4pY7wZ3aB6cD5eF0gH2jK8lM4nP1qS

Running a subset

scala-cli test . --test-only 'ShippingSuite'
ShippingSuite:
  + light parcels cost the base rate 0.020s
  + heavy standard parcels ship free 0.001s
4 tests, 4 passed
  test("known broken".ignore) { ??? }
  test("slow".tag(munit.Slow)) { ??? }
  test("only this one".only) { ??? }
  + light parcels cost the base rate 0.019s
  - known broken (ignored)

.only is convenient and dangerous — committed, it silently reduces CI to one test. MUnit fails the suite if .only is present and the munitFlakyOK-style CI flag is set; simplest is a lint rule or a grep in the pipeline.

ScalaTest, which you will also meet

//> using dep org.scalatest::scalatest::3.2.19

import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers

class ShippingSpec extends AnyFunSuite with Matchers:
  test("light parcels cost the base rate") {
    Shipping.cost(1.5) shouldBe BigDecimal("3.99")
  }
  test("negative weight is rejected") {
    an[IllegalArgumentException] should be thrownBy Shipping.cost(-1)
  }
ShippingSpec:
- light parcels cost the base rate
- negative weight is rejected
Run completed in 312 milliseconds.
Total number of tests run: 2
All tests passed.

Same tests, a matcher DSL, and several suite styles to choose from. Prefer MUnit for new work and read ScalaTest fluently, because most existing Scala codebases use it.

Practice

1. Write a failing assertion on a Map and read the diff.
=> Diff (- obtained, + expected)
 Map(
-  "GB" -> 25.50,
+  "GB" -> 65.50,
   "US" -> 12.00
 )

Matching entries are shown as context and only the differing one is marked. On a map with thirty keys this is the entire value of the framework.

2. Test a Future by returning it.
test("returns the customer total"):
  fetchTotal(1).map(total => assertEquals(total, BigDecimal("77.50")))
+ returns the customer total 0.104s

Now drop the .map and assert after an Await — it works but blocks a thread. Returning the future keeps the test honest about the code being asynchronous.

3. Write a property that fails and read the shrunk case.
falsified after 12 successful property evaluations.
> ARG_0: 10.0
> ARG_0_ORIGINAL: 7823.4419

Shrinking turned a random 7823.44 into the boundary value 10.0. Boundaries are where bugs live, and this is a machine finding them without being told where to look.

4. Add a fixture that cleans up after a failing test.
==> X OrdersSuite.writes a header row  0.038s
1 test, 0 passed, 1 failed
ls /tmp/orders*.csv
ls: cannot access '/tmp/orders*.csv': No such file or directory

The temp file is gone even though the test failed — teardown runs regardless. A cleanup written as the last line of the test body would have been skipped.

Next: the type system — generics, variance, and opaque types.

Frequently Asked Questions

Which test framework should I use in Scala?
MUnit for new projects — it is small, has clear diffs, and needs no DSL vocabulary. ScalaTest is the incumbent with more styles and matchers, and you will meet it in existing codebases. Both integrate with sbt, scala-cli and every CI.
How do I test a Future without blocking?
Return the `Future` from the test body. MUnit and ScalaTest's async styles both accept a `Future[Any]` and wait for it themselves, so you keep the composition and never call `Await` in a test.
What is property-based testing?
Instead of asserting on examples, you state a property that must hold for all inputs and let the framework generate hundreds of cases. When one fails it shrinks the input to the smallest failing case, which is usually the empty list, zero, or a boundary you forgot.
How do I share setup between tests in MUnit?
Use `FunFixture` for per-test setup and teardown, which runs even when the test fails. For expensive resources shared across a suite, override `beforeAll` and `afterAll`, and make sure the resource is read-only if tests run in parallel.