MUnit: The Modern Scala Testing Framework

MUnit: The Modern Scala Testing Framework

MUnit is a Scala testing library from the Scalameta team that has become the default choice for many modern Scala projects — particularly those using Scala 3, Cats Effect, or http4s. It prioritizes readable failure messages, minimal boilerplate, and first-class support for functional effect systems.

Why MUnit Over ScalaTest

ScalaTest's flexibility is also its weakness. With 7 different test styles and a complex trait hierarchy, teams spend energy on configuration rather than tests. MUnit makes one set of choices and commits to them:

  • One test style (flat test("description") blocks)
  • Assertion messages that show the diff, not just "assertion failed"
  • First-class fixtures without inheriting from a dozen traits
  • Native Scala 3 support from day one
  • Lightweight — no macro magic, fast compilation
// ScalaTest
class MySpec extends AnyFlatSpec with Matchers {
  "A Stack" should "pop values in last-in-first-out order" in {
    val stack = new Stack[Int]
    stack.push(1)
    stack.push(2)
    stack.pop() should equal (2)
  }
}

// MUnit — same test
class MySuite extends munit.FunSuite {
  test("Stack pops in LIFO order") {
    val stack = new Stack[Int]
    stack.push(1)
    stack.push(2)
    assertEquals(stack.pop(), 2)
  }
}

The difference shows up when tests fail. MUnit's assertEquals prints:

=> Obtained
2
=> Diff (- obtained, + expected)
-2
+3

Setup

// build.sbt
libraryDependencies += "org.scalameta" %% "munit" % "1.0.0" % Test

// For Cats Effect integration
libraryDependencies += "org.typelevel" %% "munit-cats-effect" % "2.0.0" % Test

Test framework registration (required for sbt):

// build.sbt
testFrameworks += new TestFramework("munit.Framework")

Writing Tests

class StringSuite extends munit.FunSuite {

  test("string concatenation") {
    val result = "hello" + " " + "world"
    assertEquals(result, "hello world")
  }

  test("string length") {
    assert("test".length == 4)
  }

  test("contains check") {
    val haystack = "The quick brown fox"
    assertContains(haystack, "quick")
  }

  test("collection equality") {
    val obtained = List(1, 2, 3).map(_ * 2)
    val expected = List(2, 4, 6)
    assertEquals(obtained, expected)
  }
}

Assertions

MUnit provides focused assertion methods rather than a DSL:

test("assertions overview") {
  // Equality with diff on failure
  assertEquals(obtained, expected)
  assertNotEquals(a, b)

  // Boolean
  assert(condition)
  assert(condition, "custom message")

  // Collections
  assertContains(list, element)

  // Exceptions
  val ex = intercept[IllegalArgumentException] {
    User("") // should throw
  }
  assertEquals(ex.getMessage, "name cannot be empty")

  // No exception thrown
  assertNoDiff(
    obtained = "hello\nworld",
    expected = "hello\nworld"
  )
}

assertNoDiff is particularly useful for multiline string comparison — it shows a unified diff instead of printing both strings in full.

Fixtures

Fixtures handle setup and teardown without trait inheritance:

FunFixture — One-Off Resources

class DatabaseSuite extends munit.FunSuite {

  val withDatabase: FunFixture[DatabaseConnection] = FunFixture(
    setup = _ => DatabaseConnection.create("jdbc:h2:mem:test"),
    teardown = conn => conn.close()
  )

  withDatabase.test("insert and query") { conn =>
    conn.execute("INSERT INTO users VALUES (1, 'alice')")
    val result = conn.query("SELECT name FROM users WHERE id = 1")
    assertEquals(result, List("alice"))
  }

  withDatabase.test("handles duplicate key") { conn =>
    conn.execute("INSERT INTO users VALUES (1, 'alice')")
    intercept[DuplicateKeyException] {
      conn.execute("INSERT INTO users VALUES (1, 'bob')")
    }
  }
}

Suite-Level Fixtures with BeforeEach/AfterEach

class ServerSuite extends munit.FunSuite {

  var server: TestServer = _

  override def beforeEach(context: BeforeEach): Unit = {
    server = TestServer.start(port = 0)  // random port
  }

  override def afterEach(context: AfterEach): Unit = {
    server.stop()
  }

  test("GET /health returns 200") {
    val response = Http.get(s"http://localhost:${server.port}/health")
    assertEquals(response.status, 200)
  }
}

Composed Fixtures

val withTempDir: FunFixture[Path] = FunFixture(
  setup = _ => Files.createTempDirectory("munit-test"),
  teardown = dir => Files.deleteIfExists(dir)
)

val withConfig: FunFixture[Config] = FunFixture(
  setup = _ => Config.load("test.conf"),
  teardown = _ => ()
)

// Compose both
val withDirAndConfig = FunFixture.map2(withTempDir, withConfig)

withDirAndConfig.test("writes config to temp dir") { case (dir, config) =>
  val file = dir.resolve("output.json")
  config.writeTo(file)
  assert(Files.exists(file))
}

Async Tests with Cats Effect

The munit-cats-effect integration makes async test code clean:

import cats.effect.IO
import munit.CatsEffectSuite

class UserServiceSuite extends CatsEffectSuite {

  test("creates user successfully") {
    // Return IO[Unit] directly — no Await.result needed
    for {
      service <- UserService.make[IO]
      user    <- service.create(CreateUserRequest("alice@example.com"))
    } yield {
      assertEquals(user.email, "alice@example.com")
      assert(user.id.nonEmpty)
    }
  }

  test("rejects duplicate email") {
    UserService.make[IO].flatMap { service =>
      for {
        _   <- service.create(CreateUserRequest("bob@example.com"))
        err <- service.create(CreateUserRequest("bob@example.com")).attempt
      } yield {
        assert(err.isLeft)
        assert(err.left.toOption.get.getMessage.contains("duplicate"))
      }
    }
  }
}

Resource Fixtures with Cats Effect

import cats.effect.{IO, Resource}
import munit.CatsEffectSuite

class DatabaseSuite extends CatsEffectSuite {

  val dbResource: Resource[IO, DatabasePool] = Resource.make(
    IO(DatabasePool.create("jdbc:postgresql://localhost/test"))
  )(pool => IO(pool.close()))

  // Suite-scoped fixture — created once, shared across all tests
  val pool: Fixture[DatabasePool] = ResourceSuiteLocalFixture("db-pool", dbResource)

  override def munitFixtures = List(pool)

  test("insert user") {
    val p = pool()
    for {
      _ <- IO(p.execute("INSERT INTO users VALUES (1, 'alice')"))
      r <- IO(p.query("SELECT count(*) FROM users"))
    } yield assertEquals(r, 1)
  }
}

Tagging and Filtering

class NetworkSuite extends munit.FunSuite {

  val Integration = new munit.Tag("integration")
  val Slow = new munit.Tag("slow")

  test("fast unit test") {
    assertEquals(1 + 1, 2)
  }

  test("calls external API".tag(Integration)) {
    // Only runs when integration tag is included
    val result = HttpClient.get("https://api.example.com/status")
    assertEquals(result.status, 200)
  }

  test("large dataset processing".tag(Slow).tag(Integration)) {
    // Tagged with two tags
    val count = processMillionRows()
    assert(count > 0)
  }
}

Run with tag filters:

# Run only integration tests
sbt "testOnly -- --include-tags=integration"

# Exclude slow tests
sbt "testOnly -- --exclude-tags=slow"

Test Output and Reporting

MUnit integrates with standard sbt test reporting. For CI, configure JUnit XML output:

// build.sbt
testOptions += Tests.Argument(
  new TestFramework("munit.Framework"),
  "-u", "target/test-reports"
)

This generates JUnit XML files that GitHub Actions, GitLab CI, and most CI systems can parse for test result visualization.

Parameterized Tests

MUnit supports table-driven tests through straightforward iteration:

class ValidationSuite extends munit.FunSuite {

  val validEmails = List(
    "user@example.com",
    "user+tag@example.co.uk",
    "first.last@subdomain.example.com"
  )

  val invalidEmails = List(
    "not-an-email",
    "@nodomain.com",
    "missing@",
    ""
  )

  validEmails.foreach { email =>
    test(s"accepts valid email: $email") {
      assert(EmailValidator.isValid(email))
    }
  }

  invalidEmails.foreach { email =>
    test(s"rejects invalid email: ${email.take(20)}") {
      assert(!EmailValidator.isValid(email))
    }
  }
}

Each iteration generates a separate named test — failures identify the specific input without manual debugging.

Migration from ScalaTest

MUnit provides a compatibility shim for gradual migration:

// Works during migration period
class LegacySuite extends munit.FunSuite with org.scalatest.Assertions {
  test("can use scalatest assertions temporarily") {
    1 shouldBe 1  // scalatest matcher
    assertEquals(2, 2)  // munit assertion
  }
}

A practical migration strategy: add MUnit to new test files, leave existing ScalaTest files unchanged, and migrate incrementally when touching old code.

Summary

MUnit wins on clarity. One test style, assertion messages that show what went wrong without reading source code, and fixtures that compose without trait hierarchies. For projects on Cats Effect or http4s, munit-cats-effect makes the testing model a natural extension of the production model — everything is an IO, fixtures are Resource, and there's no impedance mismatch between test code and application code.

Start with FunSuite and assertEquals. Add fixtures as needed. Reach for CatsEffectSuite only if your stack uses Cats Effect. That's the entire learning curve.

Read more

Start now free