kotlin-testing
Kotlin testing with JUnit 5, Kotest, MockK, coroutine testing, and Testcontainers. Covers TDD workflow, test structure, coroutine test utilities, and Spring Boot integration testing.
Best use case
kotlin-testing is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Kotlin testing with JUnit 5, Kotest, MockK, coroutine testing, and Testcontainers. Covers TDD workflow, test structure, coroutine test utilities, and Spring Boot integration testing.
Teams using kotlin-testing should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/kotlin-testing/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How kotlin-testing Compares
| Feature / Agent | kotlin-testing | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Kotlin testing with JUnit 5, Kotest, MockK, coroutine testing, and Testcontainers. Covers TDD workflow, test structure, coroutine test utilities, and Spring Boot integration testing.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# Kotlin Testing Skill
## When to Activate
- Writing tests for new Kotlin code
- Setting up a test framework in a Kotlin project
- Testing coroutines and Flow
- Mocking dependencies with MockK
- Writing integration tests with Testcontainers
- Configuring Kover for coverage
- Diagnosing test failures caused by `runBlocking` ignoring virtual time in coroutine tests
- Replacing H2 in-memory databases with Testcontainers to match production database behavior
---
## Framework Setup
### Gradle dependencies
```kotlin
// 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()
}
```
### Kover (coverage)
```kotlin
plugins {
id("org.jetbrains.kotlinx.kover") version "0.8.3"
}
kover {
reports {
verify {
rule {
minBound(80) // 80% minimum line coverage
}
}
}
}
```
---
## JUnit 5 + Kotlin Test
### Basic structure
```kotlin
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)
}
}
}
```
### Parameterized tests
```kotlin
@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))
}
```
---
## Kotest Style
```kotlin
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"))
}
}
}
}
})
```
---
## MockK
### Basic mocking
```kotlin
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 }
```
### Coroutine mocks (coEvery / coVerify)
```kotlin
coEvery { userRepo.findById(any()) } returns testUser
coEvery { userRepo.save(any()) } returns savedUser
coVerify { userRepo.save(match { it.name == "Alice" }) }
```
### Capturing arguments
```kotlin
val slot = slot<User>()
coEvery { userRepo.save(capture(slot)) } returns savedUser
service.update(updateRequest)
assertEquals("Alice", slot.captured.name)
```
### Spy (partial mock)
```kotlin
val realService = spyk(UserService(mockk()))
every { realService.validate(any()) } returns true
// Other methods call real implementation
```
---
## Coroutine Testing
```kotlin
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)
}
}
}
}
```
### Testing StateFlow
```kotlin
@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)
}
```
---
## Integration Tests with Testcontainers
```kotlin
@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)
}
}
```
---
## Fake vs Mock
Prefer **fakes** (in-memory implementations) over mocks for repositories:
```kotlin
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()
}
```
---
## Anti-Patterns
### Using runBlocking Instead of runTest for Coroutine Tests
**Wrong:**
```kotlin
@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:**
```kotlin
@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.
### Using every/verify for Suspend Functions Instead of coEvery/coVerify
**Wrong:**
```kotlin
every { userRepo.findById(any()) } returns testUser // Compile error or silent failure
verify { userRepo.findById(testUser.id) }
```
**Correct:**
```kotlin
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.
### Sharing Mutable State Between Tests via Class-Level Properties
**Wrong:**
```kotlin
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:**
```kotlin
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.
### Using H2 In-Memory Database for Integration Tests
**Wrong:**
```kotlin
@SpringBootTest
class UserRepoTest {
// application-test.properties: spring.datasource.url=jdbc:h2:mem:testdb
// H2 SQL dialect differs from PostgreSQL — migrations may silently fail
}
```
**Correct:**
```kotlin
@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.
### Catching CancellationException in Tests
**Wrong:**
```kotlin
@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:**
```kotlin
@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.
## Checklist
- [ ] JUnit 5 configured with `useJUnitPlatform()`
- [ ] Kover configured with 80% minimum coverage
- [ ] Backtick test names for readability
- [ ] `@BeforeEach` creates fresh dependencies (no shared mutable state)
- [ ] Coroutines tested with `runTest` (not `runBlocking`)
- [ ] `coEvery`/`coVerify` used for suspend functions
- [ ] Fakes used for repositories; MockK for external services
- [ ] Integration tests use Testcontainers (not H2)
- [ ] `CancellationException` not caught in testsRelated Skills
visual-testing
Visual Regression Testing: tool comparison (Chromatic/Percy/Playwright screenshots/BackstopJS), pixel-diff vs AI-based comparison, baseline management, flakiness strategies (masks, tolerances, waitForLoadState), CI integration with GitHub Actions, and Storybook integration.
typescript-testing
TypeScript testing patterns: Vitest for unit/integration, Playwright for E2E, MSW for API mocking, Testing Library for React components. Core TDD methodology for TypeScript/JavaScript projects.
swift-testing
Swift testing patterns: Swift Testing framework (Swift 6+), XCTest for UI tests, async/await test cases, actor testing, Combine testing, and XCUITest for UI automation. TDD for Swift/SwiftUI.
swift-protocol-di-testing
Protocol-based dependency injection for testable Swift code — mock file system, network, and external APIs using focused protocols and Swift Testing.
scala-testing
Scala testing with ScalaTest, MUnit, and ScalaCheck: FunSpec/FlatSpec test structure, property-based testing with forAll, mocking with MockitoSugar, Cats Effect testing with munit-cats-effect (runTest/IOSuite), ZIO Test, Testcontainers-Scala for database integration tests, and CI integration with sbt. Use when writing or reviewing Scala tests.
rust-testing
Rust testing patterns — unit tests with mockall, integration tests with sqlx transactions, HTTP handler testing (axum), benchmarks (criterion), property tests (proptest), fuzzing, and CI with cargo-nextest.
rust-testing-advanced
Advanced Rust testing anti-patterns and corrections — cfg(test) placement, expect() over unwrap(), mockall expectation ordering, executor mixing (#[tokio::test] vs block_on), PgPool isolation with
ruby-testing
RSpec testing patterns for Ruby and Rails — factories, mocks, request specs, feature specs, VCR, and SimpleCov coverage.
r-testing
R testing patterns: testthat 3e with expect_* assertions, snapshot testing, mocking with mockery and httptest2, covr code coverage, lintr static analysis, property-based testing with hedgehog, testing Shiny apps with shinytest2. Use when writing or reviewing R tests.
python-testing
Python testing strategies using pytest, TDD methodology, fixtures, mocking, and parametrization. Core testing fundamentals.
python-testing-advanced
Advanced Python testing — async testing with pytest-asyncio, exception/side-effect testing, test organization, common patterns (API, database, class methods), pytest configuration, and CLI reference. Extends python-testing.
php-testing
PHP testing patterns: PHPUnit 11 with mocks and data providers, Pest v3 with expectations and datasets, Laravel feature/HTTP tests with RefreshDatabase, Symfony WebTestCase, PHPStan static analysis, Infection mutation testing. Use when writing or reviewing PHP tests.