On this page
Test
One of the design goals of Micronaut was to eliminate the artificial separation imposed by traditional frameworks between function and unit tests due to slow startup times and memory consumption.
With that in mind it is generally pretty easy to start Micronaut in a unit test and one of the goals of Micronaut was to as much as possible not require a test framework to test Micronaut. For example in Spock you can simply do:
However, there are cases where having some additional features to test Micronaut come in handy, such as mocking bean definitions and so on.
This project includes a pretty simple set of extensions for JUnit 5, Spock and Kotest:
-
Automatically start and stop the server for the scope of a test suite
-
Use mocks to replace existing beans for the scope of a test suite
-
Allow dependency injection into a test instance
This is achieved through a set of annotations:
-
@MicronautTest- Can be added to any test:-
io.micronaut.test.extensions.spock.annotation.MicronautTestfor Spock. -
io.micronaut.test.extensions.junit5.annotation.MicronautTestfor JUnit 5. -
io.micronaut.test.extensions.kotest.annotation.MicronautTestfor Kotest. -
io.micronaut.test.extensions.kotest5.annotation.MicronautTestfor Kotest 5. -
micronaut.test.extensions.junit5.annotation.MicronautTestfor Python tests, which are compiled to JUnit 5 tests.
-
-
io.micronaut.test.annotation.@MockBean- Can be added to methods or inner classes of a test class to define mock beans that replace existing beans for the scope of the test.
These annotations use internal Micronaut features and do not mock any part of Micronaut itself. When you run a test within @MicronautTest it is running your real application.
In some tests you may need a reference to the ApplicationContext and/or the EmbeddedServer (for example, to create an instance of an HttpClient). Rather than defining these as properties of the test (such as a @Shared property in Spock), when using @MicronautTest you can reference the server/context that was started up for you, and inject them directly in your test.
@Inject
EmbeddedServer server //refers to the server that was started up for this test suite
@Inject
ApplicationContext context //refers to the current application context within the scope of the testEager Singleton Initialization
If you enable eager singleton initialization in your application, the Micronaut Framework eagerly initializes all singletons at startup time. This can be useful for applications that need to perform some initialization at startup time, such as registering a bean with a third party library.
However, as tests annotated with @MicronautTest are implicitly in the Singleton scope, this can cause problems injecting some beans (for example an HttpClient) into your test class.
To avoid this, you can either disable eager singleton initialization for your tests, or you will need to manually get an instance of the bean you would normally inject. As an example, to get an HttpClient you could do:
For this project, you can find a list of releases (with release notes) here:
TestName.testName removed
In Kotest 5, the test name was accessed via testCase.name.testName.
In Kotest 6, TestName no longer has a testName property — it was replaced by name.
testCase.name.testNametestCase.name.nameTestResult moved to io.kotest.engine.test
In Kotest 5, TestResult was imported from io.kotest.core.test.
In Kotest 6, it lives in io.kotest.engine.test.
import io.kotest.core.test.TestResultimport io.kotest.engine.test.TestResultTable-driven testing requires new dependency
In Kotest 5, io.kotest.data.blocking.forAll and io.kotest.data.row were included in the core framework.
In Kotest 6, they were extracted into a separate artifact.
testImplementation("io.kotest:kotest-assertions-table:<kotest6-version>")import io.kotest.data.blocking.forAll
import io.kotest.data.rowAbstractProjectConfig.extensions() changed from function to property
In Kotest 5, extensions were registered by overriding a function.
In Kotest 6, extensions is a val property.
override fun extensions() = listOf(MicronautKotest5Extension)override val extensions = listOf(MicronautKotest5Extension)JUnit Runner artifact renamed
In Kotest 5, the JUnit 5 runner artifact was:
testImplementation("io.kotest:kotest-runner-junit5-jvm:<kotest5-version>")In Kotest 6, the runner artifact was renamed to align with the new major version:
testImplementation("io.kotest:kotest-runner-junit6-jvm:<kotest6-version>")Make sure to update your build configuration accordingly when upgrading.
References
-
Kotest 6.0 release notes: https://github.com/kotest/kotest/blob/master/documentation/docs/release_6.0.md
-
Kotest docs: https://kotest.io/docs/release6/
To get started using Spock you need the following dependencies in your build configuration:
testImplementation "io.micronaut.test:micronaut-test-spock"
testImplementation("org.spockframework:spock-core") {
exclude group: "org.codehaus.groovy", module: "groovy-all"
}|
Note
|
If you plan to define mock beans you will also need micronaut-inject-groovy on your testImplementation classpath or micronaut-inject-java for Java or Kotlin (this should already be configured if you used mn create-app).
|
Or for Maven:
<dependency>
<groupId>io.micronaut.test</groupId>
<artifactId>micronaut-test-spock</artifactId>
<scope>test</scope>
</dependency>Let’s take a look at an example using Spock. Consider you have the following interface:
package io.micronaut.test.spock;
public interface MathService {
Integer compute(Integer num);
}And a simple implementation that computes the value times 4 and is defined as a Micronaut bean:
package io.micronaut.test.spock
import jakarta.inject.Singleton
@Singleton
class MathServiceImpl implements MathService {
@Override
Integer compute(Integer num) {
return num * 4 // should never be called
}
}You can define the following test to test it:
The @MicronautTest annotation supports specifying the environment names the test should run with:
@MicronautTest(environments={"foo", "bar"})In addition, although Micronaut itself doesn’t scan the classpath, some integrations do (such as JPA and GORM), for these cases you may wish to specify either the application class:
@MicronautTest(application=Application.class)Or the packages:
@MicronautTest(packages="foo.bar")To ensure that entities can be found during classpath scanning.
When using @MicronautTest each @Test method will be wrapped in a transaction that will be rolled back when the test finishes. This behaviour can be changed by using the transactional and rollback properties.
The default transactional behaviour can also be controlled through configuration. Set the micronaut.test.transactional property (for example in application-test.properties or as a system property) to false to disable transactions for all tests that do not explicitly specify the transactional member. Alternatively, set micronaut.test.transactional-default=false to require tests to opt in with @MicronautTest(transactional = true) while still allowing per-test overrides.
To avoid creating a transaction:
@MicronautTest(transactional = false)To not rollback the transaction:
@MicronautTest(rollback = false)Additionally, the transactionMode property can be used to further customize the way that transactions are handled for
each test:
@MicronautTest(transactionMode = TransactionMode.SINGLE_TRANSACTION)The following transaction modes are supported:
-
SEPARATE_TRANSACTIONS(default) - Each setup/cleanup method is wrapped in its own transaction, separate from that of the test. This transaction is always committed. -
SINGLE_TRANSACTION- All setup methods are wrapped in the same transaction as the test. Cleanup methods are wrapped in separate transactions.
Now let’s say you want to replace the implementation with a Spock Mock. You can do so by defining a method that returns a Spock mock and is annotated with @MockBean, for example:
Note that in most cases you won’t define a @MockBean and inject it, only to verify interaction with the Mock directly. Instead, the Mock will be a collaborator within your application. For example say you have a MathController:
package io.micronaut.test.spock
import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
@Controller('/math')
class MathController {
MathService mathService
MathController(MathService mathService) {
this.mathService = mathService
}
@Get(uri = '/compute/{number}', processes = MediaType.TEXT_PLAIN)
String compute(Integer number) {
return mathService.compute(number)
}
}The above controller uses the MathService to expose a /math/compute/{number] endpoint. See the following example for a test that tests interaction with the mock collaborator:
The way this works is that @MicronautTest will inject the Mock(..) instance into the test, but the controller will have a proxy that points to the Mock(..) instance injected. For each iteration of the test the mock is refreshed (in fact it uses Micronaut’s built in RefreshScope).
Since @MicronautTest turns tests into beans themselves, it means you can use the @Requires annotation on the test to enable/disable tests. For example:
@MicronautTest
@Requires(env = "my-env")
class RequiresSpec extends Specification {
...
}The above test will only run if my-env is active (you can activate it by passing the system property micronaut.environments).
You can define additional test specific properties using the @Property annotation. The following example demonstrates usage:
@Propertypackage io.micronaut.test.spock
import io.micronaut.context.annotation.Property
import io.micronaut.context.annotation.Value
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import spock.lang.Specification
import spock.lang.Stepwise
@MicronautTest
@Property(name = "foo.bar", value = "stuff")
@Stepwise
class PropertySpec extends Specification {
@Value('${foo.bar}')
String val
void "test value"() {
expect:
val == 'stuff'
}
@Property(name = "foo.bar", value = "changed")
void "test value changed"() {
expect:
val == 'changed'
}
void "test value restored"() {
expect:
val == 'stuff'
}
}Note that when a @Property is defined at the test method level, it causes a RefreshEvent to be triggered which will update any @ConfigurationProperties related to the property.
Alternatively you can specify additional propertySources in any supported format (YAML, JSON, Java properties file etc.) using the @MicronautTest annotation:
propertySources stored in filespackage io.micronaut.test.spock
import io.micronaut.context.annotation.Property
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import spock.lang.Specification
@MicronautTest(propertySources = "myprops.properties")
class PropertySourceSpec extends Specification {
@Property(name = "foo.bar")
String val
void "test property source"() {
expect:
val == 'foo'
}
}The above example expects a file located at src/test/resources/io/micronaut/spock/myprops.properties. You can however use a prefix to indicate where the file should be searched for. The following are valid values:
-
file:myprops.properties- A relative path to a file somewhere on the file system. -
classpath:myprops.properties- A file relative to the root of the classpath. -
myprops.properties- A file relative on the classpath relative to the test being run.
You can use combine the use of @Requires and @Property, so that injected beans will be refreshed if there are
configuration changes that affect their @Requires condition.
For that to work, the test must be annotated with @MicronautTest(rebuildContext = true). In that case, if there are
changes in any property for a given test, the application context will be rebuilt so that @Requires conditions are
re-evaluated again.
For example:
@Requires and @Property in a @Refreshable test class.package io.micronaut.test.spock
import io.micronaut.context.annotation.Property
import io.micronaut.context.annotation.Requires
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import spock.lang.Issue
import spock.lang.Specification
import jakarta.inject.Inject
import jakarta.inject.Singleton
@Issue("https://github.com/micronaut-projects/micronaut-test/issues/91")
@MicronautTest(rebuildContext = true)
@Property(name = "foo.bar", value = "stuff")
class PropertyValueRequiresSpec extends Specification {
@Inject
MyService myService
void "test initial value"() {
expect:
myService instanceof MyServiceStuff
}
@Property(name = "foo.bar", value = "changed")
void "test value changed"() {
expect:
myService instanceof MyServiceChanged
}
void "test value restored"() {
expect:
myService instanceof MyServiceStuff
}
}
interface MyService {}
@Singleton
@Requires(property = "foo.bar", value = "stuff")
class MyServiceStuff implements MyService {}
@Singleton
@Requires(property = "foo.bar", value = "changed")
class MyServiceChanged implements MyService {}To get started using JUnit 5 you need the following dependencies in your build configuration:
dependencies {
testAnnotationProcessor "io.micronaut:micronaut-inject-java"
...
testImplementation("org.junit.jupiter:junit-jupiter-api")
testImplementation("io.micronaut.test:micronaut-test-junit5")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testImplementation("org.junit.jupiter:junit-jupiter-engine")
}
// use JUnit 5 platform
test {
useJUnitPlatform()
}|
Note
|
If you plan to define mock beans you will also need inject-groovy on your testCompile classpath or inject-java for Java or Kotlin (this should already be configured if you used mn create-app) and the testAnnotationProcessor.
|
Or for Maven:
<dependency>
<groupId>io.micronaut.test</groupId>
<artifactId>micronaut-test-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>Let’s take a look at an example using JUnit 5. Consider you have the following interface:
package io.micronaut.test.junit5;
public interface MathService {
Integer compute(Integer num);
}And a simple implementation that computes the value times 4 and is defined as a Micronaut bean:
package io.micronaut.test.junit5;
import jakarta.inject.Singleton;
@Singleton
class MathServiceImpl implements MathService {
@Override
public Integer compute(Integer num) {
return num * 4;
}
}You can define the following test to test the implementation:
The @MicronautTest annotation supports specifying the environment names the test should run with:
@MicronautTest(environments={"foo", "bar"})In addition, although Micronaut itself doesn’t scan the classpath, some integrations do (such as JPA and GORM), for these cases you may wish to specify either the application class:
@MicronautTest(application=Application.class)Or the packages:
@MicronautTest(packages="foo.bar")To ensure that entities can be found during classpath scanning.
When using @MicronautTest each @Test method will be wrapped in a transaction that will be rolled back when the test finishes. This behaviour can be changed by using the transactional and rollback properties.
The default transactional behaviour can also be controlled through configuration. Set the micronaut.test.transactional property (for example in application-test.properties or as a system property) to false to disable transactions for all tests that do not explicitly specify the transactional member. Alternatively, set micronaut.test.transactional-default=false to require tests to opt in with @MicronautTest(transactional = true) while still allowing per-test overrides.
To avoid creating a transaction:
@MicronautTest(transactional = false)To not rollback the transaction:
@MicronautTest(rollback = false)Additionally, the transactionMode property can be used to further customize the way that transactions are handled for
each test:
@MicronautTest(transactionMode = TransactionMode.SINGLE_TRANSACTION)The following transaction modes are supported:
-
SEPARATE_TRANSACTIONS(default) - Each setup/cleanup method is wrapped in its own transaction, separate from that of the test. This transaction is always committed. -
SINGLE_TRANSACTION- All setup methods are wrapped in the same transaction as the test. Cleanup methods are wrapped in separate transactions.
To use Mockito, you must include the mockito-core library on your test classpath.
testImplementation("org.mockito:mockito-core")Now, let’s say you want to replace the implementation with a Mockito Mock. You can do so by defining a method that returns a mock and is annotated with @MockBean, for example:
Note that because the bean is an inner class of the test, it will be active only for the scope of the test. This approach allows you to define beans that are isolated per test class.
Note that in most cases you won’t define a @MockBean and inject it, only to verify interaction with the Mock directly. Instead, the Mock will be a collaborator within your application. For example say you have a MathController:
package io.micronaut.test.junit5;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
@Controller("/math")
public class MathController {
MathService mathService;
MathController(MathService mathService) {
this.mathService = mathService;
}
@Get(uri = "/compute/{number}", processes = MediaType.TEXT_PLAIN)
String compute(Integer number) {
return String.valueOf(mathService.compute(number));
}
}The above controller uses the MathService to expose a /math/compute/{number} endpoint. See the following example for a test that tests interaction with the mock collaborator:
The way this works is that @MicronautTest will inject the Mock(..) instance into the test, but the controller will have a proxy that points to the Mock(..) instance injected. For each iteration of the test the mock is refreshed (in fact it uses Micronaut’s built in RefreshScope).
For Factory injected beans, you can use Factory Replacement to inject Mocks. Refer to the factory replacement documentation for more information.
Since @MicronautTest turns tests into beans themselves, it means you can use the @Requires annotation on the test to enable/disable tests. For example:
@MicronautTest
@Requires(env = "my-env")
class RequiresTest {
...
}The above test will only run if my-env is active (you can activate it by passing the system property micronaut.environments).
You can define additional test specific properties using the @Property annotation. The following example demonstrates usage:
@Propertypackage io.micronaut.test.junit5;
import io.micronaut.context.annotation.Property;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import static org.junit.jupiter.api.Assertions.assertEquals;
@MicronautTest
@Property(name = "foo.bar", value = "stuff")
@TestMethodOrder(OrderAnnotation.class)
class PropertyValueTest {
@Property(name = "foo.bar")
String val;
@Test
@Order(1)
void testInitialValue() {
assertEquals("stuff", val);
}
@Property(name = "foo.bar", value = "changed")
@Test
@Order(2)
void testValueChanged() {
assertEquals("changed", val);
}
@Test
@Order(3)
void testValueRestored() {
assertEquals("stuff", val);
}
}Note that when a @Property is defined at the test method level, it causes a RefreshEvent to be triggered which will update any @ConfigurationProperties related to the property.
Alternatively you can specify additional propertySources in any supported format (YAML, JSON, Java properties file etc.) using the @MicronautTest annotation:
propertySources stored in filespackage io.micronaut.test.junit5;
import io.micronaut.context.annotation.Property;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@MicronautTest(propertySources = "myprops.properties")
class PropertySourceTest {
@Property(name = "foo.bar")
String val;
@Test
void testPropertySource() {
Assertions.assertEquals("foo", val);
}
}
@MicronautTest(propertySources = "file:src/test/resources/io/micronaut/test/junit5/fileprops.properties")
class FilePropertySourceTest {
@Property(name = "foo.file")
String val;
@Test
void testFilePropertySource() {
Assertions.assertEquals("file", val);
}
}The above example expects a file located at src/test/resources/io/micronaut/junit5/myprops.properties. You can however use a prefix to indicate where the file should be searched for. The following are valid values:
-
file:myprops.properties- A relative path to a file somewhere on the file system -
classpath:myprops.properties- A file relative to the root of the classpath -
myprops.properties- A file relative on the classpath relative to the test being run.
If you need more dynamic property definition or the property you want to define requires some setup then you can implement the TestPropertyProvider interface in your test and do whatever setup is necessary then return the properties you want to expose the the application.
For example:
TestPropertyProvider interfacepackage io.micronaut.test.junit5;
import io.micronaut.context.annotation.Property;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.util.CollectionUtils;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.test.support.TestPropertyProvider;
import org.junit.jupiter.api.*;
import java.util.Map;
@MicronautTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PropertySourceMapTest implements TestPropertyProvider {
@Property(name = "foo.bar")
String val;
@Test
void testPropertySource() {
Assertions.assertEquals("one", val);
}
@NonNull
@Override
public Map<String, String> getProperties() {
return CollectionUtils.mapOf(
"foo.bar", "one",
"foo.baz", "two"
);
}
}|
Note
|
When using TestPropertyProvider your test must use JUnit’s PER_CLASS test instance lifecycle, for example with @TestInstance(TestInstance.Lifecycle.PER_CLASS). The properties are resolved from the test instance before the Micronaut context starts.
|
You can use combine the use of @Requires and @Property, so that injected beans will be refreshed if there are
configuration changes that affect their @Requires condition.
For that to work, the test must be annotated with @MicronautTest(rebuildContext = true). In that case, if there are
changes in any property for a given test, the application context will be rebuilt so that @Requires conditions are
re-evaluated again.
For example:
@Requires and @Property in a @Refreshable test class.package io.micronaut.test.junit5;
import io.micronaut.context.annotation.Property;
import io.micronaut.context.annotation.Requires;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.hamcrest.MatcherAssert;
import org.hamcrest.core.IsInstanceOf;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
// https://github.com/micronaut-projects/micronaut-test/issues/91
@MicronautTest(rebuildContext = true)
@Property(name = "foo.bar", value = "stuff")
@TestMethodOrder(OrderAnnotation.class)
class PropertyValueRequiresTest {
@Inject
MyService myService;
@Test
@Order(1)
void testInitialValue() {
MatcherAssert.assertThat(myService, IsInstanceOf.instanceOf(MyServiceStuff.class));
}
@Property(name = "foo.bar", value = "changed")
@Test
@Order(2)
void testValueChanged() {
MatcherAssert.assertThat(myService, IsInstanceOf.instanceOf(MyServiceChanged.class));
}
@Test
@Order(3)
void testValueRestored() {
MatcherAssert.assertThat(myService, IsInstanceOf.instanceOf(MyServiceStuff.class));
}
}
interface MyService {}
@Singleton
@Requires(property = "foo.bar", value = "stuff")
class MyServiceStuff implements MyService {}
@Singleton
@Requires(property = "foo.bar", value = "changed")
class MyServiceChanged implements MyService {}You can write integration tests that test external servers in a couple of different ways.
One way is with the micronaut.test.server.executable property that allows you to specify the location of an executable JAR or native image of a server that should be started and shutdown for the lifecycle of test.
In this case Micronaut Test will replace the regular server with an instance of TestExecutableEmbeddedServer that executes the process to start the server and closes the process when the test ends.
For example:
micronaut.test.server.executable@Property(
name = TestExecutableEmbeddedServer.PROPERTY,
value = "src/test/apps/test-app.jar"
)Alternatively if you have independently started an EmbeddedServer instance programmatically you can also specify the URL to the server with the micronaut.test.server.url property.
By default, with JUnit 5 the test method parameters will be resolved to beans if possible. As this behaviour can be problematic if in combination with the @ParameterizedTest annotation, it can be disabled.
Observing test outcomes
TestExecutionListener beans are notified as a test class runs. Alongside the before*/after* callbacks, four callbacks report how each test actually ended:
|
Note
|
These four callbacks are fired by the JUnit 5 integration. On Spock and Kotest they remain no-ops, so a listener shared across frameworks should not depend on them firing. |
Test instance lifecycle
The test instance is injected from the application context, so @PostConstruct on a test class is called. @PreDestroy is called too, once per test instance - after each test method under the default PER_METHOD lifecycle, and once after the class under @TestInstance(PER_CLASS). A test class may therefore acquire a resource in @PostConstruct and release it in @PreDestroy:
@MicronautTest
class ResourceTest {
private Path workspace;
@PostConstruct
void open() throws IOException {
workspace = Files.createTempDirectory("test");
}
@PreDestroy
void close() throws IOException {
Files.deleteIfExists(workspace);
}
}|
Tip
|
For a temporary directory specifically, prefer JUnit’s own @TempDir - it works on a @MicronautTest class as a field or a parameter, and mixes freely with Micronaut-injected parameters in the same method signature.
|
Sharing one application context with @Nested
Every @MicronautTest class gets its own ApplicationContext, started before the class and stopped after it. There is no context cache: two top-level classes with identical configuration get two contexts, and so does each subclass of an annotated base class.
@Nested is the way to run several groups of tests against a single context. A nested class reuses the application context - and the beans, mocks and properties in it - of its outermost enclosing class, so an expensive context is paid for once no matter how many nested groups a class contains:
|
Note
|
Nesting is also how the same context is kept single-threaded under parallel execution - see Parallel Test Execution, where a nested class locks on its outermost enclosing class. |
|
Tip
|
If the slow part of a test suite is a database or a container rather than the context itself, sharing that instead is usually the bigger win. See Micronaut Test Resources. |
Configuration on @Nested classes
Sharing the enclosing class’s context has a consequence: configuration declared on the nested class itself has nowhere to go, so declaring it is an error:
A @Nested class that declares no configuration of its own is unaffected, and still gets the enclosing class’s injected beans.
|
Important
|
Before Micronaut Test 5.2.0 these annotations were silently ignored, so a nested class appeared to be configured when it was not. Tests that relied on the ignored annotation were already running against the enclosing class’s configuration; move the annotation to the enclosing class, or promote the nested class to a top-level test with its own @MicronautTest.
|
JUnit 5 can run tests in parallel. Micronaut Test supports this by guaranteeing that:
-
different
@MicronautTestclasses run at the same time, each with its own application context and, where applicable, its own embedded server; -
everything that shares one application context runs one at a time.
The second guarantee is what makes the first one safe. A @MicronautTest class owns a single ApplicationContext for the whole class, so its beans - including every MockBean - are singletons shared by every method of that class. Method level @Property values, rebuildContext = true, @Sql phases and TestExecutionListener state are all held per class as well. Running two methods of the same class concurrently would race on all of it.
Micronaut Test enforces this with a JUnit resource lock that @MicronautTest declares for you. There is nothing to configure: the lock is keyed on the test class, so it never serialises unrelated classes.
|
Note
|
@Nested classes reuse the extension instance of their outermost enclosing class, so they lock on that class - a nested test never runs alongside the methods of the class that encloses it.
|
Enabling parallel execution
Parallel execution is a JUnit setting, not a Micronaut one. Enable it in src/test/resources/junit-platform.properties:
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=same_thread
junit.jupiter.execution.parallel.mode.classes.default=concurrentThis is the recommended configuration: test classes run concurrently, methods within a class do not. Setting mode.default=concurrent as well is harmless - the resource lock keeps each context single-threaded regardless - but it buys you nothing unless you also opt classes in, as described below.
Making your tests parallel-safe
The lock protects the state Micronaut Test owns. It cannot protect anything your tests share between classes, and that is where most failures come from:
-
In-memory databases. Two classes pointing at
jdbc:h2:mem:testdbshare one database. Give each test class its own URL, or use Test Resources. -
Fixed ports. Let
@MicronautTestallocate a random port rather than pinningmicronaut.server.port. -
Static mutable state, temporary files with fixed names, and system properties set from test code.
-
Shared external services - a single Testcontainers instance, a stub server, a message broker topic.
|
Tip
|
Every additional concurrent class is another live application context, connection pool and embedded server. Raising junit.jupiter.execution.parallel.config.fixed.parallelism past what the machine can hold will slow the build down rather than speed it up.
|
Running the methods of one class concurrently
If a test class genuinely is thread-safe - no mocks, no method level @Property, no transactional fixtures, no shared bean state - declare @Execution(CONCURRENT) on the class to opt it out of the lock:
From that point the thread safety of the test class, of the beans it injects and of any TestExecutionListener on its classpath is yours to guarantee. In particular, Micronaut Data’s transaction support keeps one transaction per context, so a class using @Transactional fixtures must not opt out.
To opt out for a whole JVM - for example to reproduce a race - set the micronaut.test.parallel.methods system property:
test {
systemProperty "micronaut.test.parallel.methods", "true"
}Limitations
-
JUnit 5.12 or newer is required. The lock is declared with
@ResourceLock(providers = …), andproviderswas added in JUnit 5.12. On an older JUnit the Jupiter engine fails to execute any@MicronautTestclass. Micronaut Test manages a supported version for you; only overridejunit-bomupwards. -
The opt-out is per class, not per method. A class either serialises all of its methods or none of them.
-
A
@TestFactoryholds the lock for all of its dynamic tests, because they execute inside the factory node. -
This applies to JUnit 5 only. The Spock and Kotest integrations share the same extension state and do not carry the resource lock, so parallel execution is not supported there. See Concurrency with Kotest 6 for what Kotest 6 offers.
-
The extension is not made thread-safe by this. The lock stops JUnit from entering the extension concurrently; it does not make AbstractMicronautExtension safe to drive from threads you start yourself inside a test.
To get started using Kotest you need the following dependencies in your build configuration:
dependencies {
kaptTest "io.micronaut:micronaut-inject-java"
testImplementation "io.micronaut.test:micronaut-test-kotest5"
testImplementation "io.mockk:mockk:{mockkVersion}"
testImplementation "io.kotest:kotest-runner-junit6-jvm:{kotestVersion}"
}
// use JUnit 5 platform
test {
useJUnitPlatform()
}Or for Maven:
<dependency>
<groupId>io.micronaut.test</groupId>
<artifactId>micronaut-test-kotest5</artifactId>
<version>5.2.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.mockk</groupId>
<artifactId>mockk</artifactId>
<version>{mockkVersion}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.kotest</groupId>
<artifactId>kotest-runner-junit6-jvm</artifactId>
<version>{kotestVersion}</version>
<scope>test</scope>
</dependency>Note that for Maven you will also need to configure the Surefire plugin to use JUnit platform and configure the kotlin maven plugin:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.6.2</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<artifactId>kotlin-maven-plugin</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>1.4.10</version>
<configuration>
<compilerPlugins>
<plugin>all-open</plugin>
</compilerPlugins>
<pluginOptions>
<option>all-open:annotation=io.micronaut.aop.Around</option>
</pluginOptions>
</configuration>
<executions>
<execution>
<id>kapt</id>
<goals>
<goal>kapt</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.baseDir}/src/main/kotlin</sourceDir>
</sourceDirs>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-inject-java</artifactId>
<version>${micronaut.version}</version>
</annotationProcessorPath>
<annotationProcessorPath>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-validation</artifactId>
<version>${micronaut.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
</configuration>
</execution>
<execution>
<id>compile</id>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-kapt</id>
<goals>
<goal>test-kapt</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>src/test/kotlin</sourceDir>
</sourceDirs>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-inject-java</artifactId>
<version>${micronaut.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
<sourceDir>${project.basedir}/target/generated-sources/kapt/test</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlinVersion}</version>
</dependency>
</dependencies>
</plugin>Before you can get started writing tests with Kotest, it is necessary to inform Kotest of the Micronaut extensions. The way to do that is by providing a ProjectConfig. Here is how to do so for Micronaut Test:
package io.micronaut.test.kotest5
import io.kotest.core.config.AbstractProjectConfig
import io.micronaut.test.extensions.kotest5.MicronautKotest5Extension
@Suppress("unused")
object ProjectConfig : AbstractProjectConfig() {
override val extensions = listOf(MicronautKotest5Extension)
}Let’s take a look at an example using Kotest. Consider you have the following interface:
package io.micronaut.test.kotest5
interface MathService {
fun compute(num: Int): Int
}And a simple implementation that computes the value times 4 and is defined as a Micronaut bean:
package io.micronaut.test.kotest5
import jakarta.inject.Singleton
@Singleton
internal class MathServiceImpl : MathService {
override fun compute(num: Int): Int {
return num * 4
}
}You can define the following test to test the implementation:
The @MicronautTest annotation supports specifying the environment names the test should run with:
@MicronautTest(environments={"foo", "bar"})In addition, although Micronaut itself doesn’t scan the classpath, some integrations do (such as JPA and GORM), for these cases you may wish to specify either the application class:
@MicronautTest(application=Application.class)Or the packages:
@MicronautTest(packages="foo.bar")To ensure that entities can be found during classpath scanning.
When using @MicronautTest each @Test method will be wrapped in a transaction that will be rolled back when the test finishes. This behaviour can be changed by using the transactional and rollback properties.
The default transactional behaviour can also be controlled through configuration. Set the micronaut.test.transactional property (for example in application-test.properties or as a system property) to false to disable transactions for all tests that do not explicitly specify the transactional member. Alternatively, set micronaut.test.transactional-default=false to require tests to opt in with @MicronautTest(transactional = true) while still allowing per-test overrides.
To avoid creating a transaction:
@MicronautTest(transactional = false)To not rollback the transaction:
@MicronautTest(rollback = false)Additionally, the transactionMode property can be used to further customize the way that transactions are handled for
each test:
@MicronautTest(transactionMode = TransactionMode.SINGLE_TRANSACTION)The following transaction modes are supported:
-
SEPARATE_TRANSACTIONS(default) - Each setup/cleanup method is wrapped in its own transaction, separate from that of the test. This transaction is always committed. -
SINGLE_TRANSACTION- All setup methods are wrapped in the same transaction as the test. Cleanup methods are wrapped in separate transactions.
|
Note
|
Setup and cleanup methods are not wrapped in transactions in Kotest currently. As a result, transaction modes have no effect in Kotest. |
Now let’s say you want to replace the implementation with a Mockk. You can do so by defining a method that returns a mock and is annotated with @MockBean, for example:
Note that because the bean is a method of the test, it will be active only for the scope of the test. This approach allows you to define beans that are isolated per test class.
|
Important
|
Because Kotlin uses constructor injection, it’s not possible to automatically replace the mock proxy with the mock implementation as is done with the other test implementations. The getMock method was created to make retrieving the underlying mock object easier.
|
Note that in most cases you won’t define a @MockBean and inject it only to verify interaction with the Mock directly. Instead, the Mock will be a collaborator within your application. For example say you have a MathController:
package io.micronaut.test.kotest5
import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
@Controller("/math")
class MathController internal constructor(internal var mathService: MathService) {
@Get(uri = "/compute/{number}", processes = [MediaType.TEXT_PLAIN])
internal fun compute(number: Int): String {
return mathService.compute(number).toString()
}
}The above controller uses the MathService to expose a /math/compute/{number} endpoint. See the following example for a test that tests interaction with the mock collaborator:
|
Important
|
Table-driven testing requires new dependency |
testImplementation("io.kotest:kotest-assertions-table")The way this works is that @MicronautTest will inject a proxy that points to the mock instance. For each iteration of the test the mock is refreshed (in fact it uses Micronaut’s built in RefreshScope).
Since @MicronautTest turns tests into beans themselves, it means you can use the @Requires annotation on the test to enable/disable tests. For example:
@MicronautTest
@Requires(env = "my-env")
class RequiresTest {
...
}The above test will only run if my-env is active (you can activate it by passing the system property micronaut.environments).
You can define additional test specific properties using the @Property annotation. The following example demonstrates usage:
@Propertypackage io.micronaut.test.kotest5
import io.kotest.core.spec.style.AnnotationSpec
import io.kotest.matchers.shouldBe
import io.micronaut.context.annotation.Property
import io.micronaut.context.annotation.Value
import io.micronaut.test.extensions.kotest5.annotation.MicronautTest
@MicronautTest
@Property(name = "foo.bar", value = "stuff")
class PropertyValueTest: AnnotationSpec() {
@Value("\${foo.bar}")
lateinit var value: String
@Test
fun testInitialValue() {
value shouldBe "stuff"
}
@Property(name = "foo.bar", value = "changed")
@Test
fun testValueChanged() {
value shouldBe "changed"
}
@Test
fun testValueRestored() {
value shouldBe "stuff"
}
}Alternatively you can specify additional propertySources in any supported format (YAML, JSON, Java properties file etc.) using the @MicronautTest annotation:
propertySources stored in filespackage io.micronaut.test.kotest5
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import io.micronaut.context.annotation.Property
import io.micronaut.test.extensions.kotest5.annotation.MicronautTest
@MicronautTest(propertySources = ["myprops.properties"])
@Property(name = "supplied.value", value = "hello")
class PropertySourceTest(@Property(name = "foo.bar") val value: String,
@Property(name = "supplied.value") val suppliedValue: String) : BehaviorSpec({
given("a property source") {
`when`("the value is injected") {
then("the correct value is injected") {
value shouldBe "foo"
suppliedValue shouldBe "hello"
}
}
}
})The above example expects a file located at src/test/resources/io/micronaut/kotest/myprops.properties. You can however use a prefix to indicate where the file should be searched for. The following are valid values:
-
file:myprops.properties- A relative path to a file somewhere on the file system -
classpath:myprops.properties- A file relative to the root of the classpath -
myprops.properties- A file relative on the classpath relative to the test being run.
|
Note
|
Because Kotlin doesn’t support multiple annotations, the @PropertySource annotation must be used to define multiple properties.
|
There are a couple caveats to using constructor injection to be aware of.
-
In order for
TestPropertyProviderto work, test classes must have not have any constructor arguments. This is because the class needs to be constructed prior to bean creation, in order to add the properties to the context. Fields and methods will still be injected. -
@Requires()cannot be used with constructor injection because Kotest requires the instance to be created regardless if the test should be ignored or not. If the requirements disable the bean, it cannot be created from the context and thus construction responsibility will be delegated to the default behavior.
You can use combine the use of @Requires and @Property, so that injected beans will be refreshed if there are
configuration changes that affect their @Requires condition.
For that to work, the test must be annotated with @MicronautTest(rebuildContext = true). In that case, if there are
changes in any property for a given test, the application context will be rebuilt so that @Requires conditions are
re-evaluated again.
For example:
@Requires and @Property in a @Refreshable test class.package io.micronaut.test.kotest5
import io.kotest.core.spec.style.AnnotationSpec
import io.kotest.matchers.types.shouldBeInstanceOf
import io.micronaut.context.annotation.Property
import io.micronaut.context.annotation.Requires
import io.micronaut.test.extensions.kotest5.annotation.MicronautTest
import jakarta.inject.Inject
import jakarta.inject.Singleton
@MicronautTest(rebuildContext = true)
@Property(name = "foo.bar", value = "stuff")
class PropertyValueRequiresTest: AnnotationSpec() {
@Inject
lateinit var myService: MyService
@Test
fun testInitialValue() {
myService.shouldBeInstanceOf<MyServiceStuff>()
}
@Property(name = "foo.bar", value = "changed")
@Test
fun testValueChanged() {
myService.shouldBeInstanceOf<MyServiceChanged>()
}
@Test
fun testValueRestored() {
myService.shouldBeInstanceOf<MyServiceStuff>()
}
}
interface MyService
@Singleton
@Requires(property = "foo.bar", value = "stuff")
open class MyServiceStuff : MyService
@Singleton
@Requires(property = "foo.bar", value = "changed")
open class MyServiceChanged : MyServiceMicronaut Test fully supports the concurrency features introduced in Kotest 6.
Kotest provides two levels of concurrency control:
-
Spec Concurrency Mode — Controls whether multiple test classes (specs) execute concurrently.
-
Test Concurrency Mode — Controls whether multiple root tests within a single spec execute concurrently.
By default, all tests run sequentially. This is the safest configuration, as it avoids race conditions and does not require tests to be thread-safe.
Test Concurrency Mode
Test concurrency mode determines whether root tests within a spec are executed sequentially or concurrently.
The following example demonstrates running root tests concurrently:
package io.micronaut.test.kotest5
import io.kotest.core.spec.style.FreeSpec
import io.kotest.engine.concurrency.TestExecutionMode
import io.kotest.matchers.shouldBe
import io.micronaut.test.extensions.kotest5.annotation.MicronautTest
import kotlinx.coroutines.delay
@MicronautTest
class ConcurrentTestsSpec : FreeSpec({
// Configure this spec to run tests concurrently
testExecutionMode = TestExecutionMode.Concurrent
"test 1" {
// This test will run concurrently with other tests
1 + 2 shouldBe 3
delay(1000)
}
"test 2" {
// This test will run concurrently with other tests
1 + 2 shouldBe 3
delay(500)
}
"test 3" {
// This test will run concurrently with other tests
1 + 2 shouldBe 3
delay(200)
}
})When TestExecutionMode.Concurrent is enabled, all root tests in the spec start at approximately the same time and execute in parallel. This can significantly reduce execution time.
Micronaut applications can also be written in Python (see the Micronaut Framework documentation), and so can their tests. A Python test class is compiled by micronaut-inject-python into a regular JUnit 5 test class, so it uses the JUnit 5 @MicronautTest extension described in the previous chapter: everything that is a property of the extension (application context per test class, transactions, @Property, @Requires, parallel execution, …) behaves the same way, while the tests themselves are written in Python.
This chapter walks through the JUnit 5 examples again, in Python.
Python tests live in src/test/python and are compiled by the Micronaut Python compiler together with the JUnit 5 support for Python test classes, which you need as a test dependency:
testImplementation("io.micronaut:micronaut-inject-python-test")In addition you need the JUnit 5 integration of Micronaut Test and the JUnit engine:
testImplementation("io.micronaut.test:micronaut-test-junit5")testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")The build has to run the tests on the JUnit platform (useJUnitPlatform() with Gradle, or the Surefire plugin with Maven), just like a Java JUnit 5 test.
A Python test class is any class that has methods decorated with @Test from org.junit.jupiter.api. The @MicronautTest decorator of the JUnit 5 integration, micronaut.test.extensions.junit5.annotation.MicronautTest, turns it into a Micronaut test. JUnit’s own decorators (@Test, @Disabled, @BeforeEach, @AfterEach, @Order, @TestMethodOrder, @Execution, …) are copied to the compiled test class, so JUnit runs the Python test exactly like a Java one.
|
Note
|
Python tests need the GraalPy runtime and therefore a GraalVM JDK to run at full speed; see the Python runtime section of the Micronaut Framework documentation. |
Let’s take a look at an example using Python. Consider you have the following interface, an abstract base class in Python:
from abc import ABC, abstractmethod
class MathService(ABC):
@abstractmethod
def compute(self, num: int) -> int:
...And a simple implementation that computes the value times 4 and is defined as a Micronaut bean:
from jakarta.inject import Singleton
from .MathService import MathService
@Singleton
class MathServiceImpl(MathService):
def compute(self, num: int) -> int:
return num * 4You can define the following test to test the implementation:
|
Note
|
JUnit’s @ParameterizedTest (from org.junit.jupiter.params) is not available to Python tests; loop over the test data inside a @Test method instead, as shown above.
|
The @MicronautTest decorator supports specifying the environment names the test should run with:
from typing import Annotated
from jakarta.inject import Inject
from micronaut.context.env import Environment
from micronaut.test.extensions.junit5.annotation import MicronautTest
from org.junit.jupiter.api import Test
@MicronautTest(environments=["foo", "bar"])
class EnvironmentsTest:
environment: Annotated[Environment, Inject]
@Test
def test_environments_are_active(self):
active = self.environment.getActiveNames()
assert active.contains("foo")
assert active.contains("bar")The application and packages members described for JUnit 5 are available in the same way, for example @MicronautTest(packages="foo.bar").
When using @MicronautTest each @Test method will be wrapped in a transaction that will be rolled back when the test finishes, exactly as described for JUnit 5: the transactional, rollback and transactionMode members of @MicronautTest and the micronaut.test.transactional / micronaut.test.transactional-default configuration properties all apply to Python tests.
The following test runs against an H2 database with Micronaut Data’s JDBC transaction management on the classpath, and verifies that a transaction is active around the test and its setup and cleanup methods:
To avoid creating a transaction:
Set rollback=False to keep the changes a test makes, and transactionMode=TransactionMode.SINGLE_TRANSACTION (from micronaut.test.annotation) to run setup methods in the same transaction as the test, see Loading SQL before tests for an example.
Now let’s say you want to replace the implementation with a test double. Python has no compiled mocking library like Mockito, so the idiomatic approach is a hand-written class that records the calls it receives. The test double is a bean that replaces the real implementation with @Replaces and is only active for the test that declares it through a @Requires condition on a property that the test sets:
The real MathServiceImpl stays in place for every other test of the suite.
|
Note
|
@MockBean methods, the way JUnit 5, Spock and Kotest tests declare mocks, work for Python test doubles too - see Mocking Collaborators.
|
Note that in most cases you won’t define a test double and inject it, only to verify interaction with it directly. Instead, the test double will be a collaborator within your application. For example say you have a MathController:
from micronaut.http import MediaType
from micronaut.http.annotation import Controller, Get
from .MathService import MathService
@Controller("/math")
class MathController:
def __init__(self, math_service: MathService):
self.math_service = math_service
@Get(uri="/compute/{number}", processes=MediaType.TEXT_PLAIN)
def compute(self, number: int) -> str:
return str(self.math_service.compute(number))The above controller uses the MathService to expose a /math/compute/{number} endpoint. See the following example for a test that tests interaction with the mock collaborator:
The way this works is that the test double is a singleton for the scope of the test, so the instance injected into the test is the very instance the controller collaborates with.
The test double can also be declared with a @MockBean method, the way JUnit 5, Spock and Kotest tests declare mocks:
The way this works is that @MicronautTest injects the test double into the test, but the controller has a proxy that points to the test double instance. For each iteration of the test the test double is refreshed (in fact it uses Micronaut’s built in RefreshScope); the proxy is seen from Python as the test double itself, so its attributes (result, calls) are read and written through it.
Since @MicronautTest turns tests into beans themselves, it means you can use the @Requires annotation on the test to enable/disable tests. For example:
from micronaut.context.annotation import Requires
from micronaut.test.extensions.junit5.annotation import MicronautTest
from org.junit.jupiter.api import Test
@MicronautTest
@Requires(property="does.not.exist")
class RequiresTest:
@Test
def test_not_executed(self):
assert False, "Should never be executed"The above test will only run if the does.not.exist property is defined; otherwise it is reported as skipped. Any @Requires condition works, for example @Requires(env="my-env") to run a test only when the my-env environment is active (you can activate it by passing the system property micronaut.environments).
You can define additional test specific properties using the @Property annotation. The following example demonstrates usage:
@Propertyfrom typing import Annotated
from micronaut.context.annotation import Property
from micronaut.test.extensions.junit5.annotation import MicronautTest
from org.junit.jupiter.api import Test
@MicronautTest
@Property(name="foo.bar", value="stuff")
class PropertyValueTest:
val: Annotated[str, Property(name="foo.bar")]
@Test
def test_initial_value(self):
assert "stuff" == self.val
@Property(name="foo.bar", value="changed")
@Test
def test_value_changed(self):
assert "changed" == self.valNote that when a @Property is defined at the test method level, it causes a RefreshEvent to be triggered which will update any @ConfigurationProperties related to the property. The value is restored for the following test methods.
Alternatively you can specify additional propertySources in any supported format (YAML, JSON, Java properties file etc.) using the @MicronautTest annotation:
propertySources stored in filesfrom typing import Annotated
from micronaut.context.annotation import Property
from micronaut.test.extensions.junit5.annotation import MicronautTest
from org.junit.jupiter.api import Test
@MicronautTest(propertySources="myprops.properties")
class PropertySourceTest:
val: Annotated[str, Property(name="foo.bar")]
@Test
def test_property_source(self):
assert "foo" == self.val
@MicronautTest(propertySources="file:src/test/resources/micronaut/test/python/fileprops.properties")
class FilePropertySourceTest:
val: Annotated[str, Property(name="foo.file")]
@Test
def test_file_property_source(self):
assert "file" == self.valThe first example expects a file located at src/test/resources/micronaut/test/python/myprops.properties, next to the compiled test class. You can however use a prefix to indicate where the file should be searched for. The following are valid values:
-
file:myprops.properties- A relative path to a file somewhere on the file system -
classpath:myprops.properties- A file relative to the root of the classpath -
myprops.properties- A file relative on the classpath relative to the test being run.
|
Note
|
The TestPropertyProvider interface is not supported for Python tests: its getProperties() method is called before the application context, and with it the Python runtime, exists. Use @Property, propertySources or a Java TestPropertyProvider for properties that need dynamic setup.
|
You can use combine the use of @Requires and @Property, so that injected beans will be refreshed if there are
configuration changes that affect their @Requires condition.
For that to work, the test must be annotated with @MicronautTest(rebuildContext = true). In that case, if there are
changes in any property for a given test, the application context will be rebuilt so that @Requires conditions are
re-evaluated again.
For example:
@Requires and @Property in a @Refreshable test class.from abc import ABC
from typing import Annotated
from jakarta.inject import Inject, Singleton
from micronaut.context.annotation import Property, Requires
from micronaut.test.extensions.junit5.annotation import MicronautTest
from org.junit.jupiter.api import Test
class MyService(ABC):
pass
@Singleton
@Requires(property="foo.bar", value="stuff")
class MyServiceStuff(MyService):
pass
@Singleton
@Requires(property="foo.bar", value="changed")
class MyServiceChanged(MyService):
pass
# https://github.com/micronaut-projects/micronaut-test/issues/91
@MicronautTest(rebuildContext=True)
@Property(name="foo.bar", value="stuff")
class PropertyValueRequiresTest:
my_service: Annotated[MyService, Inject]
@Test
def test_initial_value(self):
assert isinstance(self.my_service, MyServiceStuff)
@Property(name="foo.bar", value="changed")
@Test
def test_value_changed(self):
assert isinstance(self.my_service, MyServiceChanged)You can write integration tests that test external servers in the same ways as with JUnit 5.
One way is with the micronaut.test.server.executable property that allows you to specify the location of an executable JAR or native image of a server that should be started and shutdown for the lifecycle of test.
In this case Micronaut Test will replace the regular server with an instance of TestExecutableEmbeddedServer that executes the process to start the server and closes the process when the test ends.
For example:
micronaut.test.server.executable@Property(
name="micronaut.test.server.executable",
value="../test-junit5/src/test/apps/test-app.jar"
)The test properties, including the other @Property values of the test, are passed to the process as system properties.
Alternatively if you have independently started an EmbeddedServer instance programmatically you can also specify the URL to the server with the micronaut.test.server.url property.
By default, the parameters of a @Test method are resolved to beans if possible, exactly like with JUnit 5 in Java. The type hint of a parameter selects the bean, and Annotated adds qualifiers or @Property / @Value for configuration values:
from typing import Annotated
from micronaut.context.annotation import Property, Value
from micronaut.http.client import HttpClient
from micronaut.http.client.annotation import Client
from micronaut.test.extensions.junit5.annotation import MicronautTest
from org.junit.jupiter.api import Test
from .MathService import MathService
@MicronautTest
@Property(name="foo.bar", value="test")
class ArgumentInjectionTest:
@Test
def test_argument_injected(
self,
math_service: MathService,
val: Annotated[str, Property(name="foo.bar")],
client: Annotated[HttpClient, Client("/")],
):
result = math_service.compute(2)
assert 8 == result
assert client is not None
assert "test" == val
@Test
def test_value_argument_injected(self, val: Annotated[str, Value("${foo.bar}")]):
assert "test" == valAs this behaviour can be problematic in some cases, it can be disabled with resolveParameters=False:
Observing test outcomes
TestExecutionListener beans are notified as a test class runs, and the listener itself can be written in Python. Alongside the before*/after* callbacks, four callbacks report how each test actually ended:
|
Note
|
The callbacks of TestExecutionListener are default methods of the Java interface; the Python class overrides the ones it needs. The listener above is a regular bean: the @Requires condition limits it to the test that sets the spec.name property.
|
The following test verifies the listener, and shows JUnit’s @Order, @TestMethodOrder and @Disabled in a Python test:
from typing import Annotated
from jakarta.inject import Inject
from micronaut.context.annotation import Property
from micronaut.test.extensions.junit5.annotation import MicronautTest
from org.junit.jupiter.api import Disabled, MethodOrderer, Order, Test, TestMethodOrder
from .OutcomeListener import OutcomeListener
@MicronautTest
@Property(name="spec.name", value="TestOutcomeListenerTest")
@TestMethodOrder(MethodOrderer.OrderAnnotation)
class TestOutcomeListenerTest:
listener: Annotated[OutcomeListener, Inject]
@Test
@Order(1)
def test_first(self):
assert self.listener is not None
@Test
@Disabled("never runs")
def test_disabled(self):
assert False
@Test
@Order(2)
def test_outcomes_are_reported(self):
assert "test_first()" in self.listener.successful
assert self.listener.failed == []Test instance lifecycle
The test instance is injected from the application context, so @PostConstruct on a test class is called. @PreDestroy is called too, once per test instance - after each test method under the default PER_METHOD lifecycle, and once after the class under @TestInstance(TestInstance.Lifecycle.PER_CLASS). A test class may therefore acquire a resource in @PostConstruct and release it in @PreDestroy:
import os
import tempfile
from jakarta.annotation import PostConstruct, PreDestroy
from micronaut.test.extensions.junit5.annotation import MicronautTest
from org.junit.jupiter.api import Test
@MicronautTest
class ResourceTest:
workspace: str | None = None
@PostConstruct
def open(self):
self.workspace = tempfile.mkdtemp(prefix="test")
@PreDestroy
def close(self):
if self.workspace is not None:
os.rmdir(self.workspace)
@Test
def test_workspace_available(self):
assert self.workspace is not None
assert os.path.isdir(self.workspace)Nested test classes
Every @MicronautTest class gets its own ApplicationContext, started before the class and stopped after it. There is no context cache: two top-level classes with identical configuration get two contexts.
JUnit’s @Nested classes are the way to run several groups of tests against a single context, as for JUnit 5 tests in Java: a class nested in a Python test class is compiled to an inner class of the generated test class, so JUnit runs it with the application context - and the beans, mocks and properties in it - of its outermost enclosing class, and injects its attributes:
|
Note
|
Configuration declared on the nested class itself (@Property, a second @MicronautTest) has nowhere to go and is rejected with an ExtensionConfigurationException, as in Java.
|
Python tests are JUnit 5 tests, so everything described in Parallel Test Execution applies unchanged: different @MicronautTest classes run concurrently once parallel execution is enabled in junit-platform.properties, while the methods of one class are serialised by the resource lock that @MicronautTest declares.
A class that genuinely is thread-safe can opt out of the lock with @Execution(ExecutionMode.CONCURRENT), which is copied to the compiled test class like every other JUnit decorator:
Netty applications, including e.g. Micronaut HTTP Server Netty, use a reference count based resource management approach that is more performant than normal Java garbage collection. These reference counts come with the risk of resource and memory leaks, however. Broad leak detection in tests is a crucial instrument to avoid these bugs.
Netty 4.2.7 introduces a new, light-weight leak detection mechanism specifically designed for tests, called the leak presence detector. Micronaut Test includes JUnit Jupiter and Spock extensions that performs leak detection on all tests.
testRuntimeOnly("io.micronaut.test:micronaut-test-netty-leak")|
Warning
|
This extension will only work with Netty 4.2.7 and later. [Micronaut 4.10.0](https://github.com/micronaut-projects/micronaut-platform/releases/tag/v4.10.0) ships with Netty 4.2.7. |
To enable JUnit leak detection, either extend your test like this:
import io.netty.buffer.ByteBufAllocator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(JupiterLeakPresenceExtension.class)
public class LeakyTest {
// this test triggers leak detection.
@Test
void test() {
ByteBufAllocator.DEFAULT.buffer();
}
}Or enable extension auto detection using the -Djunit.jupiter.extensions.autodetection.enabled=true system property:
tasks {
test {
useJUnitPlatform()
systemProperty("junit.jupiter.extensions.autodetection.enabled", "true")
}
}For Spock tests, you do not need to use a system property or add the extension to your tests manually, the extension is added automatically.
A small utility module exists that helps integrate the REST-assured library. Simply add the following dependency:
testImplementation("io.micronaut.test:micronaut-test-rest-assured")You can then inject instances of RequestSpecification into test fields or method parameters (parameters are only supported with JUnit 5):
package io.micronaut.test.rest.assured;
import static org.hamcrest.CoreMatchers.is;
import org.junit.jupiter.api.Test;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.restassured.specification.RequestSpecification;
@MicronautTest
public class RestAssuredHelloWorldTest {
@Test
void testHelloWorld(RequestSpecification spec) {
spec
.when().get("/hello/world")
.then().statusCode(200)
.body(is("Hello World"));
}
}|
Tip
|
See the guides for Using Micronaut Test REST-assured in a Micronaut Application and Testing REST API Integrations Using Testcontainers with WireMock or MockServer to learn more. |
When performing a test with a backing database, often some data is required in the database prior to running the tests.
As of micronaut-test version 4.1.0, there is an annotation Sql.
This annotation can be used to specify the location of one or more sql files to be executed at one of four phases in your test execution:
-
BEFORE_CLASS- executed once before the tests are run (the default). -
BEFORE_METHOD- executed before each test method. -
AFTER_METHOD- executed after each test method. -
AFTER_CLASS- executed once after all the tests are run.
The files are executed in the order specified in the annotation.
For example given the two SQL scripts
CREATE TABLE MyTable (
ID INT NOT NULL,
NAME VARCHAR(255),
PRIMARY KEY (ID)
);and
INSERT INTO MyTable (ID, NAME) VALUES (1, 'Aardvark');
INSERT INTO MyTable (ID, NAME) VALUES (2, 'Albatross');We can annotate a test to run these two scripts prior to the test.
@MicronautTest(transactionMode = TransactionMode.SINGLE_TRANSACTION)
@Property(name = "datasources.default.dialect", value = "H2")
@Property(name = "datasources.default.driverClassName", value = "org.h2.Driver")
@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP")
@Property(name = "datasources.default.url", value = "jdbc:h2:mem:SqlDatasourceTest;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE")
@Property(name = "datasources.default.username", value = "sa")
@Sql({"classpath:create.sql", "classpath:datasource_1_insert.sql"}) //
class SqlDatasourceTest {
@Inject
DataSource dataSource;
@Test
void dataIsInserted() throws Exception {
assertEquals(List.of("Aardvark", "Albatross"), readAllNames(dataSource));
}
List<String> readAllNames(DataSource dataSource) throws SQLException {
var result = new ArrayList<String>();
try (
Connection ds = dataSource.getConnection();
PreparedStatement ps = ds.prepareStatement("select name from MyTable");
ResultSet rslt = ps.executeQuery()
) {
while(rslt.next()) {
result.add(rslt.getString(1));
}
}
return result;
}
}@MicronautTest
@Property(name = "datasources.default.dialect", value = "H2")
@Property(name = "datasources.default.driverClassName", value = "org.h2.Driver")
@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP")
@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE")
@Property(name = "datasources.default.username", value = "sa")
@Sql(["classpath:create.sql", "classpath:datasource_1_insert.sql"]) //
class SqlDatasourceSpec extends Specification {
@Inject
DataSource dataSource
def "data is inserted"() {
expect:
readAllNames(dataSource) == ["Aardvark", "Albatross"]
}
List<String> readAllNames(DataSource dataSource) {
dataSource.getConnection().withCloseable {
it.prepareStatement("select name from MyTable").withCloseable {
it.executeQuery().withCloseable {
def names = []
while (it.next()) {
names << it.getString(1)
}
names
}
}
}
}
}@MicronautTest
@Property(name = "datasources.default.dialect", value = "H2")
@Property(name = "datasources.default.driverClassName", value = "org.h2.Driver")
@Property(name = "datasources.default.schema-generate", value = "CREATE_DROP")
@Property(name = "datasources.default.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE")
@Property(name = "datasources.default.username", value = "sa")
@Sql("classpath:create.sql", "classpath:datasource_1_insert.sql") //
class SqlDatasourceTest(
private val dataSource: DataSource
): BehaviorSpec({
fun readAllNames(dataSource: DataSource): List<String> {
val result = mutableListOf<String>()
dataSource.connection.use { ds ->
ds.prepareStatement("select name from MyTable").use { ps ->
ps.executeQuery().use { rslt ->
while (rslt.next()) {
result.add(rslt.getString(1))
}
}
}
}
return result
}
given("a test with the Sql annotation") {
then("the data is inserted as expected") {
readAllNames(dataSource) shouldBe listOf("Aardvark", "Albatross")
}
}
})Phases
The default phase for the scripts to be executed is BEFORE_CLASS.
To run the scripts at a different phase, we can specify the phase attribute of the annotation.
Named Datasources
If you have multiple datasources configured, you can specify the datasource name to use for the SQL scripts.
@MicronautTest
@Sql(dataSourceName = "one", value = {"classpath:create.sql", "classpath:datasource_1_insert.sql"}) //
@Property(name = "datasources.one.dialect", value = "H2")
@Property(name = "datasources.one.driverClassName", value = "org.h2.Driver")
@Property(name = "datasources.one.schema-generate", value = "CREATE_DROP")
@Property(name = "datasources.one.url", value = "jdbc:h2:mem:databaseOne;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE")
@Property(name = "datasources.one.username", value = "sa")
@Sql(dataSourceName = "two", scripts = {"classpath:create.sql", "classpath:datasource_2_insert.sql"}) //
@Property(name = "datasources.two.dialect", value = "H2")
@Property(name = "datasources.two.driverClassName", value = "org.h2.Driver")
@Property(name = "datasources.two.schema-generate", value = "CREATE_DROP")
@Property(name = "datasources.two.url", value = "jdbc:h2:mem:databaseTwo;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE")
@Property(name = "datasources.two.username", value = "sa")
class SqlNamedDatasourceTest {
@Inject
@Named("one")
DataSource dataSource1;
@Inject
@Named("two")
DataSource dataSource2;
@Test
void dataIsInserted() throws Exception {
assertEquals(List.of("Aardvark", "Albatross"), readAllNames(dataSource1));
assertEquals(List.of("Bear", "Bumblebee"), readAllNames(dataSource2));
}
List<String> readAllNames(DataSource dataSource) throws SQLException {
var result = new ArrayList<String>();
try (
Connection ds = dataSource.getConnection();
PreparedStatement ps = ds.prepareStatement("select name from MyTable");
ResultSet rslt = ps.executeQuery()
) {
while(rslt.next()) {
result.add(rslt.getString(1));
}
}
return result;
}
}@MicronautTest
@Sql(dataSourceName = "one", value = ["classpath:create.sql", "classpath:datasource_1_insert.sql"]) //
@Property(name = "datasources.one.dialect", value = "H2")
@Property(name = "datasources.one.driverClassName", value = "org.h2.Driver")
@Property(name = "datasources.one.schema-generate", value = "CREATE_DROP")
@Property(name = "datasources.one.url", value = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE")
@Property(name = "datasources.one.username", value = "sa")
@Sql(dataSourceName = "two", value = ["classpath:create.sql", "classpath:datasource_2_insert.sql"]) //
@Property(name = "datasources.two.dialect", value = "H2")
@Property(name = "datasources.two.driverClassName", value = "org.h2.Driver")
@Property(name = "datasources.two.schema-generate", value = "CREATE_DROP")
@Property(name = "datasources.two.url", value = "jdbc:h2:mem:devDb2;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE")
@Property(name = "datasources.two.username", value = "sa")
class SqlNamedDatasourceSpec extends Specification {
@Inject
@Named("one")
DataSource dataSource1
@Inject
@Named("two")
DataSource dataSource2
def "data is inserted"() {
expect:
readAllNames(dataSource1) == ["Aardvark", "Albatross"]
and:
readAllNames(dataSource2) == ["Bear", "Bumblebee"]
}
List<String> readAllNames(DataSource dataSource) {
dataSource.getConnection().withCloseable {
it.prepareStatement("select name from MyTable").withCloseable {
it.executeQuery().withCloseable {
def names = []
while (it.next()) {
names << it.getString(1)
}
names
}
}
}
}
}R2DBC
For R2DBC, the Sql annotation can be used in the same way as for JDBC however it is required to pass the resourceType as ConnectionFactory.class.
@MicronautTest
@Property(name = "r2dbc.datasources.default.db-type", value = "mysql")
@Sql(value = {"classpath:create.sql", "classpath:datasource_1_insert.sql"}, resourceType = ConnectionFactory.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Testcontainers(disabledWithoutDocker = true)
class MySqlConnectionTest implements TestPropertyProvider {
@Override
@NonNull
public Map<String, String> getProperties() {
return MySQL.getProperties();
}
@Inject
ConnectionFactory connectionFactory;
@Test
void testSqlHasBeenInjected() {
var f = Flux.from(connectionFactory.create());
var result = f.flatMap(connection ->
connection.createStatement("SELECT name from MyTable where id = 2").execute()
).flatMap(rslt ->
rslt.map((row, metadata) -> row.get(0, String.class))
).blockFirst();
assertEquals("Albatross", result);
}
}Before OpenJDK 23, the Hotspot runtime has a performance issue relating to
interface type checks. When the JVM has to do a type check against an interface, such as in arrayList instanceof List,
it has to do a fairly complicated iteration of the whole inheritance tree of arrayList. To make this fast, if the
check succeeds, the interface is stored in a secondary_super_cache on the JVM Klass structure representing the object
type, in this case ArrayList. The next time the JVM has to do a similar type check of an ArrayList against List,
the JVM can just check this field instead and avoid walking the whole type tree.
The issue arises when the same concrete type (i.e. ArrayList) is checked against multiple interface types (e.g.
List but also Collection) repeatedly. An instanceof List will set the secondary_super_cache to List, then an
instanceof Collection will set the cache to Collection, and an instanceof List will set it back to List. Not
only can the second type check not take advantage of the cache because it had been overwritten, but crucially, each
type checks writes the cache field anew. If this happens concurrently on multi-core machines, this can lead to major
inter-core traffic for cache coherency of the cache line where the secondary_super_cache is located. This is called a
"scalability issue" because it can lead to poor performance in code running in parallel.
This bug can be triggered by the interplay of multiple usually independent libraries and is very hard to reproduce in
practice. For this reason, the folks at RedHat built a
type pollution agent that transforms the bytecode of all classes
of an application to specifically track these type checks and report any scenarios where the secondary_super_cache
may be switching back and forth between multiple types.
Testing for type pollution
micronaut-test-type-pollution is an adaptation of the RedHat type pollution agent that expands its covered type
checks somewhat, but also makes it easy to use from tests. The covered type checks are:
-
instanceof -
casts
-
reflective
Class.cast -
reflective
Class.isAssignableFrom -
reflective
Class.isInstance -
reflective
Method.invoke -
reflective
Constructor.newInstance -
reflective
Field.set
This is not an exhaustive list of all the code in the JDK that can trigger the JVM to do a type check, but this is meant to cover most type checks that happen in practice.
|
Note
|
Missing entries in this list are considered a bug, even though we know about them. That means this list may be expanded in a patch release of micronaut-test, causing your tests to fail if there were previously undiscovered type pollution sites. |
|
Warning
|
The type pollution test uses a Java agent to instrument all classes of the test suite. This does not play well with other agents, in particular jacoco. It is recommended to run type pollution tests in a separate module / source root with no other Java agents. |
When a type check changes the secondary_super_cache (or rather the agent’s model of it), what we call a "focus event",
it invokes a static FocusListener. A ThresholdFocusListener will
count these events and keep track of their exact stack trace. You then run the code that will be hot in production
repeatedly. At the end of the test case, you call the checkThresholds method to verify that there were not too many
focus events:
You can find the source code of this project in this repository: