Kotlin testing with JUnit 5, Kotest, MockK, coroutine testing, and Testcontainers. Covers TDD workflow, test structure, coroutine test utilities, and Spring Boot integration testing.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
runBlocking ignoring virtual time in coroutine tests// build.gradle.kts
dependencies {
testImplementation(kotlin("test"))
testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
testImplementation("io.mockk:mockk:1.13.12")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
// Optional: Kotest
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
// Optional: Testcontainers
testImplementation("org.testcontainers:postgresql:1.20.1")
testImplementation("org.testcontainers:junit-jupiter:1.20.1")
}
tasks.test {
useJUnitPlatform()
}
plugins {
id("org.jetbrains.kotlinx.kover") version "0.8.3"
}
kover {
reports {
verify {
rule {
minBound(80) // 80% minimum line coverage
}
}
}
}
import org.junit.jupiter.api.*
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class OrderServiceTest {
private lateinit var service: OrderService
private lateinit var repo: FakeOrderRepository
@BeforeAll
fun setUpClass() {
// Runs once for the class
}
@BeforeEach
fun setUp() {
repo = FakeOrderRepository()
service = OrderService(repo)
}
@AfterEach
fun tearDown() {
repo.clear()
}
@Test
fun `places order with valid items`() {
val order = service.place(validRequest)
assertEquals(OrderStatus.PENDING, order.status)
assertNotNull(order.id)
}
@Test
fun `throws when items list is empty`() {
assertThrows<IllegalArgumentException> {
service.place(emptyItemsRequest)
}
}
}
@ParameterizedTest
@ValueSource(strings = ["", " ", "\t", "\n"])
fun `rejects blank name`(name: String) {
assertThrows<ValidationException> {
service.createUser(CreateUserRequest(name = name))
}
}
@ParameterizedTest
@CsvSource(
"alice@example.com, true",
"not-an-email, false",
"@nodomain.com, false"
)
fun `validates email format`(email: String, valid: Boolean) {
assertEquals(valid, EmailValidator.isValid(email))
}
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.assertions.throwables.shouldThrowExactly
class CartSpec : DescribeSpec({
val cart = Cart()
describe("Cart") {
context("when empty") {
it("has zero total") {
cart.total shouldBe Money.ZERO
}
it("has no items") {
cart.items shouldHaveSize 0
}
}
context("when adding items") {
it("increases total") {
cart.add(item(price = "10.00"))
cart.total shouldBe Money.of("10.00", "EUR")
}
}
context("with invalid items") {
it("throws on negative price") {
shouldThrowExactly<IllegalArgumentException> {
cart.add(item(price = "-1.00"))
}
}
}
}
})
val userRepo = mockk<UserRepository>()
// Stub
every { userRepo.findById(any()) } returns testUser
every { userRepo.findById(unknownId) } returns null
// Stub throwing
every { userRepo.save(any()) } throws DatabaseException("Connection lost")
// Verify call
verify(exactly = 1) { userRepo.findById(testUser.id) }
verify { userRepo.save(any()) wasNot Called }
coEvery { userRepo.findById(any()) } returns testUser
coEvery { userRepo.save(any()) } returns savedUser
coVerify { userRepo.save(match { it.name == "Alice" }) }
val slot = slot<User>()
coEvery { userRepo.save(capture(slot)) } returns savedUser
service.update(updateRequest)
assertEquals("Alice", slot.captured.name)
val realService = spyk(UserService(mockk()))
every { realService.validate(any()) } returns true
// Other methods call real implementation
import kotlinx.coroutines.test.*
class FlowTest {
@Test
fun `emits values from flow`() = runTest {
val values = mutableListOf<Int>()
val job = backgroundScope.launch {
counterFlow(from = 1, to = 3).collect { values.add(it) }
}
advanceUntilIdle()
assertEquals(listOf(1, 2, 3), values)
}
@Test
fun `respects timeout`() = runTest {
assertFailsWith<TimeoutCancellationException> {
withTimeout(100) {
delay(Long.MAX_VALUE)
}
}
}
}
@Test
fun `state updates on action`() = runTest {
val viewModel = MyViewModel(mockRepo)
val states = mutableListOf<UiState>()
val job = backgroundScope.launch {
viewModel.state.collect { states.add(it) }
}
viewModel.load(userId)
advanceUntilIdle()
assertTrue(states.last() is UiState.Success)
}
@Testcontainers
@SpringBootTest
class UserRepositoryIntegrationTest {
companion object {
@Container
@JvmStatic
val postgres = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply {
withDatabaseName("testdb")
}
@DynamicPropertySource
@JvmStatic
fun configureProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
}
@Autowired
lateinit var repository: UserRepository
@Test
fun `persists and retrieves user`() {
val saved = repository.save(testUser)
val found = repository.findById(saved.id)
assertNotNull(found)
assertEquals(testUser.email, found.email)
}
}
Prefer fakes (in-memory implementations) over mocks for repositories:
class FakeUserRepository : UserRepository {
private val store = mutableMapOf<UserId, User>()
override suspend fun findById(id: UserId) = store[id]
override suspend fun save(user: User) = user.also { store[it.id] = it }
override suspend fun delete(id: UserId) { store.remove(id) }
fun seed(vararg users: User) = users.forEach { store[it.id] = it }
fun clear() = store.clear()
}
Wrong:
@Test
fun `emits values from flow`() = runBlocking { // Blocks thread, ignores virtual time
val values = mutableListOf<Int>()
counterFlow(1, 3).collect { values.add(it) }
assertEquals(listOf(1, 2, 3), values)
}
Correct:
@Test
fun `emits values from flow`() = runTest { // Controls virtual time, no real delays
val values = mutableListOf<Int>()
val job = backgroundScope.launch { counterFlow(1, 3).collect { values.add(it) } }
advanceUntilIdle()
assertEquals(listOf(1, 2, 3), values)
}
Why: runBlocking executes delays in real time and does not integrate with TestCoroutineScheduler, making tests slow and unable to control timing.
Wrong:
every { userRepo.findById(any()) } returns testUser // Compile error or silent failure
verify { userRepo.findById(testUser.id) }
Correct:
coEvery { userRepo.findById(any()) } returns testUser
coVerify { userRepo.findById(testUser.id) }
Why: every/verify do not understand suspend functions; coEvery/coVerify are the MockK equivalents that correctly stub and assert on suspend calls.
Wrong:
class OrderServiceTest {
private val repo = FakeOrderRepository() // Shared across all tests — state leaks
private val service = OrderService(repo)
@Test
fun `places order`() { service.place(validRequest) }
@Test
fun `rejects empty items`() { /* repo may contain orders from the previous test */ }
}
Correct:
class OrderServiceTest {
private lateinit var repo: FakeOrderRepository
private lateinit var service: OrderService
@BeforeEach
fun setUp() {
repo = FakeOrderRepository() // Fresh instance per test
service = OrderService(repo)
}
}
Why: Class-level mutable state allows one test's side effects to pollute subsequent tests, causing order-dependent failures that are extremely difficult to diagnose.
Wrong:
@SpringBootTest
class UserRepoTest {
// application-test.properties: spring.datasource.url=jdbc:h2:mem:testdb
// H2 SQL dialect differs from PostgreSQL — migrations may silently fail
}
Correct:
@Testcontainers
@SpringBootTest
class UserRepoTest {
companion object {
@Container @JvmStatic
val postgres = PostgreSQLContainer<Nothing>("postgres:16-alpine")
@DynamicPropertySource @JvmStatic
fun props(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
}
}
}
Why: H2's SQL dialect is not fully compatible with PostgreSQL, causing migrations and queries to behave differently in tests than in production.
Wrong:
@Test
fun `handles timeout`() = runTest {
try {
withTimeout(100) { delay(Long.MAX_VALUE) }
} catch (e: Exception) { // Catches CancellationException — swallows coroutine cancellation
assertTrue(e is TimeoutCancellationException)
}
}
Correct:
@Test
fun `handles timeout`() = runTest {
assertFailsWith<TimeoutCancellationException> {
withTimeout(100) { delay(Long.MAX_VALUE) }
}
}
Why: Catching the broad Exception type also catches CancellationException, which is the coroutine mechanism for structured concurrency; swallowing it corrupts the coroutine's cancellation state.
useJUnitPlatform()@BeforeEach creates fresh dependencies (no shared mutable state)runTest (not runBlocking)coEvery/coVerify used for suspend functionsCancellationException not caught in tests