MUnit is a Scala test library from the Scalameta project. It has one way to write a test, test("name") { ... }, plain assertion methods that print a readable diff when values differ, and a small set of hooks for fixtures, async code and custom behaviour. It has no Scala dependencies of its own, so it cross-builds for the JVM, Scala.js and Scala Native, and on the JVM it runs as a JUnit runner, which is why IDEs and build tools understand it without plugins.

Choosing between MUnit, ScalaTest and ZIO Test is covered in Scala testing in depth. This page is about MUnit itself: how a suite runs, what each assertion does, fixtures at test and suite scope, async and cats-effect tests, property tests, tags and filtering, extension hooks, the mistakes that make MUnit suites lie, and moving over from ScalaTest. The examples build on one small codebase: a slug function, a file-backed store, a price client and a rate limiter.

Advertisement

Setting up MUnit

Add one test dependency. The version used here, 1.2.1, was the newest on Scaladex when this was checked; use the current one.

// sbt (sbt 1.5.0+ detects MUnit; older sbt also needs
// testFrameworks += new TestFramework("munit.Framework"))
libraryDependencies += "org.scalameta" %% "munit" % "1.2.1" % Test

// scala-cli, at the top of a test file
//> using test.dep org.scalameta::munit::1.2.1

For Scala.js or Native in sbt, use %%% instead of %%. Integrations ship as separate artifacts with their own versions: munit-scalacheck from org.scalameta and munit-cats-effect from org.typelevel. Build setup in general is in sbt in depth.

How a suite runs

A suite is a class extending munit.FunSuite. Calling test in the class body registers a test; nothing runs yet. The runner then constructs the suite, runs suite-level setup, and for each test runs per-test setup, the body, and per-test teardown. Whatever the body returns goes through a list of value transforms that turn it into a Future, and the runner waits for that Future up to munitTimeout, which defaults to 30 seconds.

sbt / IDE / MillJUnit runner APISuite instanceclass constructedbeforeAllsuite-local fixturesbeforeEachtest-local fixturesTest bodyreturns A, Future, IOValue transformsanything to FutureAwaitmunitTimeout (30 s)afterEachalways runsafterAllafter last testlast testnext testEvery test body becomes a Future; the runner waits for it with a timeout.
The life of an MUnit suite. Each test body becomes a Future through value transforms, and the runner awaits it with a timeout.

Two consequences follow. First, async support is not a separate suite type: a test that returns a Future is simply awaited. Second, the documentation warns that test classes may be constructed even when none of their tests run, for example during discovery or filtering. Never do real work in the class body. Start servers and open connections in beforeAll or a fixture.

Advertisement

Worked example: assertions and what each one is for

The worked example starts with a pure function and its suite:

object Slug:
  def of(title: String): String =
    title.trim.toLowerCase
      .replaceAll("[^a-z0-9]+", "-")
      .stripPrefix("-").stripSuffix("-")

class SlugSuite extends munit.FunSuite:
  test("joins words with single hyphens") {
    assertEquals(Slug.of("Delta Lake, in Depth"), "delta-lake-in-depth")
  }
  test("strips leading and trailing separators") {
    val input = "  --MUnit!  "
    assertEquals(Slug.of(clue(input)), "munit")
  }
  test("only punctuation gives an empty slug") {
    assertEquals(Slug.of("!!!"), "")
  }
  test("parse errors keep their message") {
    interceptMessage[NumberFormatException]("For input string: \"abc\"")("abc".toInt)
  }

Each assertion has a precise job:

  • assertEquals(obtained, expected): obtained comes first. On failure MUnit prints both values and a diff, which for case classes and long strings points at the exact field or character. A Compare type class constrains the two types, so comparing values of unrelated types (an Int with a String) is a compile error instead of a test that can never pass.
  • assertNoDiff(obtained, expected): for multi-line strings such as generated SQL or rendered output. It ignores whitespace and line-ending differences and prints the obtained value in a form you can paste back into the test.
  • clue(x): wraps an expression so its source text and value appear in the failure report. On Scala 2 it needs the -Yrangepos compiler option.
  • intercept[E](body) and interceptMessage[E](msg)(body): the test fails unless the body throws E (with that message).
  • assertMatches, assertNotEquals, assert(cond, hint) and fail(msg) cover the rest. assume(cond, why) skips a test when an environment condition is not met, such as the wrong operating system.
  • compileErrors("code"): returns the compiler's error text for a string literal, so you can test that an API rejects misuse. Pin exact messages sparingly; they change between compiler versions.

Fixtures: per test and per suite

Fixtures give tests resources with guaranteed cleanup. Use the smallest scope that works.

Per test, functional. FunFixture passes the resource to the body as an argument, so a test cannot forget to use it. Here a hypothetical file-backed FileStore gets a fresh directory per test:

import java.nio.file.{Files, Path}
import java.util.Comparator

class FileStoreSuite extends munit.FunSuite:
  val dir = FunFixture[Path](
    setup = test => Files.createTempDirectory("store-"),
    teardown = d => Files.walk(d).sorted(Comparator.reverseOrder()).forEach(p => Files.delete(p))
  )
  val twoDirs = FunFixture.map2(dir, dir)

  dir.test("put then get returns the value") { d =>
    val store = FileStore(d)
    store.put("k", "v")
    assertEquals(store.get("k"), Some("v"))
  }
  twoDirs.test("stores in different directories are isolated") { (a, b) =>
    FileStore(a).put("k", "v")
    assertEquals(FileStore(b).get("k"), None)
  }

Per suite. For something expensive, such as an embedded database, extend Fixture[T], start it in beforeAll, stop it in afterAll, return it from apply(), and list it in munitFixtures. The same class with beforeEach and afterEach gives a per-test fixture. FutureFixture is the async variant. For one-off needs you can override the suite's own beforeEach or afterAll directly.

A suite-local resource is shared by every test in the suite. Tests must not depend on each other's leftovers, so give each test its own keys, tables or tenant ids.

Async tests: Futures and cats-effect

Return a Future and MUnit awaits it. Override munitTimeout when 30 seconds is too generous; a hung test should fail fast in CI.

import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.duration.Duration

class PriceClientSuite extends munit.FunSuite:
  given ExecutionContext = ExecutionContext.global
  override val munitTimeout = Duration(5, "s")

  test("second call is served from cache") {
    val backend = FakeBackend(price = 42)
    val client = PriceClient(backend)
    for
      a <- client.price("sku-1")
      b <- client.price("sku-1")
    yield
      assertEquals((a, b), (42, 42))
      assertEquals(backend.calls, 1)
  }

  test("BROKEN: this always passes") {
    PriceClient(FakeBackend(price = 42)).price("sku-1").map(p => assertEquals(p, 0))
    ()   // the Future is discarded, so nothing awaits the failing assertion
  }

The second test is the classic async mistake: the Future holding the assertion is not the test's result, so the test passes before the assertion runs. Make the Future the last expression, and turn on your compiler's warning for discarded non-Unit values so the build catches it.

For cats-effect, add munit-cats-effect and extend CatsEffectSuite. Tests can then return IO directly, with no unsafe run calls. Resources become fixtures through ResourceSuiteLocalFixture:

//> using test.dep org.typelevel::munit-cats-effect::2.2.0
import cats.effect.{IO, Resource}
import munit.CatsEffectSuite

class RateLimiterSuite extends CatsEffectSuite:
  val limiter = ResourceSuiteLocalFixture(
    "limiter",
    Resource.make(RateLimiter.create(permitsPerWindow = 2))(_.shutdown)
  )
  override def munitFixtures = List(limiter)   // the SAME val that the tests use

  test("third request in a window is rejected") {
    val rl = limiter()
    val tenant = "tenant-third-request"          // unique key: the limiter is shared
    for
      a <- rl.tryAcquire(tenant)
      b <- rl.tryAcquire(tenant)
      c <- rl.tryAcquire(tenant)
    yield assertEquals(List(a, b, c), List(true, true, false))
  }

The comment on munitFixtures matters. The integration initialises the fixture object by mutating it, so the reference in munitFixtures must be the one the tests call. Pure-FP habits such as building a new fixture inside a def break this. For effect code in general see cats-effect.

Property tests with munit-scalacheck

Example tests check the cases you thought of. Property tests generate the cases you didn't. With munit-scalacheck, extend ScalaCheckSuite and use property instead of test; MUnit assertions work inside forAll and give the same diffs:

//> using test.dep org.scalameta::munit-scalacheck::1.2.0
import munit.ScalaCheckSuite
import org.scalacheck.Prop.forAll

class SlugProps extends ScalaCheckSuite:
  property("slugging twice changes nothing") {
    forAll { (s: String) => assertEquals(Slug.of(Slug.of(s)), Slug.of(s)) }
  }
  property("output uses only a-z, 0-9 and single inner hyphens") {
    forAll { (s: String) =>
      val out = Slug.of(s)
      assert(out.matches("[a-z0-9]+(-[a-z0-9]+)*|"), out)
    }
  }

When a property fails, the report includes the shrunk counterexample and the seed needed to replay it. Generators and shrinking are covered in ScalaCheck in depth.

Tags, modifiers and filtering

Test names carry modifiers that change how the runner treats them:

val Slow = new munit.Tag("Slow")

test("full reindex of 1M documents".tag(Slow)) { ... }
test("issue-981 reproducer".only) { ... }           // run only this test in the suite
test("old export format".ignore) { ... }
test("streaming parser".pending("PARSER-12")) {}     // work in progress, with a ticket
test("sandbox upstream".flaky) { ... }              // see MUNIT_FLAKY_OK below
test("issue-1234 still reproduces".fail) { ... }          // passes only if the body fails
sbt "testOnly -- --exclude-tags=Slow"         # fast loop
sbt "testOnly -- --include-tags=Slow"         # nightly job
sbt "testOnly *SlugSuite -- *hyphens*"        # one suite, tests matching a glob
MUNIT_FLAKY_OK=true sbt test                  # flaky failures are reported but tolerated

By default a .flaky test fails like any other. Only when MUNIT_FLAKY_OK=true is set (or munitFlakyOK is overridden) does its failure stop failing the build. Set it in the CI jobs that need it, never globally. .fail is useful for documenting a known bug: when someone fixes the bug, the test starts failing and tells them to flip it into a normal test. Suites can be skipped with @IgnoreSuite on the JVM or by overriding munitIgnore.

Extending MUnit with transforms

Two hooks cover most custom needs. munitValueTransforms teaches MUnit to await a result type it doesn't know, such as your own effect type. munitTestTransforms rewrites tests before they run, based on their tags or names:

// Your own lazy effect type, run by converting it to a Future.
override def munitValueTransforms = super.munitValueTransforms ++ List(
  new ValueTransform("Task", { case t: Task[?] => t.runToFuture })
)

// Rerun tests tagged Rerun(n) n times, following the pattern in the MUnit docs.
// Future.sequence needs a given ExecutionContext in scope.
case class Rerun(count: Int) extends munit.Tag("Rerun")
override def munitTestTransforms = super.munitTestTransforms ++ List(
  new TestTransform("Rerun", { test =>
    test.tags.collectFirst { case Rerun(n) => n } match
      case Some(n) if n > 1 =>
        test.withBody(() => Future.sequence(List.fill(n)(test.body())))
      case _ => test
  })
)

Always append to super's list. Replacing it drops MUnit's built-in transforms, including the one that awaits Futures. Put shared overrides in one base trait that all suites extend, so behaviour is consistent across the project.

Failure modes

The ways MUnit suites lie or slow down:

  • Discarded Futures and IOs. The assertion never runs and the test passes. Make the effect the last expression, and enable discarded-value warnings.
  • A committed .only. The other tests in that suite silently stop running. Add a CI check that searches test sources for .only.
  • Global MUNIT_FLAKY_OK. Real regressions in flaky-tagged tests disappear. Scope it to specific jobs and keep a list of flaky tests with owners.
  • Work in constructors. Suites can be constructed without running, so constructor side effects run at odd times and slow discovery. Use fixtures.
  • Shared state in suite fixtures. Tests pass alone and fail together, depending on order. Give every test unique keys.
  • Blocking on the global execution context. Thread.sleep inside Futures on ExecutionContext.global starves other tests and hits the timeout. Use a virtual clock or a dedicated pool.
  • Missing -Yrangepos on Scala 2. clue output loses its source positions and becomes much less useful.

MUnit compared with ScalaTest, and migrating

MUnit's trade-off is a small API in exchange for less expressiveness. Compared with ScalaTest:

MUnitScalaTest
StylesOne: test("name")Many: FunSuite, FlatSpec, WordSpec, ...
AssertionsMethods with diffs; type-checked assertEqualsassert macro plus a large matcher DSL
AsyncReturn a Future; IO via munit-cats-effectSeparate Async* suite styles
DependenciesNone beyond JUnit interface on the JVMMore; modular artifacts
IDE supportThrough JUnitDedicated integrations

Migration is gradual: sbt runs every detected framework, so ScalaTest and MUnit suites can live in one project while you move them one by one. AnyFunSuite tests map almost line for line; assert(a == b) becomes assertEquals(a, b) for the diff; matcher-heavy specs take the most work. If a team relies on BDD-style specs read by non-engineers, ScalaTest is the better fit and migrating has little value.

What to do next

  1. Add munit as a test dependency and write one suite for a pure function, using assertEquals with obtained first and clue on the inputs.
  2. Move resource setup out of suite constructors into FunFixture or Fixture; give shared-fixture tests unique keys.
  3. Make every async test return its Future or IO, lower munitTimeout to something realistic, and enable discarded-value warnings.
  4. Add one property with munit-scalacheck for a function with an invariant (idempotence or round-tripping are good first choices).
  5. Tag slow tests, exclude them from the default run, and run them in a nightly job.
  6. Add CI checks for committed .only and scope MUNIT_FLAKY_OK to the jobs that need it.
  7. Put shared overrides (timeout, transforms) in one base trait that every suite extends.
Key takeaway: MUnit gives Scala one way to write tests, assertions that print useful diffs, and a JUnit-based runner that works with sbt, scala-cli and IDEs on every Scala platform. Every test body becomes a Future that the runner awaits with a timeout, so async tests must return their Future or IO, never discard it. Use FunFixture for per-test resources and suite-local fixtures for expensive ones, add munit-scalacheck and munit-cats-effect as needed, filter with tags, and guard CI against committed .only and blanket MUNIT_FLAKY_OK.