Testing Kotlin Projects
This page will discuss common topics around working with test suites using the Mill build tool
Defining Unit Test Suites
package build
import mill._, kotlinlib._
object foo extends KotlinModule {
def mainClass = Some("foo.FooKt")
def kotlinVersion = "1.9.24"
object test extends KotlinTests {
def testFramework = "com.github.sbt.junit.jupiter.api.JupiterFramework"
def ivyDeps = Agg(
ivy"com.github.sbt.junit:jupiter-interface:0.11.4",
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1",
ivy"org.mockito.kotlin:mockito-kotlin:5.4.0"
)
// This is needed because of the "mockito-kotlin"
def kotlincOptions = super.kotlincOptions() ++ Seq("-jvm-target", "11")
}
}
This build defines a single module with a test suite, configured to use
"JUnit" + "Kotest" as the testing framework, along with Mockito. Test suites are themselves
KotlinModule
s, 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 1 ... source...
> mill foo.test.test
...foo.FooTests...hello ...
...foo.FooTests...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:
-
TestModule.Junit4
-
TestModule.Junit5
-
TestModule.TestNg
-
TestModule.Munit
-
TestModule.ScalaTest
-
TestModule.Specs2
-
TestModule.Utest
-
TestModule.ZioTest
object bar extends KotlinModule {
def mainClass = Some("bar.BarKt")
def kotlinVersion = "1.9.24"
object test extends KotlinTests with TestModule.Junit5 {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1",
ivy"org.mockito.kotlin:mockito-kotlin:5.4.0"
)
// This is needed because of the "mockito-kotlin"
def kotlincOptions = super.kotlincOptions() ++ Seq("-jvm-target", "11")
}
}
> 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 ...
...
Mill provides three ways of running tests
> mill foo.test
-
foo.test
: runs tests in a subprocess in an emptysandbox/
folder. This is short forfoo.test.test
, astest
is the default task forTestModule
s.
> mill foo.test.testCached
-
foo.test.testCached
: runs the tests in an emptysandbox/
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: usingtestCached
, 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 passforkEnv
) 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 save a file to 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.
|
If you want to pass any arguments to the test framework, you can pass them after
foo.test
in the command line. e.g. uTest
lets you pass in a selector to decide which test to run, which in Mill would be:
> kotest_filter_tests='hello' kotest_filter_specs='bar.BarTests' ./mill bar.test
...bar.BarTests...hello ...
This command only runs the hello
test case in the bar.BarTests
test suite class.
Test Dependencies
Mill has no |
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 ivyDeps
and runIvyDeps
to declare dependencies in test modules, and test modules
can use their moduleDeps
to also depend on each other
package build
import mill._, kotlinlib._
object qux extends KotlinModule {
def kotlinVersion = "1.9.24"
def moduleDeps = Seq(baz)
object test extends KotlinTests with TestModule.Junit5 {
def moduleDeps = super.moduleDeps ++ Seq(baz.test)
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1",
ivy"com.google.guava:guava:33.3.0-jre"
)
}
}
object baz extends KotlinModule {
def kotlinVersion = "1.9.24"
object test extends KotlinTests with TestModule.Junit5 {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1",
ivy"com.google.guava:guava:33.3.0-jre"
)
}
}
In this example, not only does qux
depend on baz
, but we also make
qux.test
depend on 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 ...
...
Defining Integration Test Suites
You can also define test suites with different names other than test
. For example,
the build below defines a test suite with the name integration
, in addition
to that named test
.
package build
import mill._, kotlinlib._
object qux extends KotlinModule {
def kotlinVersion = "1.9.24"
object test extends KotlinTests with TestModule.Junit5 {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1"
)
}
object integration extends KotlinTests with TestModule.Junit5 {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1"
)
}
}
The integration suite is just another regular test module within the parent KotlinModule (This example also demonstrates using Junit 5 instead of Junit 4)
These two test modules will expect their sources to be in their respective qux/test
and
qux/integration
folder respectively
> mill 'qux.{test,integration}' # run both test suites
...qux.QuxTests...hello...
...qux.QuxTests...world...
...qux.QuxIntegrationTests...helloworld...
> mill __.integration.testCached # run all integration 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 when you call test
. Test grouping is enabled
by overriding def testForkGrouping
, as shown below:
package build
import mill._, kotlinlib._
object foo extends KotlinModule {
def mainClass = Some("foo.FooKt")
def kotlinVersion = "1.9.24"
object test extends KotlinTests {
def testFramework = "com.github.sbt.junit.jupiter.api.JupiterFramework"
def ivyDeps = Agg(
ivy"com.github.sbt.junit:jupiter-interface:0.11.4",
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1",
ivy"org.mockito.kotlin:mockito-kotlin:5.4.0"
)
def testForkGrouping = discoveredTestClasses().grouped(1).toSeq
}
}
package foo
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.string.shouldStartWith
class HelloTests : FunSpec({
test("hello") {
println("Testing Hello")
val result = Foo().hello()
result shouldStartWith "Hello"
java.lang.Thread.sleep(1000)
println("Testing Hello Completed")
}
})
package foo
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.string.shouldEndWith
class WorldTests : FunSpec({
test("world") {
println("Testing World")
val result = Foo().hello()
result shouldEndWith "World"
java.lang.Thread.sleep(1000)
println("Testing World Completed")
}
})
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/test.dest
...
out/foo/test/test.dest/foo.HelloTests.log
out/foo/test/test.dest/foo.HelloTests/sandbox
out/foo/test/test.dest/foo.WorldTests.log
out/foo/test/test.dest/foo.WorldTests/sandbox
out/foo/test/test.dest/test-report.xml
Test grouping allows you to run tests in parallel while keeping things deterministic and debuggable: parallel 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. This comes with some overhead
on a per-JVM basis, so if your test classes are numerous and small you may want to assign
multiple test classes per group. 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.