Testing Scala with MUnit
MUnit is a test library with a collection of features that are not available in other Scala testing libraries.
MUnit draws inspiration from Scalatest, utest, and ava (JavaScript testing library).
This blog post explains how to setup MUnit and use its basic features.
Simple test
Let's create a Calculator
object with an addNumbers
method that adds two integers:
package com.github.mrpowers.munit.example
object Calculator {
def addNumbers(n1: Int, n2: Int): Int = {
n1 + n2
}
}
Let's add a MUnit test to verify that addNumbers
returns 7 when 3 and 4 are supplied as inputs.
package com.github.mrpowers.munit.example
class CalculatorSpec extends munit.FunSuite {
test("adds two numbers") {
assertEquals(Calculator.addNumbers(3, 4), 7)
}
}
We can run the test suite with sbt test
.
MUnit outputs the test with a well-formatted passing message.
All the code snippets in this blog are from this GitHub repo.
Let's take a look at how MUnit outputs test failure messages.
Pretty printing assertEquals failures
Here's an example of a failing test:
test("equality error message") {
val obtained = 42
val expected = 43
assertEquals(obtained, expected)
}
The failing message includes the obtained value, expected value, and line number of the failure.
Comparing collections
MUnit's assertEquals
method can be used to compare collections that have the same elements:
test("considers collections with the same elements equal") {
assertEquals(Seq(1, 2), Seq(1, 2))
}
This functionality is similar to Scalatest should matchers and utest arrow asserts.
MUnit gives nicely formatted error messages for collection comparisons that are not equal.
Here's a test that compares two collections that aren't equal.
test("gives good error messages for different collections") {
assertEquals(Seq(1, 2), Seq(1, 8))
}
Here's the error message MUnit outputs.
intercept
MUnit can also check that errors are thrown.
Let's create a crankyMethod
that throws a java.lang.IllegalArgumentException
.
def crankyMethod(): String = {
throw new java.lang.IllegalArgumentException()
}
Let's write a test using intercept
to verify that this method throws a IllegalArgumentException
:
test("can intercept exceptions") {
intercept[java.lang.IllegalArgumentException]{
Calculator.crankyMethod()
}
}
intercept
is especially useful when making sure your code is throwing custom errors for certain logic paths.
interceptMessage
Write a crankyKong
method that'll throw a java.lang.Exception
with the message "DK is my grandchild".
def crankyKong(): String = {
throw new java.lang.Exception("DK is my grandchild")
}
Write a test that makes sure the crankyKong
method throws a java.lang.Exception
with the expected message.
test("can intercept exceptions with messages") {
interceptMessage[java.lang.Exception]("DK is my grandchild"){
Calculator.crankyKong()
}
}
Use interceptMessage
instead of intercept
whenever possible. You should always write code with error messages that are descriptive and help users debug the underlying issue. Your test suite should verify that your error messages are awesome and descriptive.
compileErrors
Our Calculator.addNumbers()
method takes two integer arguments. The code won't compile if addNumbers
is invoked with two String arguments.
Let's write a test to make sure addNumbers()
errors out with a particular message when it's invoked with String arguments.
test("checks the error message of code that doesn't compile") {
assertNoDiff(
compileErrors("""Calculator.addNumbers("hi", "there")"""),
"""|error:
|type mismatch;
| found : String("hi")
| required: Int
|Calculator.addNumbers("hi", "there")
| ^
|""".stripMargin
)
}
Yep, MUnit is awesome!
Jumping to failing section of code
MUnit shows the source location for errors in failing tests:
Test failures point to the source code location where the failure happened. Cmd+click on the filename to open the relevant line number in your editor (does not work in all terminals).
For example, if you're using iTerm & MacOS, you can hold Command and click the path (e.g. /Users/powers/.../FailingSpec.scala) and you'll be redirected to the failing line in your text editor of choice.
Enabling tests based on conditions
MUnit supports rich test filtering capabilities.
These let you customize what tests are run on different operating systems and with different Scala versions.
Let's create a test file with one test that runs for a Scala versions / operating systems and other test that only runs on Windows with Scala 2.13.
package com.github.mrpowers.munit.example
import scala.util.Properties
import munit._
object Windows213 extends Tag("Windows213")
class MySuite extends FunSuite {
override def munitTestTransforms = super.munitTestTransforms ++ List(
new TestTransform("Windows213", { test =>
val isIgnored =
test.tags(Windows213) && !(
Properties.isWin &&
Properties.versionNumberString.startsWith("2.13")
)
if (isIgnored) test.tag(Ignore)
else test
})
)
test("windows-213".tag(Windows213)) {
assertEquals(2, 3)
}
test("normal test") {
assertEquals(2, 2)
}
}
Here's the output when you run this test file on macOS with Scala 2.12.
The windows-213 test is ignored and the "normal test" is run.
Next steps
MUnit is a production-ready testing library that supports a bunch of awesome testing features. Clone this repo, run the test suite, and see for yourself ;)
Projects in the scalameta family are well supported. MUnit will likely get better and grow in popularity.
MUnit is used in production projects like scalameta, metals, and mdoc. MUnit is a new library, but it's ready for your production app.
Big thanks to scalameta for continuing to release awesome projects and for relentlessly pushing Scala tooling forward.