Skip to content

Testing

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:

ConceptTypeScriptGoKotlin/JVM
Test runnerJest / Vitestgo testJUnit 5 (JUnit Platform)
Assertionsexpect(x).toBe(y)if x != y { t.Errorf(...) }assertEquals(y, x) / Kotest matchers
Mockingjest.mock() / vi.mock()interfaces + manual / testifyMockK
BDD styledescribe/it blockssubtests t.Run()Kotest BehaviorSpec / DescribeSpec
Property testingfast-checkrapid / gopterKotest property testing
Integration (Docker)testcontainers-nodetestcontainers-goTestcontainers Java/Kotlin
Coverage--coverage flaggo test -coverJaCoCo
Framework testingsupertesthttptest@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 / VitestKotest
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.

FeatureMockitoMockK
Kotlin-first DSLNoYes
Extension functionsPartialFull support
Coroutine supportVia mockito-kotlinNative
Companion object mockingNoYes
Top-level function mockingNoYes
Relaxed mocksNoYes

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:

AnnotationWhat it loadsUse for
@SpringBootTestFull application contextIntegration / E2E tests
@WebMvcTestControllers + MVC infraController unit tests
@DataJpaTestJPA + embedded DBRepository tests
@WebFluxTestWebFlux controllersReactive controller tests
None (plain JUnit)NothingUnit 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)
}
ConceptTypeScriptGoKotlin/JVM
Test frameworkJest / Vitestgo testJUnit 5
Assertionsexpect().toBe()if/t.ErrorassertEquals / Kotest shouldBe
Mockingjest.mock()interfaces + manualMockK mockk<T>()
BDD styledescribe/itt.Run() subtestsKotest DescribeSpec / BehaviorSpec
Table teststest.each[]struct{} loop@ParameterizedTest / @MethodSource
Property testingfast-checkrapidKotest forAll / checkAll
Docker teststestcontainers-nodetestcontainers-goTestcontainers @Container
Controller testssupertesthttptestMockMvc / testApplication
Coroutine testsrunTest / TestScope
Coverage--coveragego test -coverJaCoCo

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