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.
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.1For 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.
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.
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. AComparetype class constrains the two types, so comparing values of unrelated types (anIntwith aString) 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-Yrangeposcompiler option.intercept[E](body)andinterceptMessage[E](msg)(body): the test fails unless the body throwsE(with that message).assertMatches,assertNotEquals,assert(cond, hint)andfail(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 failssbt "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 toleratedBy 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.sleepinside Futures onExecutionContext.globalstarves other tests and hits the timeout. Use a virtual clock or a dedicated pool. - Missing
-Yrangeposon Scala 2.clueoutput 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:
| MUnit | ScalaTest | |
|---|---|---|
| Styles | One: test("name") | Many: FunSuite, FlatSpec, WordSpec, ... |
| Assertions | Methods with diffs; type-checked assertEquals | assert macro plus a large matcher DSL |
| Async | Return a Future; IO via munit-cats-effect | Separate Async* suite styles |
| Dependencies | None beyond JUnit interface on the JVM | More; modular artifacts |
| IDE support | Through JUnit | Dedicated 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
- Add
munitas a test dependency and write one suite for a pure function, usingassertEqualswith obtained first andclueon the inputs. - Move resource setup out of suite constructors into
FunFixtureorFixture; give shared-fixture tests unique keys. - Make every async test return its Future or IO, lower
munitTimeoutto something realistic, and enable discarded-value warnings. - Add one property with
munit-scalacheckfor a function with an invariant (idempotence or round-tripping are good first choices). - Tag slow tests, exclude them from the default run, and run them in a nightly job.
- Add CI checks for committed
.onlyand scopeMUNIT_FLAKY_OKto the jobs that need it. - Put shared overrides (timeout, transforms) in one base trait that every suite extends.