Testing Scala Projects

This page will discuss common topics around working with test suites using the Mill build tool

Basic Testing Configuration

This example demonstrates more details about testing in Mill:

build.mill.yaml (download, browse)
extends: ScalaModule
scalaVersion: 3.8.0
test/package.mill.yaml (download, browse)
extends: [build.ScalaTests, TestModule.Utest]
mvnDeps: [com.lihaoyi::utest:0.9.1]
integration/package.mill.yaml (download, browse)
extends: [build.ScalaTests, TestModule.Utest]
moduleDeps: !append [test]
mvnDeps: [com.lihaoyi::utest:0.9.1]
  • The ability to have multiple test suites, e.g. unit tests in test/ and integration tests in integration/

  • Each test suite having its own set of mvnDeps to define the third-party libraries that it needs

  • Test-module dependencies, here the integration module has a moduleDeps on test, giving access to code and utilities defined in test/src/ (note the use of !append to ensure the default dependency on the module under test is not over-written)

These two test modules will expect their sources to be in their respective test/ and integration/ folders respectively. This results in a module dependency graph that looks like:

G <root-module> <root-module> test test <root-module>->test integration integration <root-module>->integration test->integration

You can use Mill’s task query syntax to select the test modules which you want to run

> ./mill '{test,integration}' # run both test suites
+ qux.QuxTests.hello...
+ qux.QuxTests.world...
+ qux.QuxIntegrationTests.helloworld...

> ./mill __.integration # run all integration test suites

> ./mill __.test # run all normal test suites

> ./mill __.testForked # run all test suites of any kind

Selecting Test Classes or Test Cases

To select tests to run on a class or test-case granularity, you can pass parameters to the test framework at the command line.

e.g. uTest framework used in this example lets you pass in a selector to decide which individual test case to run via:

> ./mill test qux.QuxTests
...qux.QuxTests...hello ...
...qux.QuxTests...world ...
Tests: 2, Passed: 2, Failed: 0

> ./mill test qux.QuxTests.hello
...qux.QuxTests...hello ...
Tests: 1, Passed: 1, Failed: 0

For more details on testing in Mill, see Testing Scala Projects

Common Test Framework Support

This build defines a single module with a test suite, configured to use "uTest" as the testing framework.

foo/package.mill.yaml (download, browse)
extends: ScalaModule
scalaVersion: 3.8.0
foo/test/package.mill.yaml (download, browse)
extends: [build.foo.ScalaTests]
testFramework: utest.runner.Framework
mvnDeps: [com.lihaoyi::utest:0.9.1]
foo/src/Foo.scala (download, browse)
package foo
object Foo {
  def main(args: Array[String]): Unit = {
    println(hello())
  }
  def hello(): String = "Hello World"
}
foo/test/src/FooTests.scala (download, browse)
package foo
import utest.*
object FooTests extends TestSuite {
  def tests = Tests {
    test("hello") {
      val result = Foo.hello()
      assert(result.startsWith("Hello"))
      result
    }
    test("world") {
      val result = Foo.hello()
      assert(result.endsWith("World"))
      result
    }
  }
}

Test suites are themselves ScalaModules, nested within the enclosing module, and have all the normal tasks like foo.test.compile available to run, but with an additional .test task that runs the tests. You can also run the test suite directly, in which case it will run the .test task as the default task for that module.

> ./mill foo.compile
compiling 1 ... source...

> ./mill foo.test.compile
compiling 2 ... source...

> ./mill foo.test.testForked
...foo.FooTests...hello ...
...foo.FooTests...world ...
...foo.FooMoreTests...hello ...
...foo.FooMoreTests...world ...

> ./mill foo.test # same as above, `.test` is the default task for the `test` module
...foo.FooTests...hello ...
...foo.FooTests...world ...

> ./mill foo.test.testOnly foo.FooTests # explicitly select the test class you wish to run
...foo.FooTests...hello ...
...foo.FooTests...world ...

For convenience, you can also use one of the predefined test frameworks:

Each testing framework has their own flags and configuration options that are documented on their respective websites, so please see the links above for more details on how to use each one from the command line.

bar/package.mill.yaml (download, browse)
extends: ScalaModule
scalaVersion: 3.8.0
bar/test/package.mill.yaml (download, browse)
extends: [build.bar.ScalaTests, TestModule.Utest]
mvnDeps: [com.lihaoyi::utest:0.8.9]
> ./mill bar.test
...bar.BarTests...hello ...
...bar.BarTests...world ...

You can also select multiple test suites in one command using Mill’s Task Query Syntax

> ./mill __.test
...bar.BarTests...hello ...
...bar.BarTests...world ...
...foo.FooTests...hello ...
...foo.FooTests...world ...
...

Running Tests

Mill provides three ways of running tests

> ./mill foo.test

foo.test: runs tests in a subprocess in an empty sandbox/ folder. This is short for foo.test.testForked, as testForked is the default task for TestModules.

> ./mill foo.test.testCached

foo.test.testCached: runs the tests in an empty sandbox/ folder and caches the results if successful. This can be handy if you are you working on some upstream modules and only want to run downstream tests which are affected: using testCached, downstream tests which are not affected will be cached after the first run and not re-run unless you change some file upstream of them.

> ./mill foo.test.testLocal

foo.test.testLocal: runs tests in an isolated classloader as part of the main Mill process. This can be faster than .test, but is less flexible (e.g. you cannot pass forkEnv) and more prone to interference (testLocal runs do not run in sandbox folders)

It is common to run tests with -w/--watch enabled, so that once you edit a file on disk the selected tests get re-run.

Mill runs tests with the working directory set to an empty sandbox/ folder by default. Tests can access files from their resource directory via the environment variable MILL_TEST_RESOURCE_DIR which provides the path to the resource folder, and additional paths can be provided to test via forkEnv. See Classpath and Filesystem Resources for more details.

e.g. uTest framework used in this example lets you pass in a selector to decide which individual test case to run via:

> ./mill foo.test foo.FooMoreTests
...foo.FooMoreTests...hello ...

> ./mill bar.test bar.BarTests.hello
...bar.BarTests...hello ...

Test Dependencies

Mill has no test-scoped dependencies!

You might be used to test-scoped dependencies from other build tools like Maven, Gradle or sbt. As test modules in Mill are just regular modules, there is no special need for a dedicated test-scope. You can use mvnDeps and runMvnDeps to declare dependencies in test modules, and test modules can use their moduleDeps to also depend on each other

baz/package.mill.yaml (download, browse)
extends: ScalaModule
scalaVersion: 3.8.0
baz/test/package.mill.yaml (download, browse)
extends: [build.baz.ScalaTests]
testFramework: utest.runner.Framework
mvnDeps: [com.lihaoyi::utest:0.8.9]
qux/package.mill.yaml (download, browse)
extends: ScalaModule
scalaVersion: 3.8.0
moduleDeps: [baz]
qux/test/package.mill.yaml (download, browse)
extends: [build.qux.ScalaTests]
testFramework: utest.runner.Framework
mvnDeps: [com.lihaoyi::utest:0.8.9]
moduleDeps: [qux, baz.test]

In this example, not only does qux depend on baz, but we also make qux.test depend on baz.test.

G baz baz baz.test baz.test baz->baz.test qux qux qux->baz qux.test qux.test qux->qux.test qux.test->baz.test

That lets qux.test make use of the BazTestUtils class that baz.test defines, allowing us to re-use this test helper throughout multiple modules' test suites

> ./mill qux.test
Using BazTestUtils.bazAssertEquals
... qux.QuxTests...simple ...
...

> ./mill baz.test
Using BazTestUtils.bazAssertEquals
... baz.BazTests...simple ...
...

Programmable Testing Configuration

Test Parallelism

Test parallelism automatically distributes your test classes across multiple JVM subprocesses, while minimizing the overhead of JVM creation by re-using the subprocesses where possible.

build.mill (download, browse)
package build
import mill.*, scalalib.*

object foo extends ScalaModule {
  def scalaVersion = "3.8.0"
  object test extends ScalaTests, TestModule.Utest {
    def utestVersion = "0.8.9"

    def testParallelism = true
  }
}
> ./mill --jobs 2 foo.test

> find out/foo/test/testForked.dest
...
out/foo/test/testForked.dest/worker-0.log
out/foo/test/testForked.dest/worker-0
out/foo/test/testForked.dest/worker-1.log
out/foo/test/testForked.dest/worker-1
out/foo/test/testForked.dest/test-classes
out/foo/test/testForked.dest/test-report.xml
...

def testParallelism = true is enabled by default, and only shown in the above example for clarity. To disable test parallelism, add def testParallelism = false to your test suites

Test Grouping

Test Grouping is an opt-in feature that allows you to take a single test module and group the test classes such that each group will execute in parallel in a separate JVM when you call test. Test grouping is enabled by overriding def testForkGrouping, as shown below:

build.mill (download, browse)
package build
import mill.*, scalalib.*

object foo extends ScalaModule {
  def scalaVersion = "3.8.0"
  object test extends ScalaTests, TestModule.Utest {
    def utestVersion = "0.8.9"

    def testForkGrouping = discoveredTestClasses().grouped(1).toSeq
    def testParallelism = false
  }
}
foo/test/src/HelloTests.scala (download, browse)
package foo
import utest.*
object HelloTests extends TestSuite {
  def tests = Tests {
    test("hello") {
      println("Testing Hello")
      val result = Foo.hello()
      assert(result.startsWith("Hello"))
      Thread.sleep(1000)
      println("Testing Hello Completed")
      result
    }
  }
}
foo/test/src/WorldTests.scala (download, browse)
package foo
import utest.*
object WorldTests extends TestSuite {
  def tests = Tests {
    test("world") {
      println("Testing World")
      val result = Foo.hello()
      assert(result.endsWith("World"))
      Thread.sleep(1000)
      println("Testing World Completed")
      result
    }
  }
}

In this example, we have one test module foo.test, and two test classes HelloTests and WorldTests. By default, all test classes in the same module run sequentially in the same JVM, but with testForkGrouping we can break up the module and run each test class in parallel in separate JVMs, each with their own separate sandbox folder and .log file:

> ./mill foo.test

> find out/foo/test/testForked.dest
...
out/foo/test/testForked.dest/foo.HelloTests/sandbox
out/foo/test/testForked.dest/foo.WorldTests/sandbox
out/foo/test/testForked.dest/test-report.xml

Compared to Test Parallelism, test grouping allows you to run tests in parallel while and isolating different test groups into their own subprocesses, at the cost of greater subprocess overhead due to the larger number of isolated subprocesses. Different test groups will not write over each others files in their sandbox, and each one will have a separate set of logs that can be easily read without the others mixed in

In this example, def testForkGrouping = discoveredTestClasses().grouped(1).toSeq assigns each test class to its own group, running in its own JVM. You can also configure testForkGrouping to choose which test classes you want to run together and which to run alone:

  • If some test classes are much slower than others, you may want to put the slow test classes each in its own group to reduce latency, while grouping multiple fast test classes together to reduce the per-group overhead of spinning up a separate JVM.

  • Some test classes may have global JVM-wide or filesystem side effects that means they have to run alone, while other test classes may be better-behaved and OK to run in a group

In general, testForkGrouping leaves it up to you how you want to group your tests for execution, based on the unique constraints of your test suite.

Test Grouping & Test Parallelism together

testParallelism respects testForkGrouping, allowing you to use both features in a test module.

build.mill (download, browse)
package build
import mill.*, scalalib.*

object foo extends ScalaModule {
  def scalaVersion = "3.8.0"
  object test extends ScalaTests, TestModule.Utest {
    def utestVersion = "0.8.9"

    // Group tests by GroupX and GroupY
    def testForkGrouping =
      discoveredTestClasses().groupMapReduce(_.contains("GroupX"))(Seq(_))(_ ++ _).toSeq.sortBy(
        data => !data._1
      ).map(_._2)
    def testParallelism = true
  }
}
> ./mill --jobs 2 foo.test

> find out/foo/test/testForked.dest
...
out/foo/test/testForked.dest/group-0-foo.GroupX1/worker-...
out/foo/test/testForked.dest/group-0-foo.GroupX1/test-classes
out/foo/test/testForked.dest/group-1-foo.GroupY1/worker-...
out/foo/test/testForked.dest/group-1-foo.GroupY1/test-classes
out/foo/test/testForked.dest/test-report.xml
...

This example sets testForkGrouping to group test classes into two categories: GroupX and GroupY. Additionally, testParallelism is enabled. Mill ensures each subprocess exclusively claims and runs tests from either GroupX or GroupY, preventing them from mixing. Test classes from GroupX and GroupY will never share the same test runner.

This is useful when you have incompatible tests that cannot run within the same JVM. Test Grouping combined with Test Parallel Scheduler maintains their isolation while maximizing performance through parallel test execution.