Skip to content

Testing

Test JVM applications with JUnit 5, MockK, Kotest, and Testcontainers — mapped from Jest, testify, and go test.

Testing on the JVM is mature, powerful, and surprisingly different from what you’re used to in TypeScript or Go. This module covers the entire testing stack: JUnit 5, MockK, Kotest, Testcontainers, and framework-specific testing for Spring Boot and Ktor.

The JVM testing world is layered: a platform (JUnit Platform) runs engines (JUnit Jupiter, Kotest), and libraries (MockK, Testcontainers) plug into them. Here’s how the pieces map to what you already know:

Concept TypeScript Go Kotlin/JVM
Test runner Jest / Vitest go test JUnit 5 (JUnit Platform)
Assertions expect(x).toBe(y) if x != y { t.Errorf(...) } assertEquals(y, x) / Kotest matchers
Mocking jest.mock() / vi.mock() interfaces + manual / testify MockK
BDD style describe/it blocks subtests t.Run() Kotest BehaviorSpec / DescribeSpec
Property testing fast-check rapid / gopter Kotest property testing
Integration (Docker) testcontainers-node testcontainers-go Testcontainers Java/Kotlin
Coverage --coverage flag go test -cover JaCoCo
Framework testing supertest httptest @SpringBootTest / Ktor testApplication

Every Kotlin project using tests needs these in build.gradle.kts:

build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
}
dependencies {
// JUnit 5
testImplementation(platform("org.junit:junit-bom:5.11.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
// MockK
testImplementation("io.mockk:mockk:1.13.13")
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
testImplementation("io.kotest:kotest-property:5.9.1")
// Testcontainers
testImplementation(platform("org.testcontainers:testcontainers-bom:1.20.4"))
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
// Coroutine testing
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}
tasks.test {
useJUnitPlatform()
}

JUnit 5 is the default test engine on the JVM — the go test / Jest of this world. Here’s the same two tests (a passing assertion and an expected exception) written in each ecosystem:

describe('Calculator', () => {
test('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('throws on division by zero', () => {
expect(() => divide(1, 0)).toThrow('Division by zero');
});
});

Key differences:

  • Kotlin test method names can use backticks with spaces: `adds two numbers`().
  • No describe blocks in JUnit 5 — use @Nested classes instead (shown below).
  • Assertions are static imports: assertEquals(expected, actual) — note the order.
  • assertThrows<T> is a Kotlin-specific JUnit 5 extension that uses reified generics.

The class under test:

class Calculator {
fun add(a: Int, b: Int): Int = a + b
fun divide(a: Int, b: Int): Int {
if (b == 0) throw ArithmeticException("Division by zero")
return a / b
}
}

With backtick method names you rarely need @DisplayName, but it exists for when you want richer descriptions or non-ASCII characters:

import org.junit.jupiter.api.DisplayName
@DisplayName("Calculator")
class CalculatorTest {
@Test
@DisplayName("should add two positive numbers")
fun addPositive() {
assertEquals(5, Calculator().add(2, 3))
}
@Test
@DisplayName("should handle negative numbers")
fun addNegative() {
assertEquals(-1, Calculator().add(2, -3))
}
}

JUnit ships its own assertions, but you can also use Kotest’s matchers inside plain JUnit tests for a more readable, Jest-like style.

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
class AssertionExamplesTest {
@Test
fun `basic assertions`() {
// Equality
assertEquals(4, 2 + 2)
assertEquals(3.14, Math.PI, 0.01) // delta for doubles
// Boolean
assertTrue(listOf(1, 2, 3).contains(2))
assertFalse("hello".isEmpty())
// Null
val result: String? = findUser("alice")
assertNotNull(result)
assertNull(findUser("nobody"))
// Same reference
val list1 = listOf(1, 2)
val list2 = list1
assertSame(list1, list2)
}
@Test
fun `grouped assertions -- all run even if some fail`() {
val user = User("Alice", 30, "alice@example.com")
assertAll(
"user properties",
{ assertEquals("Alice", user.name) },
{ assertEquals(30, user.age) },
{ assertTrue(user.email.contains("@")) }
)
}
@Test
fun `exception assertions`() {
val exception = assertThrows<IllegalArgumentException> {
validateAge(-1)
}
assertTrue(exception.message!!.contains("negative"))
}
@Test
fun `timeout assertion`() {
assertTimeout(java.time.Duration.ofSeconds(2)) {
Thread.sleep(100) // must complete within 2 seconds
}
}
private fun findUser(name: String): String? =
if (name == "alice") "Alice" else null
private fun validateAge(age: Int) {
require(age >= 0) { "Age cannot be negative: $age" }
}
data class User(val name: String, val age: Int, val email: String)
}

assertAll is worth highlighting: it runs every assertion even if some fail, then reports all failures together — handy for checking several properties of one object.

Kotest provides matchers that read like natural language — similar to Jest’s expect API. You can use them inside JUnit 5 tests:

import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldStartWith
import io.kotest.matchers.collections.shouldContainAll
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.types.shouldBeInstanceOf
import org.junit.jupiter.api.Test
class KotestMatchersInJUnitTest {
@Test
fun `string matchers`() {
val greeting = "Hello, World!"
greeting shouldContain "World"
greeting shouldStartWith "Hello"
greeting.length shouldBe 13
}
@Test
fun `collection matchers`() {
val numbers = listOf(1, 2, 3, 4, 5)
numbers shouldHaveSize 5
numbers shouldContainAll listOf(2, 4)
}
@Test
fun `null matchers`() {
val present: String? = "hello"
val absent: String? = null
present.shouldNotBeNull()
absent.shouldBeNull()
}
@Test
fun `type matchers`() {
val result: Any = "hello"
result.shouldBeInstanceOf<String>()
}
}

Jest expect vs Kotest shouldBe:

Jest / Vitest Kotest
expect(x).toBe(5) x shouldBe 5
expect(x).toContain("a") x shouldContain "a"
expect(x).toHaveLength(3) x shouldHaveSize 3
expect(x).toBeNull() x.shouldBeNull()
expect(x).toBeInstanceOf(Foo) x.shouldBeInstanceOf<Foo>()
expect(x).toThrow() shouldThrow<Exception> { ... }

Every framework has setup/teardown hooks. Here’s the lifecycle in each ecosystem:

describe('UserService', () => {
let db: Database;
beforeAll(async () => { db = await connectDb(); });
afterAll(async () => { await db.close(); });
beforeEach(() => { db.clear(); });
afterEach(() => { /* cleanup */ });
test('creates user', () => { /* ... */ });
});

Key points:

  • @BeforeAll / @AfterAll must be on companion object methods with @JvmStatic.
  • Or use @TestInstance(TestInstance.Lifecycle.PER_CLASS) to avoid companion objects.
  • By default, JUnit 5 creates a new test class instance per test method (like Go).

The per-class lifecycle is simpler — no companion object needed:

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserServiceTest {
private lateinit var db: Database
@BeforeAll
fun setupAll() {
db = Database.connect() // no companion object needed
}
@AfterAll
fun teardownAll() {
db.close()
}
@Test
fun `creates a user`() { /* ... */ }
}

This is the JUnit 5 equivalent of Jest’s nested describe — an inner class annotated with @Nested:

import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.DisplayName
@DisplayName("UserService")
class UserServiceTest {
private val service = UserService()
@Nested
@DisplayName("when creating users")
inner class CreateUser {
@Test
fun `saves valid user`() {
val user = service.create("alice", "alice@test.com")
assertNotNull(user.id)
}
@Test
fun `rejects duplicate email`() {
service.create("alice", "alice@test.com")
assertThrows<DuplicateEmailException> {
service.create("bob", "alice@test.com")
}
}
}
@Nested
@DisplayName("when finding users")
inner class FindUser {
@Test
fun `finds existing user by email`() {
service.create("alice", "alice@test.com")
val found = service.findByEmail("alice@test.com")
assertNotNull(found)
}
@Test
fun `returns null for unknown email`() {
assertNull(service.findByEmail("nobody@test.com"))
}
}
}
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.condition.*
@Test
@Disabled("Waiting for API v2 -- JIRA-1234")
fun `handles rate limiting`() { /* ... */ }
@Test
@EnabledOnOs(OS.LINUX)
fun `uses epoll on Linux`() { /* ... */ }
@Test
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
fun `only runs in CI`() { /* ... */ }
@Test
@EnabledIfSystemProperty(named = "java.version", matches = "21.*")
fun `needs JDK 21`() { /* ... */ }
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.TestMethodOrder
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
class OrderedTest {
@Test @Order(1)
fun `step 1 -- create resource`() { /* ... */ }
@Test @Order(2)
fun `step 2 -- modify resource`() { /* ... */ }
@Test @Order(3)
fun `step 3 -- delete resource`() { /* ... */ }
}

Parameterized tests let you run the same test logic with multiple inputs — like Jest’s test.each or Go’s table-driven tests.

test.each([
[1, 1, 2],
[2, 3, 5],
[0, 0, 0],
])('add(%i, %i) = %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});

This is the closest to Go’s table-driven test pattern — a data class per case:

data class TestCase(
val name: String,
val input: String,
val expected: String
)
class SlugifyTest {
@ParameterizedTest(name = "{0}")
@MethodSource("testCases")
fun `converts string to slug`(case: TestCase) {
assertEquals(case.expected, slugify(case.input))
}
companion object {
@JvmStatic
fun testCases() = listOf(
TestCase("simple words", "Hello World", "hello-world"),
TestCase("special chars", "Hello, World!", "hello-world"),
TestCase("extra spaces", " too many spaces ", "too-many-spaces"),
TestCase("already a slug", "hello-world", "hello-world"),
TestCase("mixed case", "CamelCaseString", "camelcasestring"),
).map { Arguments.of(it) }
}
}

MockK is the standard mocking library for Kotlin. It’s designed for Kotlin’s language features (extension functions, coroutines, companion objects) in a way that Mockito cannot match.

Feature Mockito MockK
Kotlin-first DSL No Yes
Extension functions Partial Full support
Coroutine support Via mockito-kotlin Native
Companion object mocking No Yes
Top-level function mocking No Yes
Relaxed mocks No Yes

The shape is the same everywhere: build a fake repository, inject it into the service, assert on behavior. MockK creates the fake from an interface with mockk<UserRepository>():

const userRepo = {
findById: jest.fn().mockResolvedValue({ id: '1', name: 'Alice' }),
save: jest.fn().mockResolvedValue(undefined),
};
const service = new UserService(userRepo);

Stubbing: every / returns / throws / answers

Section titled “Stubbing: every / returns / throws / answers”

The every { … } returns … block is MockK’s core stubbing DSL. Variants cover exceptions, sequences, custom logic, and void functions:

class MockBehaviorTest {
private val repo = mockk<UserRepository>()
@Test
fun `throws an exception`() {
every { repo.findById("bad") } throws IllegalArgumentException("Invalid ID")
}
@Test
fun `returns different values on consecutive calls`() {
every { repo.findById("1") } returnsMany listOf(
User("1", "Alice", "a@b.com"),
User("1", "Alice Updated", "a@b.com")
)
assertEquals("Alice", repo.findById("1")!!.name)
assertEquals("Alice Updated", repo.findById("1")!!.name)
}
@Test
fun `custom answer logic`() {
every { repo.save(any()) } answers {
val user = firstArg<User>()
user.copy(id = "generated-id")
}
val saved = repo.save(User("", "Bob", "bob@test.com"))
assertEquals("generated-id", saved.id)
}
@Test
fun `void functions -- use just Runs`() {
every { repo.delete(any()) } just Runs
repo.delete("1") // no exception
verify { repo.delete("1") }
}
}

Use any() or match { … } to match arguments loosely, and a slot<User>() to capture exactly what was passed:

@Test
fun `argument matchers`() {
every { repo.findById(any()) } returns User("x", "Any", "any@test.com")
every { repo.findById(match { it.startsWith("user-") }) } returns User("u", "Matched", "m@test.com")
assertEquals("Any", repo.findById("random")!!.name)
assertEquals("Matched", repo.findById("user-123")!!.name)
}
@Test
fun `captures arguments with slot`() {
val slot = slot<User>()
every { repo.save(capture(slot)) } answers { slot.captured }
service.createUser("Charlie", "charlie@test.com")
// Inspect what was passed to repo.save()
assertEquals("Charlie", slot.captured.name)
assertEquals("charlie@test.com", slot.captured.email)
assertTrue(slot.captured.id.isNotEmpty())
}

To capture multiple calls, pass a mutableListOf<User>() to capture(...) instead of a single slot.

@Test
fun `verification options`() {
val repo = mockk<UserRepository>()
every { repo.findById(any()) } returns null
every { repo.findAll() } returns emptyList()
every { repo.save(any()) } answers { firstArg() }
repo.findById("1")
repo.findById("2")
repo.findAll()
// Verify call count
verify(exactly = 2) { repo.findById(any()) }
verify(exactly = 1) { repo.findAll() }
verify(exactly = 0) { repo.save(any()) }
// Verify call order
verifyOrder {
repo.findById("1")
repo.findById("2")
repo.findAll()
}
// Verify no other calls were made
confirmVerified(repo)
}

A relaxed mock returns default values (0, “”, empty list, null) for any unstubbed call, instead of throwing. This is useful for tests where you only care about some interactions:

@Test
fun `relaxed mock returns defaults`() {
val repo = mockk<UserRepository>(relaxed = true)
// These all work without `every` -- they return defaults
val user = repo.findById("1") // returns null (nullable return)
val users = repo.findAll() // returns emptyList()
repo.delete("1") // just runs
assertNull(user)
assertTrue(users.isEmpty())
}

relaxUnitFun = true only relaxes Unit-returning functions: with mockk<UserRepository>(relaxUnitFun = true), repo.delete("1") works without stubbing, but repo.findById("1") still throws — it returns a value type.

A spy wraps a real object and lets you override specific methods:

const service = new UserService();
jest.spyOn(service, 'getUser').mockReturnValue({ id: '1', name: 'Alice' });

MockK can mock things Mockito can’t — companion objects with mockkObject, and suspending functions with coEvery / coVerify:

class IdGenerator {
companion object {
fun generate(): String = java.util.UUID.randomUUID().toString()
}
}
@Test
fun `mock companion object`() {
mockkObject(IdGenerator.Companion)
every { IdGenerator.generate() } returns "fixed-id"
assertEquals("fixed-id", IdGenerator.generate())
unmockkObject(IdGenerator.Companion)
}
interface AsyncUserRepository {
suspend fun findById(id: String): User?
suspend fun save(user: User): User
}
@Test
fun `mock suspending functions`() = runTest {
val repo = mockk<AsyncUserRepository>()
coEvery { repo.findById("1") } returns User("1", "Alice", "a@b.com")
coEvery { repo.save(any()) } coAnswers { firstArg() }
val user = repo.findById("1")
assertEquals("Alice", user!!.name)
coVerify { repo.findById("1") }
}

coEvery and coVerify are the suspend equivalents of every and verify.

Kotest is a Kotlin-native test framework that provides BDD-style specs (like Jest/Mocha) and powerful property-based testing (like fast-check or Go’s rapid).

Kotest offers multiple spec styles — pick the one that matches your team’s preference. StringSpec is the simplest, DescribeSpec reads exactly like Jest, and BehaviorSpec gives you Given/When/Then BDD:

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldHaveLength
class StringSpecExample : StringSpec({
"string length should be correct" {
"hello".length shouldBe 5
}
"string should have expected length" {
"kotlin" shouldHaveLength 6
}
})

Property testing generates random inputs to verify that properties hold for all inputs, not just the ones you think of. This is like fast-check in TypeScript or rapid in Go:

import fc from 'fast-check';
test('reverse of reverse is identity', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
expect(arr.reverse().reverse()).toEqual(arr);
})
);
});

The key generators are forAll (returns a Boolean property) and checkAll (runs assertions inside the block). Arb (Arbitrary) describes how to generate values, and you can compose your own for domain objects:

import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
data class Money(val amount: Long, val currency: String) {
init {
require(amount >= 0) { "Amount must be non-negative" }
}
operator fun plus(other: Money): Money {
require(currency == other.currency) { "Currency mismatch" }
return Money(amount + other.amount, currency)
}
}
fun Arb.Companion.money(currency: String = "USD"): Arb<Money> =
Arb.long(0L..1_000_000L).map { Money(it, currency) }
class MoneyPropertyTest : StringSpec({
"addition is commutative" {
forAll(Arb.money(), Arb.money()) { a, b ->
a + b == b + a
}
}
"addition with zero is identity" {
forAll(Arb.money()) { money ->
money + Money(0, "USD") == money
}
}
"addition is associative" {
forAll(Arb.money(), Arb.money(), Arb.money()) { a, b, c ->
(a + b) + c == a + (b + c)
}
}
})

Testcontainers: Docker-based integration tests

Section titled “Testcontainers: Docker-based integration tests”

Testcontainers spins up real Docker containers (PostgreSQL, Redis, Kafka, etc.) for your tests. No more mocking databases — test against the real thing. Each container’s lifecycle is bound to the test run:

Testcontainers lifecycle
Rendering diagram…

For comparison, this is the equivalent in testcontainers-go:

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:16",
ExposedPorts: []string{"5432/tcp"},
WaitingFor: wait.ForListeningPort("5432/tcp"),
},
Started: true,
})

The @Testcontainers annotation manages the container lifecycle; @Container on a @JvmStatic field starts one container shared by all tests in the class:

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import java.sql.DriverManager
@Testcontainers
class PostgresIntegrationTest {
companion object {
@Container
@JvmStatic
val postgres = PostgreSQLContainer("postgres:16-alpine").apply {
withDatabaseName("testdb")
withUsername("test")
withPassword("test")
}
}
@Test
fun `can connect and query`() {
val conn = DriverManager.getConnection(
postgres.jdbcUrl,
postgres.username,
postgres.password
)
conn.createStatement().execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
""")
conn.prepareStatement("INSERT INTO users (name, email) VALUES (?, ?)").apply {
setString(1, "Alice")
setString(2, "alice@test.com")
executeUpdate()
}
val rs = conn.createStatement().executeQuery("SELECT name FROM users WHERE email = 'alice@test.com'")
assertTrue(rs.next())
assertEquals("Alice", rs.getString("name"))
conn.close()
}
}

Redis uses the GenericContainer with an exposed port; Kafka has a dedicated KafkaContainer. Both expose connection details via mapped ports:

@Testcontainers
class RedisIntegrationTest {
companion object {
@Container
@JvmStatic
val redis = GenericContainer("redis:7-alpine").apply {
withExposedPorts(6379)
}
}
@Test
fun `stores and retrieves values`() {
val jedis = Jedis(redis.host, redis.getMappedPort(6379))
jedis.set("greeting", "hello")
assertEquals("hello", jedis.get("greeting"))
jedis.close()
}
}
@Testcontainers
class KafkaIntegrationTest {
companion object {
@Container
@JvmStatic
val kafka = KafkaContainer("apache/kafka:3.8.1")
}
@Test
fun `produces and consumes messages`() {
val topic = "test-topic"
val producerProps = Properties().apply {
put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.bootstrapServers)
put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java.name)
put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java.name)
}
val producer = KafkaProducer<String, String>(producerProps)
producer.send(ProducerRecord(topic, "key", "hello-kafka")).get()
producer.close()
val consumerProps = Properties().apply {
put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.bootstrapServers)
put(ConsumerConfig.GROUP_ID_CONFIG, "test-group")
put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest")
put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name)
put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name)
}
val consumer = KafkaConsumer<String, String>(consumerProps)
consumer.subscribe(listOf(topic))
val records = consumer.poll(Duration.ofSeconds(10))
assertEquals(1, records.count())
assertEquals("hello-kafka", records.first().value())
consumer.close()
}
}

Starting containers for every test class is slow. Share them across test classes by holding the container in an object with withReuse(true):

src/test/kotlin/testutil/TestContainers.kt
object TestPostgres {
val container = PostgreSQLContainer("postgres:16-alpine").apply {
withDatabaseName("testdb")
withUsername("test")
withPassword("test")
withReuse(true) // Keep container running between test runs
start()
}
}

Then add this to ~/.testcontainers.properties to enable reuse:

~/.testcontainers.properties
testcontainers.reuse.enable=true

Spring Boot provides comprehensive test infrastructure with test slices that load only the parts of the application you need:

Annotation What it loads Use for
@SpringBootTest Full application context Integration / E2E tests
@WebMvcTest Controllers + MVC infra Controller unit tests
@DataJpaTest JPA + embedded DB Repository tests
@WebFluxTest WebFlux controllers Reactive controller tests
None (plain JUnit) Nothing Unit tests (preferred)

Full integration test with @SpringBootTest

Section titled “Full integration test with @SpringBootTest”

@SpringBootTest plus @AutoConfigureMockMvc boots the full context and gives you a MockMvc to drive HTTP requests:

import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
@SpringBootTest
@AutoConfigureMockMvc
class TaskControllerIntegrationTest {
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `creates and retrieves a task`() {
val createResult = mockMvc.perform(
post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title": "Write tests", "description": "Add test coverage"}""")
)
.andExpect(status().isCreated)
.andExpect(jsonPath("$.title").value("Write tests"))
.andReturn()
val id = com.jayway.jsonpath.JsonPath.read<Int>(
createResult.response.contentAsString, "$.id"
)
mockMvc.perform(get("/api/tasks/$id"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.title").value("Write tests"))
}
@Test
fun `returns 404 for unknown task`() {
mockMvc.perform(get("/api/tasks/99999"))
.andExpect(status().isNotFound)
}
}

@WebMvcTest loads only the controller layer. You mock the service layer with @MockkBean (from springmockk):

import io.mockk.every
import io.mockk.verify
import com.ninjasquad.springmockk.MockkBean
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
@WebMvcTest(TaskController::class)
class TaskControllerUnitTest {
@Autowired
lateinit var mockMvc: MockMvc
@MockkBean
lateinit var taskService: TaskService
@Test
fun `returns task by id`() {
val task = Task(id = 1, title = "Test", description = "A task", completed = false)
every { taskService.findById(1) } returns task
mockMvc.perform(get("/api/tasks/1"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.title").value("Test"))
.andExpect(jsonPath("$.completed").value(false))
verify(exactly = 1) { taskService.findById(1) }
}
}

@DataJpaTest configures an embedded DB and gives you a TestEntityManager to set up fixtures:

import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
@DataJpaTest
class TaskRepositoryTest {
@Autowired
lateinit var entityManager: TestEntityManager
@Autowired
lateinit var repository: TaskRepository
@Test
fun `finds tasks by completed status`() {
entityManager.persist(TaskEntity(title = "Done", description = "d", completed = true))
entityManager.persist(TaskEntity(title = "Todo", description = "d", completed = false))
entityManager.persist(TaskEntity(title = "Also done", description = "d", completed = true))
entityManager.flush()
val completed = repository.findByCompleted(true)
assertEquals(2, completed.size)
assertTrue(completed.all { it.completed })
}
}

The most common integration pattern — use a real PostgreSQL database, wiring the container’s connection details into Spring with @DynamicPropertySource:

@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class TaskApiIntegrationTest {
companion object {
@Container
@JvmStatic
val postgres = PostgreSQLContainer("postgres:16-alpine").apply {
withDatabaseName("testdb")
withUsername("test")
withPassword("test")
}
@JvmStatic
@DynamicPropertySource
fun configureProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { postgres.jdbcUrl }
registry.add("spring.datasource.username") { postgres.username }
registry.add("spring.datasource.password") { postgres.password }
registry.add("spring.jpa.hibernate.ddl-auto") { "create-drop" }
}
}
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `full CRUD cycle against real PostgreSQL`() {
// Create
val json = """{"title": "Integration test", "description": "Testing with real DB"}"""
val result = mockMvc.perform(
post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(json)
)
.andExpect(status().isCreated)
.andExpect(jsonPath("$.id").exists())
.andReturn()
val id = com.jayway.jsonpath.JsonPath.read<Int>(
result.response.contentAsString, "$.id"
)
// Read / Update / Delete
mockMvc.perform(get("/api/tasks/$id"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.title").value("Integration test"))
mockMvc.perform(
put("/api/tasks/$id")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"title": "Updated", "description": "Updated desc", "completed": true}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.completed").value(true))
mockMvc.perform(delete("/api/tasks/$id"))
.andExpect(status().isNoContent)
mockMvc.perform(get("/api/tasks/$id"))
.andExpect(status().isNotFound)
}
}

To avoid duplicating container setup, extract it into an abstract base class that your test classes extend:

src/test/kotlin/testutil/IntegrationTestBase.kt
abstract class IntegrationTestBase {
companion object {
@JvmStatic
val postgres = PostgreSQLContainer("postgres:16-alpine").apply {
withDatabaseName("testdb")
withUsername("test")
withPassword("test")
start()
}
@JvmStatic
@DynamicPropertySource
fun configureProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { postgres.jdbcUrl }
registry.add("spring.datasource.username") { postgres.username }
registry.add("spring.datasource.password") { postgres.password }
registry.add("spring.jpa.hibernate.ddl-auto") { "create-drop" }
}
}
}

WebTestClient provides a fluent API and works with both MVC and WebFlux:

import org.springframework.boot.test.context.SpringBootTest.WebEnvironment
import org.springframework.test.web.reactive.server.WebTestClient
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class TaskApiWebTestClientTest {
@Autowired
lateinit var webClient: WebTestClient
@Test
fun `creates a task`() {
webClient.post()
.uri("/api/tasks")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.bodyValue("""{"title": "Test", "description": "desc"}""")
.exchange()
.expectStatus().isCreated
.expectBody()
.jsonPath("$.title").isEqualTo("Test")
}
}

Ktor provides a built-in test engine that runs your application in-process without starting a real HTTP server. This is fast and simple — comparable to Go’s httptest:

req := httptest.NewRequest("GET", "/api/tasks", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)

The testApplication { … } builder configures your app and exposes a client for making requests:

import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
class TaskRoutesTest {
@Test
fun `GET tasks returns empty list`() = testApplication {
application {
configureSerialization()
configureRouting()
}
val response = client.get("/api/tasks")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("[]", response.bodyAsText())
}
@Test
fun `POST creates a task`() = testApplication {
application {
configureSerialization()
configureRouting()
}
val response = client.post("/api/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"title": "Test task", "description": "A test"}""")
}
assertEquals(HttpStatusCode.Created, response.status)
assertTrue(response.bodyAsText().contains("Test task"))
}
@Test
fun `GET unknown task returns 404`() = testApplication {
application {
configureSerialization()
configureRouting()
}
val response = client.get("/api/tasks/99999")
assertEquals(HttpStatusCode.NotFound, response.status)
}
}

Install ContentNegotiation on a custom client to send and receive typed bodies with body<TaskResponse>():

import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
@Test
fun `create and retrieve task with typed client`() = testApplication {
application {
configureSerialization()
configureRouting()
}
val jsonClient = createClient {
install(ContentNegotiation) { json() }
}
val created = jsonClient.post("/api/tasks") {
contentType(ContentType.Application.Json)
setBody(CreateTaskRequest(title = "Learn Ktor", description = "Testing module"))
}
assertEquals(HttpStatusCode.Created, created.status)
val task = created.body<TaskResponse>()
assertEquals("Learn Ktor", task.title)
}

Authenticated endpoints are tested by sending the Authorization header, and a database-backed app can wire in a Testcontainers Postgres:

@Test
fun `protected endpoint requires auth`() = testApplication {
application {
configureSerialization()
configureAuth()
configureRouting()
}
val unauthed = client.get("/api/admin/users")
assertEquals(HttpStatusCode.Unauthorized, unauthed.status)
val authed = client.get("/api/admin/users") {
header(HttpHeaders.Authorization, "Bearer ${generateTestToken()}")
}
assertEquals(HttpStatusCode.OK, authed.status)
}

Testing coroutines requires special support to control virtual time and concurrency. The problem: regular runBlocking waits real time for delay() calls. runTest skips delays automatically.

import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.delay
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
class CoroutineTestExamples {
suspend fun fetchWithRetry(fetch: suspend () -> String, retries: Int = 3): String {
repeat(retries) { attempt ->
try {
return fetch()
} catch (e: Exception) {
if (attempt == retries - 1) throw e
delay(1000L * (attempt + 1)) // Exponential backoff
}
}
throw IllegalStateException("Unreachable")
}
@Test
fun `retries on failure then succeeds`() = runTest {
var attempts = 0
val result = fetchWithRetry(
fetch = {
attempts++
if (attempts < 3) throw RuntimeException("fail")
"success after retries"
}
)
assertEquals("success after retries", result)
assertEquals(3, attempts)
// Note: delays were skipped -- this test runs instantly
}
@Test
fun `throws after all retries exhausted`() = runTest {
assertThrows<RuntimeException> {
fetchWithRetry(
fetch = { throw RuntimeException("always fails") },
retries = 3
)
}
}
}

Here the function under test takes a suspend () -> String lambda — the retry delays are skipped by runTest, so the test completes instantly.

runTest gives you a TestScope with virtual-time controls: advanceTimeBy, runCurrent, and advanceUntilIdle:

import kotlinx.coroutines.test.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class VirtualTimeTest {
@Test
fun `controls virtual time`() = runTest {
var value = 0
launch {
delay(1000)
value = 1
delay(1000)
value = 2
}
assertEquals(0, value) // time hasn't advanced yet
advanceTimeBy(1000)
runCurrent()
assertEquals(1, value)
advanceTimeBy(1000)
runCurrent()
assertEquals(2, value)
}
@Test
fun `advanceUntilIdle completes all work`() = runTest {
val results = mutableListOf<Int>()
launch { delay(100); results.add(1) }
launch { delay(200); results.add(2) }
launch { delay(300); results.add(3) }
advanceUntilIdle()
assertEquals(listOf(1, 2, 3), results)
}
}

Flows collect inside runTest like any other suspending code. For infinite flows, combine take(n) with toList():

import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
class FlowTestExamples {
fun numberFlow(): Flow<Int> = flow {
emit(1); emit(2); emit(3)
}
@Test
fun `collects all values from flow`() = runTest {
val values = numberFlow().toList()
assertEquals(listOf(1, 2, 3), values)
}
@Test
fun `tests flow transformations`() = runTest {
val doubled = numberFlow()
.map { it * 2 }
.filter { it > 2 }
.toList()
assertEquals(listOf(4, 6), doubled)
}
}

When your code uses specific dispatchers, inject a TestDispatcher so virtual time controls it:

import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
class UserService(private val dispatcher: CoroutineDispatcher = Dispatchers.IO) {
suspend fun loadUser(id: String): User = withContext(dispatcher) {
delay(1000) // simulate slow I/O
User(id, "User $id")
}
}
class UserServiceTest {
@Test
fun `loads user with test dispatcher`() = runTest {
val testDispatcher = StandardTestDispatcher(testScheduler)
val service = UserService(dispatcher = testDispatcher)
val deferred = async { service.loadUser("1") }
advanceUntilIdle()
val user = deferred.await()
assertEquals("User 1", user.name)
}
}

A common convention is to split fast unit tests from slow integration tests by directory:

  • Directorysrc/
    • Directorymain/kotlin/com/example/
      • Directorymodel/
        • …
      • Directoryrepository/
        • …
      • Directoryservice/
        • …
      • Directorycontroller/
        • …
    • Directorytest/kotlin/com/example/
      • Directoryunit/ Fast, no external deps
        • Directoryservice/
          • UserServiceTest.kt
          • TaskServiceTest.kt
      • Directoryintegration/ Testcontainers, real DB
        • Directoryrepository/
          • UserRepositoryTest.kt
        • Directoryapi/
          • TaskApiIntegrationTest.kt
      • Directorytestutil/ Shared test helpers
        • IntegrationTestBase.kt
        • TestFixtures.kt

Separating unit and integration tests with Gradle

Section titled “Separating unit and integration tests with Gradle”

Create a separate source set and Gradle task so integration tests run on demand:

build.gradle.kts
sourceSets {
create("integrationTest") {
kotlin.srcDir("src/integrationTest/kotlin")
resources.srcDir("src/integrationTest/resources")
compileClasspath += sourceSets["main"].output + sourceSets["test"].output
runtimeClasspath += sourceSets["main"].output + sourceSets["test"].output
}
}
configurations["integrationTestImplementation"].extendsFrom(configurations["testImplementation"])
configurations["integrationTestRuntimeOnly"].extendsFrom(configurations["testRuntimeOnly"])
tasks.register<Test>("integrationTest") {
description = "Runs integration tests."
group = "verification"
testClassesDirs = sourceSets["integrationTest"].output.classesDirs
classpath = sourceSets["integrationTest"].runtimeClasspath
useJUnitPlatform()
shouldRunAfter(tasks.test)
}
tasks.check {
dependsOn(tasks.named("integrationTest"))
}

Tagging tests (alternative to separate source sets)

Section titled “Tagging tests (alternative to separate source sets)”

Tag classes with @Tag(...), then include or exclude tags at run time:

import org.junit.jupiter.api.Tag
@Tag("unit")
class UserServiceTest { /* ... */ }
@Tag("integration")
class UserApiIntegrationTest { /* ... */ }
@Tag("slow")
class FullE2ETest { /* ... */ }
Terminal window
./gradlew test -PincludeTags=unit # only unit tests
./gradlew test -PexcludeTags=slow # skip slow tests
build.gradle.kts
tasks.test {
useJUnitPlatform {
val includeTags = project.findProperty("includeTags") as String?
val excludeTags = project.findProperty("excludeTags") as String?
includeTags?.let { includeTags(it) }
excludeTags?.let { excludeTags(it) }
}
}

JaCoCo is the JVM’s coverage tool (the equivalent of go test -cover or Jest’s --coverage). Apply the plugin and wire reports onto the test task:

build.gradle.kts
plugins {
jacoco
}
tasks.test {
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required.set(true) // For CI tools
html.required.set(true) // For humans
csv.required.set(false)
}
}
tasks.jacocoTestCoverageVerification {
violationRules {
rule {
limit { minimum = "0.80".toBigDecimal() } // 80% line coverage
}
rule {
element = "CLASS"
excludes = listOf("*.Application*", "*.config.*")
limit {
counter = "BRANCH"
minimum = "0.70".toBigDecimal()
}
}
}
}
build/reports/jacoco/test/html/index.html
./gradlew test jacocoTestReport
./gradlew jacocoTestCoverageVerification
# Fails build if coverage is below threshold

Centralize test-data construction in factory functions with sensible defaults — the name: String = "Test User" defaults let each test override only what it cares about:

src/test/kotlin/testutil/TestFixtures.kt
object TestFixtures {
fun user(
id: String = "user-${java.util.UUID.randomUUID()}",
name: String = "Test User",
email: String = "$id@test.com"
) = User(id = id, name = name, email = email)
fun task(
id: Long = 0,
title: String = "Test Task",
description: String = "A test task",
completed: Boolean = false
) = Task(id = id, title = title, description = description, completed = completed)
}
Concept TypeScript Go Kotlin/JVM
Test framework Jest / Vitest go test JUnit 5
Assertions expect().toBe() if/t.Error assertEquals / Kotest shouldBe
Mocking jest.mock() interfaces + manual MockK mockk<T>()
BDD style describe/it t.Run() subtests Kotest DescribeSpec / BehaviorSpec
Table tests test.each []struct{} loop @ParameterizedTest / @MethodSource
Property testing fast-check rapid Kotest forAll / checkAll
Docker tests testcontainers-node testcontainers-go Testcontainers @Container
Controller tests supertest httptest MockMvc / testApplication
Coroutine tests — — runTest / TestScope
Coverage --coverage go test -cover JaCoCo

Put the testing stack to work — write a full suite against a real database, then prove invariants with generated inputs.