MUnit Async, Fixtures & Other Frameworks
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Scala 3.10 (3.10.0, released 2026-10-12) with Scala CLI 1.17.1 on JDK 26. Every MUnit and ScalaTest example was
compiled and run with scala test on that setup (checked on 2026-10-16) against the Scala Toolkit 0.9.2, which brings MUnit 1.2.1, and the output
shown is the output of those runs, with the long stack traces cut. The one example that needs MUnit 1.3.6 (the FutureFixture section) was run
with that version. Nothing here is specific to 3.10: the code compiles unchanged on the 3.9 LTS and 3.3 LTS lines. The sbt build files are shown
but were not run here: sbt is not installed on the machine used to prepare this page.
Testing with MUnit introduced the Toolkit, suites, assertions, running one test and testing exceptions. This page covers the rest of the official Toolkit testing guide: asynchronous tests, fixtures, and the "what else" features (clues, environment-specific and flaky tests). It then steps outside the Toolkit with ScalaTest, the other widely used test framework, and the sbt tutorial that the Scala documentation uses for it. MUnit itself is a third-party library (scalameta.org/munit) that the Scala Toolkit bundles; ScalaTest (scalatest.org) is also third-party. Property-based tests have their own page, Property-Based Testing. The Java and Groovy counterparts are Testing with JUnit 5 and Mockito and Testing with Spock & Geb.
Getting MUnit
The tests below are Scala CLI test files, so the first lines are directives. A test file is one whose name ends in .test.scala or that lives in a
test directory, and test.toolkit puts the Toolkit on the test classpath only:
//> using scala 3.10.0
//> using test.toolkit 0.9.2
To depend on MUnit alone, without the rest of the Toolkit, use //> using test.dep org.scalameta::munit:1.2.1. In sbt the same two choices are
"org.scala-lang" %% "toolkit-test" % "0.9.2" % Test or "org.scalameta" %% "munit" % "1.2.1" % Test; in Mill, a test module that mixes in
TestModule.Munit. The Toolkit pages show the older versions Toolkit 0.7.0 and MUnit 1.1.0 with
Scala 3.4.2; the current Toolkit is 0.9.2, and MUnit has since moved on to 1.3.6, which the Toolkit does not yet bundle. The build-file details are on
Scala CLI and sbt & Build Tools.
Asynchronous tests
In Scala an asynchronous operation usually returns a Future (see Futures & Promises).
MUnit’s rule is simple: a test body may return a Future[Unit], MUnit waits for it to complete in the background, and the test fails if the future fails.
An assertion that throws inside a map or a for comprehension fails the future, and so it fails the test. The Toolkit guide uses an asynchronous
square method; the example below adds the failure modes and the timeout.
//> using scala 3.10.0
//> using test.toolkit 0.9.2
import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.duration.*
object AsyncMathLib:
def square(x: Int)(using ExecutionContext): Future[Int] =
Future(x * x)
class AsyncMathLibTests extends munit.FunSuite:
import scala.concurrent.ExecutionContext.Implicits.global
override val munitTimeout: Duration = 1.second
test("square") {
for
squareOf3 <- AsyncMathLib.square(3)
squareOfMinus4 <- AsyncMathLib.square(-4)
yield
assertEquals(squareOf3, 9)
assertEquals(squareOfMinus4, 16)
}
test("an expected failure of the future") {
Future.failed[Int](IllegalStateException("closed")).failed.map: error =>
assertEquals(error.getMessage, "closed")
}
test("a failing assertion inside the future") {
AsyncMathLib.square(5).map(n => assertEquals(n, 26))
}
test("a dropped future is never checked") {
AsyncMathLib.square(5).foreach(n => assertEquals(n, 26))
}
test("a future that is too slow") {
Future:
Thread.sleep(3000)
}
Running scala test . printed, with stack frames omitted:
AsyncMathLibTests:
+ square 0.064s
+ an expected failure of the future 0.001s
==> X AsyncMathLibTests.a failing assertion inside the future 0.014s munit.ComparisonFailException: Async.test.scala:31
30: test("a failing assertion inside the future") {
31: AsyncMathLib.square(5).map(n => assertEquals(n, 26))
32: }
values are not the same
=> Obtained
25
=> Diff (- expected, + obtained)
-26
+25
+ a dropped future is never checked 0.001s
==> X AsyncMathLibTests.a future that is too slow 1.003s java.util.concurrent.TimeoutException: test timed out after 1 second
Error: munit tests failed.
Four things to read from that output:
-
The
forcomprehension in the first test sequences the two futures and then asserts, exactly like the guide. Theyieldblock is the last expression, so itsUnitresult becomes theFuture[Unit]MUnit waits for. -
To assert that a future fails, turn the failure into a value with
failed, which gives aFuture[Throwable], and assert on that.intercepttakes a by-name block and cannot wait for a future, so it is not the tool here. -
The "dropped future" test passed, although
26is wrong.foreachreturnsUnitimmediately, so the test body finished before the assertion ran. The failed assertion was thrown on another thread and Java printed it as an uncaught exception in the middle of the output, which is where themunit.ComparisonFailExceptiontrace came from. The rule is to usemap/flatMap/forand to return the resulting future, neverforeachor a fire-and-forgetFuture { ... }. -
munitTimeoutis the time MUnit waits for a test to finish; the default is 30 seconds. Overriding it on the suite, as above, changes it for every test in the suite. The slow test failed with aTimeoutExceptionafter one second even though its future would still be sleeping.
The execution context in the example is ExecutionContext.Implicits.global, imported inside the class. In Scala 2 the helper takes
(implicit ec: ExecutionContext) instead of (using ExecutionContext), and the class body and for comprehension use braces; apart from that the code
is the same. The Toolkit guide itself shows the import above the class.
Other result types with value transforms
A test body can return any type as long as MUnit knows how to turn it into a Future. The mechanism is the list munitValueTransforms; the default
list already knows Future. To make a type that is not covered acceptable, add a ValueTransform
to the list. The example below makes scala.util.Try a legal test result, so that a Failure fails the test:
import scala.util.Try
import scala.concurrent.Future
class TryTests extends munit.FunSuite:
override def munitValueTransforms =
super.munitValueTransforms ++ List(
new ValueTransform("Try", { case t: Try[?] => Future.fromTry(t) })
)
test("a Try is a test result too") {
Try(assertEquals(1, 2))
}
The "what else" section below runs this transform in a suite, with equal values (the test passed) and with different values (a ComparisonFailException). The MUnit documentation shows the same hook used for a custom effect type such as a Task or an
IO, and the third-party effect libraries ship ready-made suites that do this. Source:
Scala Toolkit: How to write asynchronous tests? and
MUnit: Declare async test.
Fixtures: managing the resources of a test
A fixture creates a resource before a test and releases it afterwards. MUnit has several kinds, and the Toolkit guide recommends the first:
| Kind | Lifetime | Use it when |
|---|---|---|
|
One resource per test; setup before, teardown after (also after a failure). |
Each test needs its own isolated resource: a temporary file, a table, a connection. The guide’s recommended type. |
|
One instance per suite, with |
The resource is expensive to create and tests only read it, such as a server started once. |
Ad-hoc fixtures |
Overrides of |
A one-off bit of setup that is not worth a reusable class. |
|
The same lifetimes, with |
Setup is asynchronous, for example starting a service and waiting for it. |
FunFixture
FunFixture is built from a setup function that receives the TestOptions of the test (so it can use the name) and returns the resource, and a
teardown function that releases it. fixture.test("name") { resource => ... } declares a test that receives the resource. FunFixture.map2 combines
two fixtures into a FunFixture[(A, B)]. The example records every lifecycle step in a log that the suite prints in afterAll, so the order is visible:
//> using scala 3.10.0
//> using test.toolkit 0.9.2
import scala.collection.mutable.ListBuffer
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
class FixtureTests extends munit.FunSuite:
val log = ListBuffer.empty[String]
val usingTempFile: FunFixture[os.Path] = FunFixture(
setup = options =>
log += s"setup (${options.name})"
os.temp(prefix = "file-tests")
,
teardown = tempFile =>
log += "teardown"
os.remove(tempFile)
)
usingTempFile.test("overwrite on file") { tempFile =>
os.write.over(tempFile, "Hello, World!")
assertEquals(os.read(tempFile), "Hello, World!")
}
usingTempFile.test("teardown runs after a failure") { tempFile =>
os.write.over(tempFile, "abc")
assertEquals(os.size(tempFile), 4L)
}
val using2TempFiles: FunFixture[(os.Path, os.Path)] =
FunFixture.map2(usingTempFile, usingTempFile)
using2TempFiles.test("merge two files") { (file1, file2) =>
os.write.over(file1, "a")
os.write.over(file2, "b")
assertEquals(os.read(file1) + os.read(file2), "ab")
}
val usingAsyncValue: FunFixture[Int] = FunFixture.async(
setup = _ => Future { log += "async setup"; 21 },
teardown = _ => Future { log += "async teardown"; () }
)
usingAsyncValue.test("async setup and teardown") { n =>
assertEquals(n * 2, 42)
}
// reusable fixture: one instance for the whole suite
val sharedCounter = new Fixture[Array[Int]]("sharedCounter"):
private val cell = Array(0)
def apply(): Array[Int] = cell
override def beforeAll(): Unit = log += "fixture beforeAll"
override def beforeEach(context: BeforeEach): Unit = cell(0) += 1
override def afterEach(context: AfterEach): Unit = log += s"fixture afterEach (${context.test.name})"
override def afterAll(): Unit = log += "fixture afterAll"
override def munitFixtures = List(sharedCounter)
test("the counter counts every test of the suite") {
assertEquals(sharedCounter()(0), 5)
}
override def beforeAll(): Unit = log += "suite beforeAll"
override def afterAll(): Unit =
log += "suite afterAll"
println(log.mkString("\n"))
The output of scala test . was the printed log first, because MUnit prints the report only when the suite is finished, and then the report:
suite beforeAll
fixture beforeAll
setup (overwrite on file)
teardown
fixture afterEach (overwrite on file)
setup (teardown runs after a failure)
teardown
fixture afterEach (teardown runs after a failure)
setup (merge two files)
setup (merge two files)
teardown
teardown
fixture afterEach (merge two files)
async setup
async teardown
fixture afterEach (async setup and teardown)
fixture afterEach (the counter counts every test of the suite)
fixture afterAll
suite afterAll
FixtureTests:
+ overwrite on file 0.024s
==> X FixtureTests.teardown runs after a failure 0.009s munit.ComparisonFailException: Fixtures.test.scala:28
27: os.write.over(tempFile, "abc")
28: assertEquals(os.size(tempFile), 4L)
29: }
values are not the same
=> Obtained
3
=> Diff (- expected, + obtained)
-4
+3
+ merge two files 0.003s
+ async setup and teardown 0.003s
+ the counter counts every test of the suite 0.001s
Error: munit tests failed.
What the run shows:
-
setupran once per test, and the second test’steardownran although its assertion failed (the file containsabc, three bytes, not four). Teardown is the place for cleanup because it always runs. Theos.tempandos.removecalls come from the Toolkit’s OS-Lib, see Files & Processes with OS-Lib. -
FunFixture.map2(usingTempFile, usingTempFile)calledsetuptwice andteardowntwice for the one test, and gave the test a tuple. The guide notes that composing aFunFixture[A]and aFunFixture[B]yields aFunFixture[(A, B)]. -
FunFixture.asyncaccepts functions that return aFuture, so the setup can wait for something that starts in the background. -
A
Fixturelisted inmunitFixturesgets itsbeforeEachandafterEachhooks for every test in the suite, including tests that do not use it, which is why the counter reads5in the last test: the five tests before and including it each incremented it. The suite-levelbeforeAll/afterAllran outermost, the fixture hooks inside them. -
The
FunFixturesetup and teardown are invoked around the body of the one test that uses the fixture; the reusable fixture’s hooks surround every test.
The guide gives the reason to prefer FunFixture: it is explicit (each test declares the resource it needs) and safe (each test has its own resource, so
tests cannot interfere). The shared state of a Fixture is mutable, so keep it for read-mostly resources. Create expensive resources in beforeAll, not in the
class constructor, as the MUnit documentation advises, because the constructor runs even when the suite is filtered out. Source:
Scala Toolkit: How to manage the resources of a test? and
MUnit: Fixtures.
Asynchronous fixtures with FutureFixture
For lifecycle hooks that return a Future, MUnit 1.3.6 adds FutureFixture[T], a Fixture whose beforeAll, beforeEach, afterEach and afterAll
return Future[Unit]. The MUnit documentation marks it as available only in 1.3.6. That version is newer than the one the Toolkit 0.9.2 pins (MUnit 1.2.1),
so it needs an explicit dependency, //> using test.dep org.scalameta::munit:1.3.6, instead of test.toolkit. This example was run with 1.3.6:
//> using scala 3.10.0
//> using test.dep org.scalameta::munit:1.3.6
import munit.FutureFixture
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
class FutureFixtureTests extends munit.FunSuite:
val connection = new FutureFixture[String]("connection"):
private var value = "closed"
def apply(): String = value
override def beforeAll(): Future[Unit] = Future { Thread.sleep(50); value = "open" }
override def afterAll(): Future[Unit] = Future { value = "closed" }
override def munitFixtures = List(connection)
test("the asynchronous fixture was opened before the test") {
assertEquals(connection(), "open")
}
Test run FutureFixtureTests started
FutureFixtureTests: finished 0.071s
Test run FutureFixtureTests finished: 0 failed, 0 ignored, 1 total 0.075s
The report format differs from the MUnit 1.2.1 runs above; the test passed. On the Toolkit’s MUnit 1.2.1 the equivalent is FunFixture.async for per-test resources, or doing the waiting yourself inside a synchronous
beforeAll. Source: MUnit: Fixtures.
What else can MUnit do?
The last Toolkit testing page lists three smaller features. All three appear in the suite below, together with the neighbours the MUnit documentation puts next
to them (ignore, fail):
//> using scala 3.10.0
//> using test.toolkit 0.9.2
import scala.util.{Properties, Try}
import scala.concurrent.Future
class EnvironmentTests extends munit.FunSuite:
val a = 1
val b = 2
test("a clue explains a failed assertion") {
assert(clue(List(a).head) > clue(b))
}
test("only on Linux") {
assume(Properties.isLinux, "this test runs only on Linux")
assert(os.home.toString.startsWith("/home/"))
}
test("only when a variable is set") {
assume(sys.env.contains("TRAILHEAD_CI"), "TRAILHEAD_CI is not set")
assert(true)
}
test("requests".flaky) {
assertEquals(a, b)
}
test("work in progress".ignore) {
assertEquals(a, b)
}
test("known bug".fail) {
assertEquals(a, b)
}
On macOS, without the variable, scala test . printed (stack frames omitted):
EnvironmentTests:
==> X EnvironmentTests.a clue explains a failed assertion 0.029s munit.FailException: Env.test.scala:12 assertion failed
11: test("a clue explains a failed assertion") {
12: assert(clue(List(a).head) > clue(b))
13: }
Clues {
List(a).head: Int = 1
b: Int = 2
}
==> s EnvironmentTests.only on Linux skipped 0.008s
==> s EnvironmentTests.only when a variable is set skipped 0.003s
==> X EnvironmentTests.requests 0.008s munit.ComparisonFailException: Env.test.scala:26
25: test("requests".flaky) {
26: assertEquals(a, b)
27: }
values are not the same
=> Obtained
1
=> Diff (- expected, + obtained)
-2
+1
==> i EnvironmentTests.work in progress ignored 0.0s
+ known bug 0.002s
Error: munit tests failed.
Clues
A plain assert(x > y) only says that the assertion failed. Wrapping the sub-expressions in clue(...) makes MUnit print their source text, type and value
in a Clues block, as above (List(a).head: Int = 1). clue works inside assert and the other assertion methods, on Scala 2 and Scala 3 alike.
Environment-specific tests with assume
assume(condition, message) skips the test when the condition is false instead of failing it. The report marks the test with s and the word skipped. Any
boolean can be used: the operating system (Properties.isLinux, Properties.isMac), the Java version, a system property or, as in the second test, an
environment variable. Run with TRAILHEAD_CI=1, the second test is executed. Selecting one test from the command line is a regular expression passed to MUnit after
--, with the variable set:
TRAILHEAD_CI=1 scala test . -- '--tests=.*when a variable.*'
EnvironmentTests:
+ only when a variable is set 0.02s
The other tests of the suite appear as ignored in that run. Without any filter, tests can also be excluded for a whole suite, by a tag or by a dynamic condition
(munitIgnore), which the MUnit page on filtering describes. The Toolkit guide’s own example asserts that os.home starts with /home/ and is therefore skipped on a
Mac; the version above keeps the same shape.
Flaky tests
"name".flaky marks a test as flaky. A flaky test that fails does not break the build when the environment variable MUNIT_FLAKY_OK is true; the test is reported
as skipped. Run with the variable set, the same suite printed:
==> s EnvironmentTests.requests skipped 0.007s
Without it the failure above is reported like any other. Flaky is a quarantine, not a fix: it fits the I/O-heavy tests that fail on a busy build server, and the
tag should come with a ticket. MUnit can also re-evaluate a body until its assertions stop failing with eventually(), documented under "Retry failing assertions". The related markers are .ignore,
which skips the test and reports it as ignored (==> i), and .fail, which inverts the result: a test that should fail passes while the bug exists and starts
failing, with expected failure but test passed, when somebody fixes it. That is a convenient way to pin a known bug.
Tests that return a Try
The ValueTransform from the earlier section was added to the EnvironmentTests suite as one more test, a Try is a test result too, which passed with equal values. With Try(assertEquals(a, b)) in its place the test failed with
==> X EnvironmentTests.a Try is a test result too 0.002s munit.ComparisonFailException: Env.test.scala:38
values are not the same
=> Obtained
1
=> Diff (- expected, + obtained)
-2
+1
so the failure inside the Try was not swallowed. Source: Scala Toolkit: What else can MUnit do?,
MUnit: Tests and MUnit: Filtering tests.
Other test frameworks
MUnit is the framework the Toolkit chose, but it is not the only one, and a project you join may use another. The official "Testing Scala with sbt" tutorial uses
ScalaTest, and its closing paragraph points at ScalaCheck and Specs2 as further options. ScalaTest is the best known. Its styles are chosen by the suite you
extend (AnyFunSuite for the test("...") style, plus AnyFlatSpec, AnyWordSpec, AnyFreeSpec, AnyFunSpec, AnyFeatureSpec and AnyPropSpec; the user guide’s
"Selecting a style" page compares them), and its assertions come from the Assertions, Matchers and Inspectors traits.
ScalaTest with Scala CLI
ScalaTest (here 3.2.20, published 2026-03-25 on Maven Central) is a normal library for Scala CLI. There is no Toolkit flag for it, only a dependency, restricted to the
test scope with test.dep. The example is the tutorial’s CubeCalculator in two files. Cube.scala:
//> using scala 3.10.0
object CubeCalculator:
def cube(x: Int): Int = x * x * x
and CubeCalculatorTest.test.scala, with a deliberately wrong last expectation (-2 cubed is -8):
//> using test.dep org.scalatest::scalatest:3.2.20
import org.scalatest.funsuite.AnyFunSuite
class CubeCalculatorTest extends AnyFunSuite:
test("CubeCalculator.cube 3 should be 27") {
assert(CubeCalculator.cube(3) === 27)
}
test("CubeCalculator.cube 0 should be 0") {
assert(CubeCalculator.cube(0) === 0)
}
test("CubeCalculator.cube -2 should be -9") {
assert(CubeCalculator.cube(-2) === -9)
}
scala test . downloaded ScalaTest from Maven Central and printed:
CubeCalculatorTest:
- CubeCalculator.cube 3 should be 27
- CubeCalculator.cube 0 should be 0
- CubeCalculator.cube -2 should be -9 *** FAILED ***
-8 did not equal -9 (CubeCalculatorTest.test.scala:15)
Run completed in 76 milliseconds.
Total number of tests run: 3
Suites: completed 1, aborted 0
Tests: succeeded 2, failed 1, canceled 0, ignored 0, pending 0
*** 1 TEST FAILED ***
Error: ScalaTest tests failed.
Source: Getting Started: Testing Scala with sbt and ScalaTest on the Command Line, understanding tests and Scala CLI: test command, test framework
Scala CLI detects the framework on the classpath, so the same scala test command runs MUnit, ScalaTest and the other frameworks that implement the sbt test
interface. In the tutorial’s code === is ScalaTest’s equality check, which gives the message -8 did not equal -9; the plain == also works but the message is
less informative. ScalaTest tests that return a Future need AsyncFunSuite; they do not share MUnit’s rule that any test body may return one.
The sbt track
The Scala documentation’s getting-started tutorial for tests, Testing Scala with sbt and ScalaTest on the Command Line, follows the sbt route: sbt new
scala/scalatest-example.g8 creates a project named ScalaTestTutorial, sbt test runs the generated CubeCalculatorTest, and the tutorial then adds a second test
and runs sbt test again. It is the same code as above. The relevant part of the build file is:
// build.sbt (not run here: sbt is not installed on the machine used to prepare this page)
scalaVersion := "3.10.0"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.20" % Test
and the tutorial’s output is the same list of test names followed by All tests passed., Tests: succeeded 2, failed 0 and the elapsed time. Three details of that
page are out of date and should not be copied: the sample output shows sbt 0.13 and [info] Loading global plugins from /Users/.../.sbt/0.13/plugins; the test
class uses the Scala 2 brace style; and the second output shows a scala-2.13 build directory.
The sbt tutorial and the general build tooling are covered on sbt & Build Tools.
Choosing a framework
| Framework | Character | Use it when |
|---|---|---|
MUnit |
Small, minimal API; fixtures and |
You start a new project or a script and want the default. |
ScalaTest |
Many test styles, rich matchers, long history, large user base. |
The project already uses it, or you need its matcher DSL or a particular style. |
ScalaCheck (see Property-Based Testing) |
Property-based testing: generated inputs and shrinking. |
You want to test invariants rather than examples; it combines with MUnit or ScalaTest. |
Specs2, and effect-library suites |
Specification-style tests, or suites for a particular effect system. |
The project is built around that library. They are named here as pointers and are not covered further. |
For the JUnit-based path in Java, see Java Build & Tooling; the Groovy equivalent is Testing with JUnit & Groovy Test.
Where the sources are outdated
-
The Toolkit testing pages show
//> using toolkit latest,toolkit-test0.7.0, MUnit 1.1.0 and Scala 3.4.2. Use Toolkit 0.9.2 (MUnit 1.2.1), or MUnit 1.3.6 directly, and a pinned Scala version such as 3.10.0 in place oflatest. -
The Toolkit guide’s
munitFixtures,FunFixtureandassumeexamples are unchanged and run as written on 3.10.0.FutureFixtureis documented by MUnit only for 1.3.6 and does not exist in the Toolkit’s MUnit 1.2.1. -
The sbt/ScalaTest tutorial shows ScalaTest with
sbt 0.13output and Scala 2 braces, as noted above. The dependency to use today isscalatest3.2.x. -
The Scala 2 forms of the examples use braces after the class and
implicitin place ofusing; everything else is the same.
Pitfalls
-
Dropping the future with
foreachor a bareFuture { ... }makes the test pass without checking anything. Return the future. -
A fixture’s
beforeEach/afterEachruns for every test in the suite, not only for the ones that use it. -
Starting a resource in the suite constructor runs it even for tests that are filtered out; use
beforeAll. -
.flakyhides failures only whenMUNIT_FLAKY_OKis set; a CI job that sets it permanently hides real regressions. -
Filter patterns passed with
--tests=are regular expressions, not globs:*when*fails withDangling meta character '*'; write.*when.*.