JUnit5
Module provides an extension for JUnit 5 that allows testing an application through the same component graph that is used at runtime.
The Kora extension for JUnit 5 is intended for component and integration testing of the source code that will later run in the real application.
The test uses the dependency container of the main application: it can be limited to the required components,
extended with test components, or have individual parts replaced with mocks.
Module allows you to conduct:
Component tests- testing of a single component.Inter-component tests- testing of several components and their interaction with each other.Integration tests- testing of components and interaction with external systems.
It is recommended to additionally test the service artifact packaged in the final image, as a black box using the Testcontainers library.
For a step-by-step walkthrough before the reference details, see Component Testing, Integration Testing and Black-Box Testing.
Dependency¶
Dependency build.gradle:
Setup JUnit platform build.gradle:
Dependency build.gradle.kts:
Setup JUnit platform build.gradle.kts:
Usage¶
Examples will be shown relative to such an application:
Test¶
To enable the Kora extension, annotate the test class with @KoraAppTest.
The annotation connects the JUnit 5 extension, finds the generated graph of the specified @KoraApp application, and prepares the dependency container for the test.
Parameters of the @KoraAppTest annotation:
value- class annotated with@KoraAppwhose component graph will be used in the test (required, no default).components- additional component classes that should be included in the test graph in addition to components discovered through@TestComponent(default:{}).modules- additional modules with component factory methods that should be connected to the test graph (default:{}).
Only module interfaces can be specified in modules. If the whole graph needs to be tested, inject KoraAppGraph or do not limit the graph to individual @TestComponent components.
Component¶
To inject and select components for testing, use the @TestComponent annotation.
It allows injecting components into test method arguments, the constructor, and/or test class fields, and limits the dependency container to those components.
All components listed in the test fields and/or method/constructor arguments annotated @TestComponent will be injected as dependencies within the test.
The test dependency container will be limited to those components and their dependencies.
It is important that components within the test must be used by at least one @Root component that is also specified within the test.
An example of a test where components are injected in fields:
Example of a test where components are injected in a constructor:
Example of a test where components are injected in method arguments:
Injection Rules¶
Components can be injected in three ways: into a test class field, into the constructor, or into a test method parameter. The chosen form affects when the Kora extension can access the test class instance and which additional mechanisms are available.
- Fields suit most tests and are compatible with
KoraAppTestConfigModifier,KoraAppTestGraphModifier,PER_METHOD, andPER_CLASS. - Constructor injection is convenient for immutable fields, but is incompatible with
KoraAppTestConfigModifierandKoraAppTestGraphModifier, because the extension needs a test class instance to callconfig()orgraph(), while that instance is still being created during constructor injection. - Method parameters are convenient for dependencies local to a specific test; with
PER_METHOD, the graph includes parameters of the current method, while withPER_CLASS, the extension collects@TestComponentparameters from all methods of the class in advance. - If constructor injection is used,
@TestComponent,@Mock,@Spy,@MockK, or@SpyKcannot also be injected into test method parameters. - In
PER_CLASSmode,@Mock/@MockKcannot be injected into test method parameters because method-level mocks live shorter than the shared test class graph. - The same element cannot be declared as a regular
@TestComponent, mock, and spy at the same time: the extension will fail the test with a configuration error.
If the test needs KoraAppTestConfigModifier or KoraAppTestGraphModifier, use field injection or method parameters.
If constructor injection is required, it is better to move configuration and graph modification into a separate test @KoraApp or connected module.
Tag¶
In order to inject a dependency/mock that has an @Tag, you must specify the appropriate @Tag annotation next to the argument for injection:
Application Graph¶
If a test needs direct access to the prepared graph, inject KoraAppGraph into a field, constructor, or test method argument.
It can retrieve one or several components by type and can also account for @Tag.
Main KoraAppGraph methods:
getFirst(Type type)/getFirst(Class<T> type)- return the first found component ornull.getFirst(Type type, Class<?>... tags)/getFirst(Class<T> type, Class<?>... tags)- return the first component with the specified tags ornull.findFirst(...)- returnsOptional<T>instead ofnull.getAll(...)- returns all components of the specified type, optionally accounting for tags.
KoraAppGraph cannot be used as a target for @Mock, @Spy, @MockK, or @SpyK, because it is a service object of the test extension, not an application component.
Mock¶
It is proposed to use annotations provided by the Mockito library together with the @TestComponent annotation to create component mock in Java as part of a test.
It is required to add the Mockito library as a build.gradle dependency:
Important, it is assumed that MockitoExtension will not be used and will be disabled, you can't combine it together with @KoraAppTest.
@Mock and @Spy annotations and all parameters of these annotations are supported. It is recommended to read more about how these annotations work in the official Mockito library documentation.
The @Mock annotation allows you to make a class stub of a
annotated component and control the behavior of its methods with Mockito or the methods will return default values: void, default values for primitives, empty collections and null for all other objects.
The stub component will be injected as a dependency into the arguments and/or fields of the test class and into all components that required it as a dependency. All dependent components that are not required anywhere else within the test will be excluded as unnecessary.
Example of a test using a @Mock component and injecting a mock in a field:
@KoraAppTest(Application.class)
class SomeTests {
@Mock
@TestComponent
private Supplier<String> component1;
@BeforeEach
void mock() {
Mockito.when(component1.get()).thenReturn("?");
}
@Test
void example() {
assertEquals("?", component1.get());
}
}
@Spy annotation allows you to make a spy facade of a class implementation of a of a component from a dependency container that will have the original behavior of the component's methods by default, but as with stubs, their behavior can be overridden.
The spy component will be implemented as a dependency in the arguments and/or fields of the test class and in all components that required it as a dependency.
Example of a test using @Spy component and injecting the spy in a method argument:
@KoraAppTest(Application.class)
class SomeTests {
@Test
void example(@Spy @TestComponent Supplier<String> component1) {
Mockito.when(component1.get()).thenReturn("?");
assertEquals("?", component1.get());
}
}
You can also make a spy from the value of a test class field.
The spy component will be injected as a dependency in the arguments and/or fields of the test class and in all components that required it as a dependency. All dependent components that are not required anywhere else within the test will be excluded as unnecessary.
Example of a test using @Spy spy component:
In order to mock components in Kotlin, it is suggested to use the annotations provided by the MockK library together with the @TestComponent annotation.
The MockK library is required to be attached as a build.gradle.kts dependency:
Important, it is assumed that MockkExtension will not be used and will be disabled, you can't combine it together with @KoraAppTest.
@MockK and @SpyK annotations and all parameters of these annotations are supported.
It is also possible to use Mockito if desired. For a more detailed description of how Kora and Mockito work, you should read the Java tab of this paragraph. In order to improve the interaction between Mockito and Kotlin you can use the Mockito Kotlin library.
Important, it is assumed that MockitoExtension will not be used and will be disabled, you can't combine it together with @KoraAppTest.
@MockK annotation allows you to make a class mock
annotated component and control the behavior of its methods using MockK.
Mock component will be injected as a dependency into the arguments and/or fields of the test class and into all components that required it as a dependency. All dependent components that are not required anywhere else within the test will be excluded as unnecessary.
Example of a test using @MockK component and injecting a mock:
@KoraAppTest(Application::class)
class SomeTests(@MockK @TestComponent val component1: Supplier<String>) {
@BeforeEach
fun mock() {
every { component1.get() } returns "?"
}
@Test
fun example() {
assertEquals("?", component1.get())
}
}
@SpyK annotation allows you to make a spy facade of a class implementation of a of a component from a dependency container that will have the original behavior of the component's methods by default, but as with stubs, their behavior can be overridden.
The spy component will be implemented as a dependency in the arguments and/or fields of the test class and in all components that required it as a dependency.
Example of a test using @SpyK component and embedding the spy in a method argument:
@KoraAppTest(Application::class)
class SomeTests {
@Test
fun example(@SpyK @TestComponent component1: Supplier<String>) {
every { component1.get() } returns "?"
assertEquals("?", component1.get())
}
}
You can also make a spy from the value of a test class field.
The spy component will be implemented as a dependency in the arguments and/or fields of the test class and in all components that required it as a dependency. All dependent components that are not required anywhere else within the test will be excluded as unnecessary.
An example of a test using the @SpyK spy component:
Mock strictness¶
Mockito mocks can be checked with the @MockitoStrictness annotation.
It sets the verification level for Mockito mocks created by the Kora extension within the test class.
The extension behaves similarly to MockitoSession: after the test completes, it passes the created mocks to Mockito verification and reports unused or suspicious stubbing.
If @MockitoStrictness is not specified, Kora uses Strictness.WARN: the test does not fail, but warnings are written to the log.
Supported levels:
Strictness.WARN- default value; writes warnings to the log and does not fail the test.Strictness.STRICT_STUBS- strict mode; unused stubbing fails the test, for example withUnnecessaryStubbingException.Strictness.LENIENT- lenient mode; disables unused stubbing checks.
If a specific @Mock has its own strictness parameter, it applies to that mock's settings.
@MockitoStrictness is convenient as a common level for the whole test class, so the setting does not need to be duplicated on every mock.
In the example above, Mockito.when(component1.get()).thenReturn("?") must be used by the test.
If the component1.get() call is removed from the test method, Strictness.STRICT_STUBS will fail the test.
For Kotlin with Mockito Kotlin, the same mechanism applies because verification is performed by Mockito.
@MockitoStrictness does not apply to MockK mocks.
Test graph¶
Sometimes you may need to use an extended dependency container as part of your tests. For example, a test application can extend the main application and add components that are only needed in tests.
This approach is useful when you have different Read API and Write API applications with common components, which may be required as part of testing one and the other. Or, you may need some save/delete/update functions just for testing as a quick test utility.
Recommendation
Highly Recommend Testing applications as a black box and rely on this approach as the primary source of truth and correctness of the application.
Application may work differently depending on the JVM flags, base image and native libraries, differences between partial and full configurations, differences in conversion at application entry points, use of schema registries, and so on. Only a prod-ready image can guarantee the closest possible testing environment.
Let's imagine that the application looks like this:
In tests, you can create a separate test @KoraApp that extends the main application and use that graph.
For this scenario, the generated submodule of the main application is required: without it, the test application cannot inherit and connect the main graph components.
First, enable the parameter that creates a submodule of the main application in build.gradle:
Then it is required to create an extended test graph of the application in test source's directory.
Remember to label components as @Root since they are most likely not used by anyone,
but the tests and will not otherwise be included in the graph:
In order for the test application graph to be generated, we need to add processors as test dependencies in build.gradle:
It may be required to exclude scanning of Kora generated classes by JUnit (sometimes an error occurs during test search):
You can now use the extended application graph in your tests:
If inheritance from the main @KoraApp is not needed and only factory methods from a separate module should be added,
use the modules parameter of @KoraAppTest.
modules accepts module interfaces, not component classes:
Summary:
kora.app.submodule.enabled=trueis needed when a test@KoraAppextends the main@KoraApp.@KoraAppTest(modules = ...)suits cases where additional modules simply need to be connected to the test graph.- Components that should appear in the limited test graph must still be reachable from
@TestComponent,components, orKoraAppGraph.
Test configuration¶
By default, the basic configuration will be used, as in the case of running a real application.
To change or add configuration within tests, the test class should implement KoraAppTestConfigModifier,
and the config() method should return a KoraConfigModification.
KoraAppTestConfigModifier cannot be used together with component injection into the test class constructor:
the extension needs to obtain the configuration modification before creating the test graph, and for that the test instance must already exist.
Environment variables¶
In case the test needs to use the default configuration that would be used when the application is running,
and you only need to substitute environment variables, you can use the SystemProperty mechanism in KoraConfigModification:
Suppose there is such a configuration application.conf:
In order to use such a config and pass only environment variables, you need to return such KoraConfigModification:
@KoraAppTest(Application.class)
class SomeTests implements KoraAppTestConfigModifier {
@NotNull
@Override
public KoraConfigModification config() {
return KoraConfigModification
.ofSystemProperty("POSTGRES_JDBC_URL", "jdbc:postgresql://localhost:5432/postgres")
.withSystemProperty("POSTGRES_USER", "postgres")
.withSystemProperty("POSTGRES_PASS", "postgres");
}
}
@KoraAppTest(Application::class)
class SomeTests : KoraAppTestConfigModifier {
override fun config(): KoraConfigModification {
return KoraConfigModification
.ofSystemProperty("POSTGRES_JDBC_URL", "jdbc:postgresql://localhost:5432/postgres")
.withSystemProperty("POSTGRES_USER", "postgres")
.withSystemProperty("POSTGRES_PASS", "postgres")
}
}
If several values need to be passed at once, use withSystemProperties(Map<String, String>):
@KoraAppTest(Application.class)
class SomeTests implements KoraAppTestConfigModifier {
@NotNull
@Override
public KoraConfigModification config() {
return KoraConfigModification
.ofSystemProperty("POSTGRES_JDBC_URL", "jdbc:postgresql://localhost:5432/postgres")
.withSystemProperties(Map.of(
"POSTGRES_USER", "postgres",
"POSTGRES_PASS", "postgres"
));
}
}
@KoraAppTest(Application::class)
class SomeTests : KoraAppTestConfigModifier {
override fun config(): KoraConfigModification {
return KoraConfigModification
.ofSystemProperty("POSTGRES_JDBC_URL", "jdbc:postgresql://localhost:5432/postgres")
.withSystemProperties(
mapOf(
"POSTGRES_USER" to "postgres",
"POSTGRES_PASS" to "postgres"
)
)
}
}
Configuration file¶
An example of providing a configuration as a file:
Configuration text¶
An example of adding a configuration as a string would look like this, in this case only this configuration will be used without any configuration files:
Configuration substitution¶
The environment substitution shown in Environment variables also works with an inline configuration:
declare ${ENV} placeholders directly inside the ofString(...) configuration and resolve them with chained withSystemProperty(...).
This is convenient when the whole configuration is described in the test, but some values (ports, hosts, credentials) are only known at runtime:
@KoraAppTest(Application.class)
class SomeTests implements KoraAppTestConfigModifier {
@Override
public @Nonnull KoraConfigModification config() {
return KoraConfigModification.ofString("""
myconfig {
myinnerconfig {
first = ${ENV_FIRST}
second = ${ENV_SECOND}
}
}
""")
.withSystemProperty("ENV_FIRST", "1")
.withSystemProperty("ENV_SECOND", "2");
}
}
@KoraAppTest(Application::class)
class SomeTests : KoraAppTestConfigModifier {
override fun config(): KoraConfigModification {
return KoraConfigModification.ofString(
"""
myconfig {
myinnerconfig {
first = \${ENV_FIRST}
second = \${ENV_SECOND}
}
}
""".trimIndent()
)
.withSystemProperty("ENV_FIRST", "1")
.withSystemProperty("ENV_SECOND", "2")
}
}
Testcontainers¶
A common use of KoraAppTestConfigModifier is Testcontainers integration:
the test starts a container and passes its runtime connection values into the configuration through config().
Testcontainers assigns a random host port on each run, so the values must not be hardcoded — they are declared as ${...} placeholders in the inline configuration
and populated from the container getters via withSystemProperty(...).
Because config() runs before the test graph is built, the configuration is ready before any component is created.
For the same reason KoraAppTestConfigModifier is incompatible with constructor injection: use field or method-parameter injection as shown below.
Add the Testcontainers dependencies in build.gradle:
testImplementation "org.testcontainers:junit-jupiter:1.21.4"
testImplementation "org.testcontainers:postgresql:1.21.4"
@Testcontainers
@KoraAppTest(Application.class)
class SomeIntegrationTests implements KoraAppTestConfigModifier {
@Container
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16");
@TestComponent
private SomeService service;
@NotNull
@Override
public KoraConfigModification config() {
return KoraConfigModification.ofString("""
db {
jdbcUrl = ${POSTGRES_JDBC_URL}
username = ${POSTGRES_USER}
password = ${POSTGRES_PASS}
poolName = "kora"
}
""")
.withSystemProperty("POSTGRES_JDBC_URL", POSTGRES.getJdbcUrl())
.withSystemProperty("POSTGRES_USER", POSTGRES.getUsername())
.withSystemProperty("POSTGRES_PASS", POSTGRES.getPassword());
}
@Test
void example() {
// interact with the service backed by the container
}
}
Add the Testcontainers dependencies in build.gradle.kts:
testImplementation("org.testcontainers:junit-jupiter:1.21.4")
testImplementation("org.testcontainers:postgresql:1.21.4")
@Testcontainers
@KoraAppTest(Application::class)
class SomeIntegrationTests : KoraAppTestConfigModifier {
companion object {
@Container
@JvmStatic
val POSTGRES = PostgreSQLContainer("postgres:16")
}
@TestComponent
lateinit var service: SomeService
override fun config(): KoraConfigModification {
return KoraConfigModification.ofString(
"""
db {
jdbcUrl = \${POSTGRES_JDBC_URL}
username = \${POSTGRES_USER}
password = \${POSTGRES_PASS}
poolName = "kora"
}
""".trimIndent()
)
.withSystemProperty("POSTGRES_JDBC_URL", POSTGRES.jdbcUrl)
.withSystemProperty("POSTGRES_USER", POSTGRES.username)
.withSystemProperty("POSTGRES_PASS", POSTGRES.password)
}
@Test
fun example() {
// interact with the service backed by the container
}
}
For a full walkthrough — dependencies, a test @KoraApp, migrations and repository setup — see the Integration Testing guide.
Container modification¶
To add, replace, or programmatically create mocks in the application container without annotations, implement KoraAppTestGraphModifier
and return a KoraGraphModification from the graph() method.
KoraAppTestGraphModifier cannot be used together with component injection into the test class constructor:
the extension needs to obtain the graph modification before creating the graph and injecting components.
KoraGraphModification supports these operations:
addComponent(...)- adds a new component to the test graph.replaceComponent(...)- replaces an existing component, while its dependencies remain in the graph.mockComponent(...)- replaces an existing component with a mock and removes the replaced component's real dependencies from the graph if they are no longer needed by the test.
addComponent(...) and replaceComponent(...) have overloads with Function<KoraAppGraph, T> if the new component should be built from already initialized graph components.
For components with @Tag, use overloads with List<Class<?>> tags.
Adding¶
An example of adding a component to a graph:
@KoraAppTest(value = Application.class)
class SomeTests implements KoraAppTestGraphModifier {
@Override
public @Nonnull KoraGraphModification graph() {
return KoraGraphModification.create()
.addComponent(TypeRef.of(Supplier.class, Integer.class), () -> (Supplier<Integer>) () -> 1);
}
@Test
void example(@TestComponent Supplier<Integer> supplier) {
assertEquals(1, supplier.get());
}
}
@KoraAppTest(value = Application::class)
class SomeTests : KoraAppTestGraphModifier {
override fun graph(): KoraGraphModification {
return KoraGraphModification.create()
.addComponent(TypeRef.of(Supplier::class.java, Int::class.java), Supplier { Supplier { 1 } })
}
@Test
fun example(@TestComponent supplier: Supplier<Int>) {
assertEquals(1, supplier.get())
}
}
In case it is required to add components using a real component from the graph, this is also available through another method signature:
@KoraAppTest(value = Application.class)
class SomeTests implements KoraAppTestGraphModifier {
@Override
public @Nonnull KoraGraphModification graph() {
return KoraGraphModification.create()
.addComponent(TypeRef.of(Supplier.class, String.class),
(graph) -> {
final Supplier<Integer> existingComponent = (Supplier<Integer>) graph.getFirst(TypeRef.of(Supplier.class, Integer.class));
return (Supplier<String>) () -> "1" + existingComponent.get();
});
}
@Test
void example(@TestComponent Supplier<String> supplier) {
assertEquals(1, supplier.get());
}
}
@KoraAppTest(value = Application::class)
class SomeTests : KoraAppTestGraphModifier {
@Nonnull
override fun graph(): KoraGraphModification {
return KoraGraphModification.create()
.addComponent(TypeRef.of(Supplier::class.java, String::class.java))
{ graph ->
val existingComponent = graph.getFirst(TypeRef.of(Supplier::class.java, Int::class.java))
as Supplier<Int>
Supplier { "1" + existingComponent.get() }
}
}
@Test
fun example(@TestComponent supplier: Supplier<String>) {
assertEquals(1, supplier.get())
}
}
Replacement¶
An example of replacing a component in a dependency container, this mechanism can also be used to create custom mocks:
@KoraAppTest(value = Application.class)
class SomeTests implements KoraAppTestGraphModifier {
@Override
public @Nonnull KoraGraphModification graph() {
return KoraGraphModification.create()
.replaceComponent(TypeRef.of(Supplier.class, String.class), List.of(Supplier.class), () -> (Supplier<String>) () -> "?");
}
@Test
void example(@Tag(Supplier.class) @TestComponent Supplier<String> supplier) {
assertEquals("?", supplier.get());
}
}
@KoraAppTest(value = Application::class)
class SomeTests : KoraAppTestGraphModifier {
override fun graph(): KoraGraphModification {
return KoraGraphModification.create()
.replaceComponent(TypeRef.of(Supplier::class.java, String::class.java), listOf(Supplier::class.java), Supplier { Supplier { "?" } })
}
@Test
fun example(@Tag(Supplier::class) @TestComponent supplier: Supplier<String>) {
assertEquals("?", supplier.get())
}
}
In case it is required to replace components using a real component from the graph, this is also available through another method signature:
@KoraAppTest(value = Application.class)
class SomeTests implements KoraAppTestGraphModifier {
@Override
public @Nonnull KoraGraphModification graph() {
return KoraGraphModification.create()
.replaceComponent(TypeRef.of(Supplier.class, Integer.class),
(graph) -> {
final Supplier<Integer> existingComponent = (Supplier<Integer>) graph.getFirst(TypeRef.of(Supplier.class, Integer.class));
return (Supplier<Integer>) () -> 1 + existingComponent.get();
});
}
@Test
void example(@TestComponent Supplier<Integer> supplier) {
assertEquals(1, supplier.get());
}
}
@KoraAppTest(value = Application::class)
class SomeTests : KoraAppTestGraphModifier {
@Nonnull
override fun graph(): KoraGraphModification {
return KoraGraphModification.create()
.replaceComponent(TypeRef.of(Supplier::class.java, Int::class.java))
{ graph ->
val existingComponent = graph.getFirst(TypeRef.of(Supplier::class.java, Int::class.java))
as Supplier<Int>
Supplier { 1 + existingComponent.get() }
}
}
@Test
fun example(@TestComponent supplier: Supplier<Int>) {
assertEquals(1, supplier.get())
}
}
Programmatic Mock¶
If a component should be replaced specifically as a mock, use mockComponent(...).
Unlike replaceComponent(...), this method tells the extension that the real dependencies of the replaced component are not needed and can be excluded from the test graph.
@KoraAppTest(value = Application.class)
class SomeTests implements KoraAppTestGraphModifier {
@Override
public @Nonnull KoraGraphModification graph() {
return KoraGraphModification.create()
.mockComponent(TypeRef.of(Supplier.class, String.class), () -> Mockito.mock(Supplier.class));
}
@Test
void example(@TestComponent Supplier<String> supplier) {
Mockito.when(supplier.get()).thenReturn("?");
assertEquals("?", supplier.get());
}
}
@KoraAppTest(value = Application::class)
class SomeTests : KoraAppTestGraphModifier {
override fun graph(): KoraGraphModification {
return KoraGraphModification.create()
.mockComponent(TypeRef.of(Supplier::class.java, String::class.java), Supplier { mockk<Supplier<String>>() })
}
@Test
fun example(@TestComponent supplier: Supplier<String>) {
every { supplier.get() } returns "?"
assertEquals("?", supplier.get())
}
}
Initialization¶
By default, JUnit 5 uses TestInstance.Lifecycle.PER_METHOD, so Kora creates and cleans up the test graph for each test method.
If the container should be initialized once for the whole test class, annotate the test class with @TestInstance(TestInstance.Lifecycle.PER_CLASS):
With PER_CLASS, one graph instance is used by all test methods in the class, and cleanup runs after the whole class completes.
This speeds up heavy integration tests, but mutable component and mock state should be handled more carefully.
Lifecycle restrictions:
- When components are injected into the constructor,
@TestComponentor mocks cannot also be injected into test method parameters. - When components are injected into the constructor,
KoraAppTestConfigModifierandKoraAppTestGraphModifiercannot be used. - In
PER_CLASSmode,@Mock/@MockKcannot be injected into test method parameters; use fields or the constructor. - For
@Nestedclasses, field injection into the inner class cannot be used if the outer test class runs inPER_CLASSmode; use method parameters or a separate lifecycle for the nested class.