Scala - Write and Run Unit Tests Using MUnit


Unit testing is an important practice in software development. Unit testing is used to ensure code quality and to catch bugs early. MUnit is a lightweight and efficient testing library for Scala. MUnit is used to streamline the process of writing tests. It builds on top of JUnit. So it is compatible with existing tools and IDE integrations, like IntelliJ and VS Code. MUnit provides clear and actionable error reports to identify and fix issues.

Why MUnit?

There are various advantages of MUnit -

  • It is a JUnit-based testing style.
  • No additional Scala dependencies - So you can use it cross-building across different Scala versions.
  • Better diffs on assertion failures - So you can identify and understand test failures easily.
  • Cross-platform compatibility - MUnit compiles to JVM bytecode, JavaScript via Scala.js. MUnit is optimized binaries via Scala Native/LLVM.
  • Actionable errors - Test reports are color-coded and clear of test failures.

Setting Up

You need to add the following dependencies to your build.sbt to get started with MUnit using sbt -

libraryDependencies += "org.scalameta" %% "munit" % "0.7.29" % Test

You also need to add for sbt versions prior to 1.5.0 -

testFrameworks += new TestFramework("munit.Framework")

Writing Tests

Test suite in MUnit is a Scala class. It extends munit.FunSuite. Each test is defined using the test() method.

Example

You should have this dependency in your build.sbt file to execute below example -

import Dependencies._

ThisBuild / scalaVersion := "2.13.14"

lazy val root = (project in file("."))
  .settings(
    name := "Demo",
    version := "0.1",
    libraryDependencies ++= Seq(
      "org.scalameta" %% "munit" % "0.7.29" % Test
    )
  )

Now, you can define MyTest.scala under src/test/scala folder -

class MyTests extends munit.FunSuite {
  test("sum of two integers") {
    val obtained = 2 + 2
    val expected = 4
    assertEquals(obtained, expected)
  }
}

Commands

Now, you execute these commands to compile and run test -

sbt compile
sbt test

Output

MyTests:
  + sum of two integers 0.039s
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 4 s, completed 08-Aug-2024, 10:04:38 am  

Assertions

MUnit supports various assertion methods, like assert(), assertEquals(), assertNotEquals(), assertNoDiff(), etc.

Code Description
assert() Fails the test if the given boolean condition is false.
assertEquals() Checks if two values are equal, providing readable diffs for failures.
assertNotEquals() Ensures two values are not equal.
assertNoDiff() Compares strings while ignoring spaces and newlines.
intercept() Verifies that a block of code throws a specific exception.

Example

Consider this example for above same build.sbt file -

class MyEvenNumbersTests extends munit.FunSuite {
  test("all even numbers") {
    val input: List[Int] = List(1, 2, 3, 4)
    val obtainedResults: List[Int] = input.map(_ * 2)
    assert(obtainedResults.forall(_ % 2 == 0))
  }
}

Running Tests

You can run all your test suites using sbt with the following commands -

Run all tests -

sbt test

The output will be -

MyEvenNumbersTests:
  + all even numbers 0.032s
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 3 s, completed 08-Aug-2024, 10:10:58 am 

Run a single test suite -

sbt "testOnly example.MyTests"

The output will be -

[info] Passed: Total 0, Failed 0, Errors 0, Passed 0
[info] No tests to run for Test / testOnly
[success] Total time: 0 s, completed 08-Aug-2024, 10:11:40 am   

You need to append only to the test name to run a single test within a suite -

class MathSuite extends munit.FunSuite {
  test("addition") {
    assert(1 + 1 == 2)
  }
 
  test("multiplication".only) {
    assert(3 * 7 == 21)
  }
}

Running Tests

You can run all your test suites using sbt with the following commands -

Run all tests -

sbt test

The output will be -

MathSuite:
  + multiplication 0.058s
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 3 s, completed 08-Aug-2024, 10:13:22 am 

Run a single test suite -

sbt "testOnly example.MyTests"

The output will be -

[info] Passed: Total 0, Failed 0, Errors 0, Passed 0
[info] No tests to run for Test / testOnly
[success] Total time: 0 s, completed 08-Aug-2024, 10:14:50 am   

Advanced Features

Async Tests:

MUnit supports asynchronous tests using Future.

import scala.concurrent.ExecutionContext.Implicits.global
test("async test") {
  Future {
    assertEquals(2 + 2, 4)
  }
}

Customizing Timeouts

The default timeout for async tests is 30 seconds, but you can customize it.

override val munitTimeout = Duration(1, "s")

Fixtures

You can manage resources efficiently using functional fixtures.

class FileTests extends munit.FunSuite {
  val usingTempFile: FunFixture[os.Path] = FunFixture(
    setup = _ => os.temp(prefix = "file-tests"),
    teardown = tempFile => os.remove(tempFile)
  )
  usingTempFile.test("overwrite on file") { tempFile =>
    os.write.over(tempFile, "Hello, World!")
    val obtained = os.read(tempFile)
    assertEquals(obtained, "Hello, World!")
  }
}

Composing Fixtures

You can use FunFixture.map2 to combine multiple fixtures.

val using2TempFiles: FunFixture[(os.Path, os.Path)] =
  FunFixture.map2(usingTempFile, usingTempFile)

using2TempFiles.test("merge two files") { case (file1, file2) =>
  // body of the test
}

Intercepting Exceptions

You can verify that a block of code throws a specific exception.

import java.nio.file.NoSuchFileException
class FileTests extends munit.FunSuite {
  test("read missing file") {
    val missingFile = os.pwd / "missing.txt"
    intercept[NoSuchFileException] {
      os.read(missingFile)
    }
  }
}

Adding Clues

You can use clue for better error reports.

assert(clue(List(a).head) > clue(b))

Environment-Specific Tests

You can use assume for conditional test execution.

import scala.util.Properties

test("home directory") {
  assume(Properties.isLinux, "this test runs only on Linux")
  assert(os.home.toString.startsWith("/home/"))
}

Tagging Flaky Tests

You can mark tests as flaky using .flaky.

test("requests".flaky) {
  // I/O heavy tests that sometimes fail
}

Declaring Tests Inside Helper Functions

You can reuse shared parts in test methods.

def check[T](name: String, original: List[T], expected: Option[T])(implicit loc: munit.Location): Unit = {
  test(name) {
    val obtained = original.headOption
    assertEquals(obtained, expected)
  }
}

check("basic", List(1, 2), Some(1))
check("empty", List(), Some(1))
check("null", List(null, 2), Some(null))

Declaring Tests Expected to Fail

You can mark tests expected to fail with .fail.

test("issue-456".fail) {
  // Reproduce reported bug
}

Customizing Test Evaluation

You can extend munitTestTransforms to modify test evaluation.

case class Rerun(count: Int) extends munit.Tag("Rerun")

class MyRerunSuite extends munit.FunSuite {
  override def munitTestTransforms = super.munitTestTransforms ++ List(
    new TestTransform("Rerun", { test =>
      val rerunCount = test.tags.collectFirst { case Rerun(n) => n }.getOrElse(1)
      if (rerunCount == 1) test
      else {
        test.withBody(() => Future.sequence(1.to(rerunCount).map(_ => test.body()).toList))
      }
    })
  )

  test("files".tag(Rerun(3))) {
    println("Hello") // will run 3 times
  }

  test("files") {
    // will run once, like normal
  }
}

Customizing Test Names

You can also modify munitNewTest to customize test names dynamically.

class ScalaVersionSuite extends munit.FunSuite {
  val scalaVersion = scala.util.Properties.versionNumberString

  override def munitTestTransforms = super.munitTestTransforms ++ List(
    new TestTransform("append Scala version", { test =>
      test.withName(test.name + "-" + scalaVersion)
    })
  )

  test("foo") {
    assert(!scalaVersion.startsWith("2.11"))
  }
}