Announcing MUnit: a new Scala testing library

Announcement: https://scalameta.org/munit/blog/2020/02/01/hello-world.html

MUnit is a new Scala testing library that has no external Scala dependencies and is published for 2.11, 2.12, 2.13 and Dotty. It also supports Scala.js (0.6.x + 1.x) and Scala Native (0.4.x).

10 Likes

Is it pronounced μnit?

What is its relationship to the nanotest effort?

I pronounce it as m-unit, similar to j-unit.

No relationship with nanotest, I wasn’t aware of nanotest until quite recently.

2 Likes

it’s still called “strawman”, so it probably didn’t get anywhere? :man_shrugging:

maybe @eed3si9n could tell us more about the current state of nanotest and the differences between it and munit.
to me, the biggest difference seems to be that munit depends on JUnit, while nanotest does not.

There is one very useful feature in ScalaTest that I hope to see in any testing framework, and that is custom test fixures (other than before / after each). One use-case is injecting a transaction to each test-case that will be rolled-back at the end of the test-case; another use-case is running a verification / assertion code after each test case (using “afterEach” fails the entire suite instead of just the single test case).

Another less common but still useful feature is dynamically creating test cases. I’ve used it to generate tests based on files (each representing a test case), and also to create “chained” test cases (using DelayedInit).

One last thing regarding build tools - I’m using Gradle as my main Scala build tool, and unfortunately it is not the most compatible with ScalaTest. I’d hope that a new testing framework would be more compatible with it. You can read about it here.

The current name of the library is Scala Verify. The goal of the project was to create a small test framework that has as few dependencies as possible, which we thought would help testing core libraries and scala/scala itself.

If anyone wants to give it a try, Verify 0.2.0 is available on Maven Central, and it’s published against JVM, JS, Native, and Dotty.

I called it “strawman” because my original goal was to bring the source into scala/scala, so it can evolve together along with the codebase. It’s not really a priority task, so likely it’ll stay in its current state for a while?

2 Likes

Great to have out-of-the-box type safety

test("compile-time type safety") {
  assertEquals("", 42) // Error: Cannot prove that String =:= Int.
}

and case class pretty diffs including field names

test("User should be Picard") {
  val expected = User("Picard", 67)
  val actual = User("Worf", 30)
  assertEquals(actual, expected)
}

image

8 Likes

In particular, if I remember correctly, a goal of Scala Verify was to avoid the JUnit dependency because JUnit needs special treatment to work on platforms without reflection (Scala.js, Scala Native). Maybe that’s a non-issue, because the work is done, and it’s not a big deal to keep it working in the future.

1 Like

It was actually more work to keep the JUnit dependency than it would have been without it. I personally don’t use the JUnit/Hamcrest assertions and matchers. The benefit of JUnit comes from its tooling integrations, some build tools like Gradle and Pants (I think also Maven) don’t support the sbt testing interface. The IntelliJ integration to run JUnit tests is also quite amazing.

3 Likes

Thanks for the feedback!

One use-case is injecting a transaction to each test-case that will be rolled-back at the end of the test-case

You can do this today using MUnit fixtures https://scalameta.org/munit/docs/fixtures.html
It’s also possible to override munitRunTest to wrap the body of a test case with try/finally, see here for an example demonstrating how to re-run tests multiple times based on a tag https://scalameta.org/munit/docs/tests.html#customize-evaluation-of-tests-with-tags

using “afterEach” fails the entire suite instead of just the single test case

That’s an interesting idea, MUnit has a fail() helper to fail a single test case and you can call that in the afterEach method. There is no helper however to abort the entire test suite. I opened https://github.com/scalameta/munit/issues/37 to introduce a failSuite() helper for that use-case.

Another less common but still useful feature is dynamically creating test cases

You can achieve this in the same way as you can generate tests with ScalaTest

1.to(10).foreach { n =>
  test(n.toString) { ... }
}

You can also “roll your own testing library” with fairly minimal code, as demonstrated here https://scalameta.org/munit/docs/tests.html#roll-our-own-testing-library-with-munitsuite

class MyCustomSuite extends munit.Suite {
  override type TestValue = Any
  override def munitTests() = List(
    new Test(
      "name",
      body = () => println("Hello world!"),
      tags = Set.empty[Tag],
      location = implicitly[Location]
    )
  )
}

One last thing regarding build tools - I’m using Gradle as my main Scala build tool, and unfortunately it is not the most compatible with ScalaTest. I’d hope that a new testing framework would be more compatible with it.

MUnit is implemented as a JUnit runner so you should be able to use the existing JUnit support in Gradle as long as you annotate the test suites with @RunWith(classOf[munit.MUnitRunner]). The munit.Suite superclass already has this annotation so Gradle might be able to auto-detect the @RunWith annotation without having to annotate every subclass (IntelliJ doesn’t require the annotation on every subclass).

2 Likes

Thank you for the comprehensive response!

Further feedback:

  1. It seems to works flawlessly with Gradle (see repository here); no need for manual @RunWith annotations nor custom module-suites with reflection; reports are combined with Java ones; gradle test inclusion (--tests) works just like with Java.

  2. It does not currently support JUnit 5, at least not with gradle – trying to run with org.junit.jupiter:junit-jupiter-engine doesn’t work, and instead the “vintage” engine has to be used.

  3. assertEquals uses a reverse order for the “expected” and “actual” / “obtained” values. I’m pointing it out to make sure you’re aware of this, not necessarily to say it is a bad thing.

  4. JUnit reports seem to be generated with incorrect class name suite; it uses the name of an arbitrary (first?) test case in the suite.

  5. Do you think my “chained test cases” use-case can be achieved with MUnit without DelayedInit?

1 Like

It seems to works flawlessly with Gradle (see repository here)

That’s awesome! Glad to hear that :slight_smile:

It does not currently support JUnit 5, at least not with gradle – trying to run with org.junit.jupiter:junit-jupiter-engine doesn’t work, and instead the “vintage” engine has to be used.

I opened https://github.com/scalameta/munit/issues/41 to track this.

That’s a shame. MUnit uses the conventions I’ve used in my personal projects for several years. It would probably make sense to try and use the same order as org.junit.Assert. The reason the “obtained” case comes first is because the “expected” is often a large hardcoded expression/string like this

val obtained = someMethod()
assertNoDiff(
  obtained,
  """|val x = 42
     |val y = 43
     |... more lines
     |""".stripMargin
)

It would not read as nicely with the arguments swapped the other way around. (Note: I’ve primarily worked on tooling projects like scalafmt/scalafmt/metals that assert a lot of behavior against multiline strings containing Scala source code, I understand this does not reflect most Scala projects in the real world)

JUnit reports seem to be generated with incorrect class name suite; it uses the name of an arbitrary (first?) test case in the suite.

That could very well be, I haven’t used the JUnit reports myself so much. Looking at the JUnit XML reports that are generated by sbt it looks like the test suite name is correct, however

<testsuite hostname="tw-mbp-lgeirsson" name="munit.AssertionsSuite" tests="3" errors="0" failures="0" skipped="0" time="0.079" timestamp="2020-02-03T11:49:10">

Those reports are generated by the sbt test interface so there could be differences. Please open an issue with a reproduction and we can look into it.

Do you think my “chained test cases” use-case can be achieved with MUnit without DelayedInit ?

At a quick glance I believe so. You can override def munitTests() to return exactly what tests should run in what order. See example here https://scalameta.org/munit/docs/filtering.html#filter-tests-cases-based-on-a-dynamic-conditions

Trust me, I’ve been confused by JUnit’s order myself quite a few times; I’m not sure it makes sense, it’s just what it is :neutral_face:

On it.

That won’t do, as I need to know when does the last “chained” test is declared. I could go with this approach instead:

class ChainingSuite extends ChainedTestsSuite {

  test("a") {}

  chain("first") {
    test("b") {}
    test("c") {}
  }

  chain("second") {
    test("d") {}
    test("e") {}
  }
}

Where the implementation of the helper suite is:

trait ChainedTestsSuite extends munit.FunSuite {

  private val chainedTests = mutable.ArrayBuffer.empty[ChainedTest]

  private var chaining = false

  override def test(options: TestOptions)(body: => Any)(implicit loc: Location): Unit = {
    if (chaining) {
      val order = chainedTests.size + 1
      chainedTests.append(ChainedTest(order, options, () => body, loc))
    } else {
      super.test(options)(body)(loc)
    }
  }

  def chain(options: TestOptions)(body: => Any): Unit = {
    if (chaining) {
      throw new IllegalStateException("Already chaining")
    } else {
      chaining = true
      body
      for (test <- chainedTests) {
        val name = s"(${test.order}) ${test.options.name}"
        super.test(test.options.withName(name))(test.body)(test.loc)
      }
      for (testA <- chainedTests; testB <- chainedTests) {
        val name = s"Chain test '${options.name}': ${testA.order} and then ${testB.order}"
        super.test(options.withName(name)) {
//          logger.info(s"Executing first test: ${testA.options.name}")
          testA.body()
          betweenChainedTests()
//          logger.info(s"Executing second test: ${testB.options.name}")
          testB.body()
        }
      }
      chainedTests.clear()
      chaining = false
    }
  }

  def betweenChainedTests(): Unit = {}
}

object ChainedTestsSuite {
  private case class ChainedTest(order: Int, options: TestOptions, body: () => Any, loc: Location)
}

Further feedback:

  1. It was surprising to find out that TestOptions is not a case class; any particular reason?

  2. I’d suggest not using the implicit conversion String to TestOptions. Imagine a newbie Scala developer writing a unit test with this library, wanting to explore the implementation a bit by “jumping” to the test implementation, only to find out some weird magic.

I often correct the order of junit assert args.

It should be written as assert(expected)(actual).

The assert function is assertEquals(expected)(_), an assertion that just takes the actual result, just like assertTrue.

I would normally

assert(constant) {
  Some(stuff)
}

so it’s interesting to me that @olafurpg thinks the opposite, that the test expression is brief and constructing the expected result is verbose.

I would like my favorite minimalist test framework to figure out which order I supplied them. There must be a reasonable heuristic for which arg depends on test data.

I think the person who coded the test decides which order is more readable; or I may want to apply the same test to different expecteds, or different actuals.

2 Likes

Minimalist + heuristic = oxymoron :slight_smile:

1 Like

This is good feedback. I was concerned about it as well, and have considered adding a method overload to make it more beginner friendly.

def test(name: String)(body: => Any): Unit = test(new TestOptions(name, ...))(body)

cc/ @gabro

It was surprising to find out that TestOptions is not a case class; any particular reason?

It may become a case class if I’m 100% certain that it won’t need more fields. I wanted to avoid case classes in order to be able to add new fields without breaking binary compatibility. It’s still a young library but I would like to eventually release v1.0 and aim to never break binary compatibility.

That won’t do, as I need to know when does the last “chained” test is declared.

I’ll take look at it later, I’m still not sure I fully understand “chained” tests.

I’m not sure how far this would go from “minimalist”, but it shouldn’t be hard to support both. Possibly something along these lines:

expect(expected).from {
  actual
}

assert(actual).is {
  expected
}
1 Like

Ahh, binary compatibility. I knew there was something I wasn’t thinking of.

I’ve been using it to test units with workflows that may leave them with undesired state that’ll affect future workflows. Those units are usually some coordinator / manager overseeing async workflows:

test("(1) start a job, wait for successful completion") { ... }
test("(2) start a job, cancel before completed") { ... }
test("(1) and then (1)") { ... }
test("(1) and then (2)") { ... }
test("(2) and then (1)") { ... }
test("(2) and then (2)") { ... }