Your test suite doesn't have to wait for production to meet Kotlin. If you're working on a Java Spring Boot project, you can start writing Kotlin tests today, against your existing Java code, without changing a single production class. Because Kotlin compiles to JVM bytecode, a Kotlin test can call any Java class directly, with no wrappers or adapters, which makes it a low-risk way to get familiar with the language.
Kotlin needs roughly 40% less code than equivalent Java, and test code, which is mostly setup and assertions, benefits from that. This roundup covers five widely adopted libraries, from JUnit to Kotlin-native additions like MockK and Kotest, and shows how each works with Kotlin. You can add any of them to an existing project without touching the others.
How these tools were chosen
Each tool here cleared three bars: being widely adopted and mature, offering first-class or excellent Kotlin support, and, together, the five cover the main testing layers, including unit, mocking, integration, assertions, and the full Spring context. You can add any one of them to an existing Java project without touching the others.
JUnit 6
JUnit 6 is the latest version of JUnit, the de facto standard testing framework for JVM applications. If you're on a Java project, you may already be using JUnit, making it a natural entry point for writing your first Kotlin test.
Kotlin's backtick function names noticeably improve test code readability. Take a Java test that looks like this:
@Test
void shouldReturnDiscountedPrice() {
OrderService service = new OrderService();
assertEquals(90.0, service.applyDiscount(100.0, 10), 0.001);
}
The Kotlin equivalent reads like this:
@Test
fun `should return discounted price`() {
val service = OrderService()
assertEquals(90.0, service.applyDiscount(100.0, 10), 0.001)
}
Backticks let you write full English sentences as test names, which appear directly in your test reports, with no camelCase decoding required.
Parameterized tests also get cleaner in Kotlin. Using @MethodSource with a companion object keeps your test data close to the test without the ceremony of a static utility class:
companion object {
@JvmStatic
fun discountScenarios() = listOf(
Arguments.of(100.0, 10, 90.0),
Arguments.of(200.0, 25, 150.0),
Arguments.of(50.0, 0, 50.0)
)
}
@ParameterizedTest
@MethodSource("discountScenarios")
fun `applies discount correctly`(
price: Double,
pct: Int,
expected: Double
) {
assertEquals(
expected,
OrderService().applyDiscount(price, pct),
0.001
)
}
JUnit's @Nested tests also work well with Kotlin, but nested test classes must be declared as inner class because JUnit requires nested test classes to be non-static:
@Nested
inner class WhenDiscountIsApplied {
// tests
}
Kotlin works with JUnit 6 through standard JVM interoperability, with two differences from Java. Kotlin classes are final by default, but JUnit 6 test classes don't need to be open. For @BeforeAll and @AfterAll, you can either expose the methods as static JVM methods using a companion object and @JvmStatic, or use JUnit's PER_CLASS test instance lifecycle to keep them as instance methods.
Testcontainers
Testcontainers lets you run integration tests against real dependencies such as PostgreSQL, Kafka, and Redis by starting disposable Docker containers during the test run. Instead of relying on an in-memory substitute or a service installed on the developer's machine, your tests can exercise the same type of infrastructure your application uses.
Kotlin's apply scope function makes container configuration concise. apply lets you call the container's methods directly inside the lambda, then returns the container itself, so you don't have to repeat the variable name:
val postgres =
PostgreSQLContainer(
DockerImageName.parse("postgres:16-alpine")
).apply {
withDatabaseName("testdb")
withUsername("testuser")
withPassword("testpass")
}
For JUnit integration, @Testcontainers and @Container manage the container lifecycle. A container declared as a static field is shared across the test methods in that class, while an instance container is started and stopped for each test method.
When using Kotlin, a shared container can be exposed as a JVM static field with @JvmStatic:
@Testcontainers
@SpringBootTest
class OrderRepositoryIntegrationTest {
companion object {
@Container
@JvmStatic
val postgres =
PostgreSQLContainer(
DockerImageName.parse("postgres:16-alpine")
).apply {
withDatabaseName("testdb")
withUsername("testuser")
withPassword("testpass")
}
@JvmStatic
@DynamicPropertySource
fun properties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url",
postgres::getJdbcUrl)
registry.add("spring.datasource.username",
postgres::getUsername)
registry.add("spring.datasource.password",
postgres::getPassword)
}
}
@Test
fun `persists order and retrieves by ID`() {
// test against a real PostgreSQL instance
}
}
@DynamicPropertySource registers dynamically determined values, such as a Testcontainers database URL and credentials, with the Spring ApplicationContext. In Kotlin, Spring's documentation uses @JvmStatic for both the container field and the dynamic-property method.
For reusable test infrastructure, Kotlin extension functions can keep configuration helpers close to the types they operate on:
fun PostgreSQLContainer<*>.jdbcUrl() = jdbcUrl
This can reduce repetition when several integration tests need similar container configuration.
Testcontainers also supports modules for common dependencies such as PostgreSQL, Redis, and Kafka. Combining Testcontainers with Spring's context caching can cause failures in later test classes, though. JUnit stops a container when its test class finishes, but Spring may keep the ApplicationContext for the next class, and that cached context can still point at the stopped container. If several test classes share a container, declare it as a Spring bean so its lifecycle follows the ApplicationContext instead of a single test class.
MockK
Mockito is widely used in Java projects and works with Kotlin, but Kotlin-specific language features can introduce some friction. Kotlin classes are final by default, while older Mockito setups often required additional configuration to mock final classes. Kotlin's when keyword also conflicts with Mockito's when() method, requiring escaped syntax such as Mockito.`when`(...). Mockito and the mockito-kotlin library support coroutine-based code, but MockK offers Kotlin-specific APIs that make stubbing and verification more idiomatic.
MockK was designed specifically for Kotlin. It can mock final classes without additional configuration and provides a Kotlin-oriented DSL for stubbing and verification:
val orderService = mockk<OrderService>()
every { orderService.findById(1L) } returns Order(id = 1L, total = 99.99)
val result = invoiceService.generateInvoice(1L)
verify { orderService.findById(1L) }
assertEquals(99.99, result.amount)
For suspending functions, MockK provides coEvery and coVerify, allowing coroutine calls to be stubbed and verified directly:
coEvery { orderService.findByIdAsync(1L) } returns
Order(id = 1L, total = 99.99)
runTest {
val result = invoiceService.generateInvoiceAsync(1L)
coVerify { orderService.findByIdAsync(1L) }
assertEquals(99.99, result.amount)
}
MockK also provides a JUnit 5/6 extension that works with @MockK annotations, avoiding the need to create each mock manually:
@ExtendWith(MockKExtension::class)
class InvoiceServiceTest {
@MockK
lateinit var orderService: OrderService
// tests
}
For mocks where you only care about a subset of interactions, MockK's relaxed = true option can provide default return values without requiring every call to be stubbed explicitly:
val orderService = mockk<OrderService>(relaxed = true)
For teams maintaining both Java and Kotlin tests, there's no need to migrate an existing Mockito-based test suite just to introduce MockK. Mockito and MockK can coexist in the same project, letting teams keep existing Java tests unchanged while using MockK for new Kotlin tests where its Kotlin-specific APIs are useful.
Kotest assertions
Kotest is a Kotlin testing ecosystem that provides a complete testing framework as well as standalone libraries for assertions, property-based testing, and other testing utilities. You can use Kotest's assertions and property testing in Kotlin tests while JUnit 6 remains the test framework, so Java teams can adopt Kotlin incrementally without changing their test runner. This section focuses on Kotest's assertion library and property-testing capabilities. Its test framework isn't covered in depth.
Kotest's assertion APIs use Kotlin's language features to make test expectations more readable:
@Test
fun `calculates order total`() {
val total = orderService.calculateTotal(
Order(items = listOf(Item(price = 49.99)))
)
total shouldBe 49.99
}
Instead of passing the actual and expected values to a traditional assertion method, shouldBe expresses the relationship directly. Kotest provides similar assertions for collections, exceptions, strings, nullable values, and other common testing scenarios.
These DSL-style assertions can make Kotlin tests more concise and expressive without requiring a team to replace its existing JUnit 6 test infrastructure.
Kotest also provides property-based testing capabilities. Rather than checking a function against only a few manually selected examples, you can describe a property that should hold across a range of generated inputs.
For example:
import io.kotest.matchers.collections.shouldContainAll
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.list
import io.kotest.property.checkAll
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class SortingPropertyTest {
@Test
fun `sorting preserves all elements`() = runTest {
checkAll(Arb.list(Arb.int())) { numbers ->
val sorted = numbers.sorted()
sorted shouldContainAll numbers
sorted.size shouldBe numbers.size
}
}
}
The test describes properties that should always hold, while Kotest generates multiple inputs to check those properties. This can help uncover edge cases that a small set of hand-written examples might not exercise.
Property-based testing is particularly useful when code has clear invariants, such as sorting, serialization, parsing, validation, or mathematical operations. Existing JUnit 6 tests can remain unchanged while new Kotlin tests use Kotest's assertion APIs. You can then add property-based testing selectively where generated inputs add value.
Spring Boot Test
For Spring Boot applications, spring-boot-starter-test provides the testing infrastructure needed to test Spring components and application behavior in a realistic application context. Kotlin works with the same Spring Boot testing infrastructure used by Java applications, while Kotlin-specific features can make some test code more concise.
Spring Boot Test is particularly useful when a test needs more than an isolated unit test, for example, when you want to verify how multiple Spring-managed components work together.
The most practical improvement is constructor injection in test classes. In Java, @SpringBootTest tests typically use @Autowired field injection, which means mutable fields and no compile-time guarantee that dependencies are actually available. In Kotlin, you can use @TestConstructor(autowireMode = AutowireMode.ALL) to wire dependencies through the primary constructor:
@ExtendWith(SpringExtension::class)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@SpringBootTest(webEnvironment =
SpringBootTest.WebEnvironment.RANDOM_PORT)
internal class OrderApiIntegrationTest(
val orderRepository: OrderRepository,
val testRestTemplate: TestRestTemplate
) {
@Test
fun `GET orders returns 200 with order list`() {
orderRepository.save(Order(total = 49.99))
val response = testRestTemplate.getForEntity("/orders",
List::class.java)
assertEquals(HttpStatus.OK, response.statusCode)
}
}
To set this globally for all test classes, add spring.test.constructor.autowire.mode=all to src/test/resources/junit-platform.properties. All your Kotlin test classes then get constructor injection automatically.
Kotlin works with all standard Spring test slice annotations, including @DataJpaTest, @WebMvcTest, and @AutoConfigureMockMvc. Spring Framework's Kotlin extensions add idiomatic API variants where the Java API feels verbose, particularly for MockMvc and WebTestClient.
For integration tests in mixed Java/Kotlin projects, a useful pattern is a Kotlin base class that declares the @Testcontainers setup and @DynamicPropertySource, combined with Spring Boot Test's context-caching behavior. Both JUnit Java tests and Kotlin tests can extend the same base class, reusing the same application context and container instance across the test run.
JetBrains and Spring formalized a strategic partnership in May 2025 to improve the Kotlin developer experience in Spring applications. The collaboration covers full null-safety integration for Kotlin in Spring, official Spring learning materials in Kotlin, and enhanced tooling in IntelliJ IDEA. The same announcement reports that 27% of Spring developers use Kotlin.
What to do next
These five tools cover the full testing stack, from unit mocks to full-context integration tests, and you can adopt each one at a time without touching your production code. A practical starting point is adding Kotlin to your test source set, writing your next new test in Kotlin using JUnit 6, and pulling in MockK when you hit the first case where Mockito's final-class behavior gets in the way.
Kotlin's concise syntax, null safety, extension functions, and DSLs carry over from test code to production code, which is why the test suite is a natural on-ramp. Once your team has written a few dozen Kotlin tests and developed an intuition for the idioms, expanding Kotlin into production services becomes a lower-risk conversation.
kotlinlang.org has the language reference, coding conventions, and getting-started guides for JVM and Spring Boot development. The official JUnit testing tutorial for Kotlin covers the Gradle setup steps in detail.
Top comments (0)