Claude Agent Skill · by Affaan M

Kotlin Testing

Install Kotlin Testing skill for Claude Code from affaan-m/everything-claude-code.

Install
Terminal · npx
$npx skills add https://github.com/obra/superpowers --skill test-driven-development
Works with Paperclip

How Kotlin Testing fits into a Paperclip company.

Kotlin Testing drops into any Paperclip agent that handles this kind of work. Assign it to a specialist inside a pre-configured PaperclipOrg company and the skill becomes available on every heartbeat — no prompt engineering, no tool wiring.

S
SaaS FactoryPaired

Pre-configured AI company — 18 agents, 18 skills, one-time purchase.

$27$59
Explore pack
Source file
SKILL.md824 lines
Expand
---name: kotlin-testingdescription: Kotlin testing patterns with Kotest, MockK, coroutine testing, property-based testing, and Kover coverage. Follows TDD methodology with idiomatic Kotlin practices.origin: ECC--- # Kotlin Testing Patterns Comprehensive Kotlin testing patterns for writing reliable, maintainable tests following TDD methodology with Kotest and MockK. ## When to Use - Writing new Kotlin functions or classes- Adding test coverage to existing Kotlin code- Implementing property-based tests- Following TDD workflow in Kotlin projects- Configuring Kover for code coverage ## How It Works 1. **Identify target code** — Find the function, class, or module to test2. **Write a Kotest spec** — Choose a spec style (StringSpec, FunSpec, BehaviorSpec) matching the test scope3. **Mock dependencies** — Use MockK to isolate the unit under test4. **Run tests (RED)** — Verify the test fails with the expected error5. **Implement code (GREEN)** — Write minimal code to pass the test6. **Refactor** — Improve the implementation while keeping tests green7. **Check coverage** — Run `./gradlew koverHtmlReport` and verify 80%+ coverage ## Examples The following sections contain detailed, runnable examples for each testing pattern: ### Quick Reference - **Kotest specs** — StringSpec, FunSpec, BehaviorSpec, DescribeSpec examples in [Kotest Spec Styles](#kotest-spec-styles)- **Mocking** — MockK setup, coroutine mocking, argument capture in [MockK](#mockk)- **TDD walkthrough** — Full RED/GREEN/REFACTOR cycle with EmailValidator in [TDD Workflow for Kotlin](#tdd-workflow-for-kotlin)- **Coverage** — Kover configuration and commands in [Kover Coverage](#kover-coverage)- **Ktor testing** — testApplication setup in [Ktor testApplication Testing](#ktor-testapplication-testing) ### TDD Workflow for Kotlin #### The RED-GREEN-REFACTOR Cycle ```RED     -> Write a failing test firstGREEN   -> Write minimal code to pass the testREFACTOR -> Improve code while keeping tests greenREPEAT  -> Continue with next requirement``` #### Step-by-Step TDD in Kotlin ```kotlin// Step 1: Define the interface/signature// EmailValidator.ktpackage com.example.validator fun validateEmail(email: String): Result<String> {    TODO("not implemented")} // Step 2: Write failing test (RED)// EmailValidatorTest.ktpackage com.example.validator import io.kotest.core.spec.style.StringSpecimport io.kotest.matchers.result.shouldBeFailureimport io.kotest.matchers.result.shouldBeSuccess class EmailValidatorTest : StringSpec({    "valid email returns success" {        validateEmail("user@example.com").shouldBeSuccess("user@example.com")    }     "empty email returns failure" {        validateEmail("").shouldBeFailure()    }     "email without @ returns failure" {        validateEmail("userexample.com").shouldBeFailure()    }}) // Step 3: Run tests - verify FAIL// $ ./gradlew test// EmailValidatorTest > valid email returns success FAILED//   kotlin.NotImplementedError: An operation is not implemented // Step 4: Implement minimal code (GREEN)fun validateEmail(email: String): Result<String> {    if (email.isBlank()) return Result.failure(IllegalArgumentException("Email cannot be blank"))    if ('@' !in email) return Result.failure(IllegalArgumentException("Email must contain @"))    val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")    if (!regex.matches(email)) return Result.failure(IllegalArgumentException("Invalid email format"))    return Result.success(email)} // Step 5: Run tests - verify PASS// $ ./gradlew test// EmailValidatorTest > valid email returns success PASSED// EmailValidatorTest > empty email returns failure PASSED// EmailValidatorTest > email without @ returns failure PASSED // Step 6: Refactor if needed, verify tests still pass``` ### Kotest Spec Styles #### StringSpec (Simplest) ```kotlinclass CalculatorTest : StringSpec({    "add two positive numbers" {        Calculator.add(2, 3) shouldBe 5    }     "add negative numbers" {        Calculator.add(-1, -2) shouldBe -3    }     "add zero" {        Calculator.add(0, 5) shouldBe 5    }})``` #### FunSpec (JUnit-like) ```kotlinclass UserServiceTest : FunSpec({    val repository = mockk<UserRepository>()    val service = UserService(repository)     test("getUser returns user when found") {        val expected = User(id = "1", name = "Alice")        coEvery { repository.findById("1") } returns expected         val result = service.getUser("1")         result shouldBe expected    }     test("getUser throws when not found") {        coEvery { repository.findById("999") } returns null         shouldThrow<UserNotFoundException> {            service.getUser("999")        }    }})``` #### BehaviorSpec (BDD Style) ```kotlinclass OrderServiceTest : BehaviorSpec({    val repository = mockk<OrderRepository>()    val paymentService = mockk<PaymentService>()    val service = OrderService(repository, paymentService)     Given("a valid order request") {        val request = CreateOrderRequest(            userId = "user-1",            items = listOf(OrderItem("product-1", quantity = 2)),        )         When("the order is placed") {            coEvery { paymentService.charge(any()) } returns PaymentResult.Success            coEvery { repository.save(any()) } answers { firstArg() }             val result = service.placeOrder(request)             Then("it should return a confirmed order") {                result.status shouldBe OrderStatus.CONFIRMED            }             Then("it should charge payment") {                coVerify(exactly = 1) { paymentService.charge(any()) }            }        }         When("payment fails") {            coEvery { paymentService.charge(any()) } returns PaymentResult.Declined             Then("it should throw PaymentException") {                shouldThrow<PaymentException> {                    service.placeOrder(request)                }            }        }    }})``` #### DescribeSpec (RSpec Style) ```kotlinclass UserValidatorTest : DescribeSpec({    describe("validateUser") {        val validator = UserValidator()         context("with valid input") {            it("accepts a normal user") {                val user = CreateUserRequest("Alice", "alice@example.com")                validator.validate(user).shouldBeValid()            }        }         context("with invalid name") {            it("rejects blank name") {                val user = CreateUserRequest("", "alice@example.com")                validator.validate(user).shouldBeInvalid()            }             it("rejects name exceeding max length") {                val user = CreateUserRequest("A".repeat(256), "alice@example.com")                validator.validate(user).shouldBeInvalid()            }        }    }})``` ### Kotest Matchers #### Core Matchers ```kotlinimport io.kotest.matchers.shouldBeimport io.kotest.matchers.shouldNotBeimport io.kotest.matchers.string.*import io.kotest.matchers.collections.*import io.kotest.matchers.nulls.* // Equalityresult shouldBe expectedresult shouldNotBe unexpected // Stringsname shouldStartWith "Al"name shouldEndWith "ice"name shouldContain "lic"name shouldMatch Regex("[A-Z][a-z]+")name.shouldBeBlank() // Collectionslist shouldContain "item"list shouldHaveSize 3list.shouldBeSorted()list.shouldContainAll("a", "b", "c")list.shouldBeEmpty() // Nullsresult.shouldNotBeNull()result.shouldBeNull() // Typesresult.shouldBeInstanceOf<User>() // Numberscount shouldBeGreaterThan 0price shouldBeInRange 1.0..100.0 // ExceptionsshouldThrow<IllegalArgumentException> {    validateAge(-1)}.message shouldBe "Age must be positive" shouldNotThrow<Exception> {    validateAge(25)}``` #### Custom Matchers ```kotlinfun beActiveUser() = object : Matcher<User> {    override fun test(value: User) = MatcherResult(        value.isActive && value.lastLogin != null,        { "User ${value.id} should be active with a last login" },        { "User ${value.id} should not be active" },    )} // Usageuser should beActiveUser()``` ### MockK #### Basic Mocking ```kotlinclass UserServiceTest : FunSpec({    val repository = mockk<UserRepository>()    val logger = mockk<Logger>(relaxed = true) // Relaxed: returns defaults    val service = UserService(repository, logger)     beforeTest {        clearMocks(repository, logger)    }     test("findUser delegates to repository") {        val expected = User(id = "1", name = "Alice")        every { repository.findById("1") } returns expected         val result = service.findUser("1")         result shouldBe expected        verify(exactly = 1) { repository.findById("1") }    }     test("findUser returns null for unknown id") {        every { repository.findById(any()) } returns null         val result = service.findUser("unknown")         result.shouldBeNull()    }})``` #### Coroutine Mocking ```kotlinclass AsyncUserServiceTest : FunSpec({    val repository = mockk<UserRepository>()    val service = UserService(repository)     test("getUser suspending function") {        coEvery { repository.findById("1") } returns User(id = "1", name = "Alice")         val result = service.getUser("1")         result.name shouldBe "Alice"        coVerify { repository.findById("1") }    }     test("getUser with delay") {        coEvery { repository.findById("1") } coAnswers {            delay(100) // Simulate async work            User(id = "1", name = "Alice")        }         val result = service.getUser("1")        result.name shouldBe "Alice"    }})``` #### Argument Capture ```kotlintest("save captures the user argument") {    val slot = slot<User>()    coEvery { repository.save(capture(slot)) } returns Unit     service.createUser(CreateUserRequest("Alice", "alice@example.com"))     slot.captured.name shouldBe "Alice"    slot.captured.email shouldBe "alice@example.com"    slot.captured.id.shouldNotBeNull()}``` #### Spy and Partial Mocking ```kotlintest("spy on real object") {    val realService = UserService(repository)    val spy = spyk(realService)     every { spy.generateId() } returns "fixed-id"     spy.createUser(request)     verify { spy.generateId() } // Overridden    // Other methods use real implementation}``` ### Coroutine Testing #### runTest for Suspend Functions ```kotlinimport kotlinx.coroutines.test.runTest class CoroutineServiceTest : FunSpec({    test("concurrent fetches complete together") {        runTest {            val service = DataService(testScope = this)             val result = service.fetchAllData()             result.users.shouldNotBeEmpty()            result.products.shouldNotBeEmpty()        }    }     test("timeout after delay") {        runTest {            val service = SlowService()             shouldThrow<TimeoutCancellationException> {                withTimeout(100) {                    service.slowOperation() // Takes > 100ms                }            }        }    }})``` #### Testing Flows ```kotlinimport io.kotest.matchers.collections.shouldContainInOrderimport kotlinx.coroutines.flow.MutableSharedFlowimport kotlinx.coroutines.flow.toListimport kotlinx.coroutines.launchimport kotlinx.coroutines.test.advanceTimeByimport kotlinx.coroutines.test.runTest class FlowServiceTest : FunSpec({    test("observeUsers emits updates") {        runTest {            val service = UserFlowService()             val emissions = service.observeUsers()                .take(3)                .toList()             emissions shouldHaveSize 3            emissions.last().shouldNotBeEmpty()        }    }     test("searchUsers debounces input") {        runTest {            val service = SearchService()            val queries = MutableSharedFlow<String>()             val results = mutableListOf<List<User>>()            val job = launch {                service.searchUsers(queries).collect { results.add(it) }            }             queries.emit("a")            queries.emit("ab")            queries.emit("abc") // Only this should trigger search            advanceTimeBy(500)             results shouldHaveSize 1            job.cancel()        }    }})``` #### TestDispatcher ```kotlinimport kotlinx.coroutines.test.StandardTestDispatcherimport kotlinx.coroutines.test.advanceUntilIdle class DispatcherTest : FunSpec({    test("uses test dispatcher for controlled execution") {        val dispatcher = StandardTestDispatcher()         runTest(dispatcher) {            var completed = false             launch {                delay(1000)                completed = true            }             completed shouldBe false            advanceTimeBy(1000)            completed shouldBe true        }    }})``` ### Property-Based Testing #### Kotest Property Testing ```kotlinimport io.kotest.core.spec.style.FunSpecimport io.kotest.property.Arbimport io.kotest.property.arbitrary.*import io.kotest.property.forAllimport io.kotest.property.checkAllimport kotlinx.serialization.json.Jsonimport kotlinx.serialization.encodeToStringimport kotlinx.serialization.decodeFromString // Note: The serialization roundtrip test below requires the User data class// to be annotated with @Serializable (from kotlinx.serialization). class PropertyTest : FunSpec({    test("string reverse is involutory") {        forAll<String> { s ->            s.reversed().reversed() == s        }    }     test("list sort is idempotent") {        forAll(Arb.list(Arb.int())) { list ->            list.sorted() == list.sorted().sorted()        }    }     test("serialization roundtrip preserves data") {        checkAll(Arb.bind(Arb.string(1..50), Arb.string(5..100)) { name, email ->            User(name = name, email = "$email@test.com")        }) { user ->            val json = Json.encodeToString(user)            val decoded = Json.decodeFromString<User>(json)            decoded shouldBe user        }    }})``` #### Custom Generators ```kotlinval userArb: Arb<User> = Arb.bind(    Arb.string(minSize = 1, maxSize = 50),    Arb.email(),    Arb.enum<Role>(),) { name, email, role ->    User(        id = UserId(UUID.randomUUID().toString()),        name = name,        email = Email(email),        role = role,    )} val moneyArb: Arb<Money> = Arb.bind(    Arb.long(1L..1_000_000L),    Arb.enum<Currency>(),) { amount, currency ->    Money(amount, currency)}``` ### Data-Driven Testing #### withData in Kotest ```kotlinclass ParserTest : FunSpec({    context("parsing valid dates") {        withData(            "2026-01-15" to LocalDate(2026, 1, 15),            "2026-12-31" to LocalDate(2026, 12, 31),            "2000-01-01" to LocalDate(2000, 1, 1),        ) { (input, expected) ->            parseDate(input) shouldBe expected        }    }     context("rejecting invalid dates") {        withData(            nameFn = { "rejects '$it'" },            "not-a-date",            "2026-13-01",            "2026-00-15",            "",        ) { input ->            shouldThrow<DateParseException> {                parseDate(input)            }        }    }})``` ### Test Lifecycle and Fixtures #### BeforeTest / AfterTest ```kotlinclass DatabaseTest : FunSpec({    lateinit var db: Database     beforeSpec {        db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")        transaction(db) {            SchemaUtils.create(UsersTable)        }    }     afterSpec {        transaction(db) {            SchemaUtils.drop(UsersTable)        }    }     beforeTest {        transaction(db) {            UsersTable.deleteAll()        }    }     test("insert and retrieve user") {        transaction(db) {            UsersTable.insert {                it[name] = "Alice"                it[email] = "alice@example.com"            }        }         val users = transaction(db) {            UsersTable.selectAll().map { it[UsersTable.name] }        }         users shouldContain "Alice"    }})``` #### Kotest Extensions ```kotlin// Reusable test extensionclass DatabaseExtension : BeforeSpecListener, AfterSpecListener {    lateinit var db: Database     override suspend fun beforeSpec(spec: Spec) {        db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")    }     override suspend fun afterSpec(spec: Spec) {        // cleanup    }} class UserRepositoryTest : FunSpec({    val dbExt = DatabaseExtension()    register(dbExt)     test("save and find user") {        val repo = UserRepository(dbExt.db)        // ...    }})``` ### Kover Coverage #### Gradle Configuration ```kotlin// build.gradle.ktsplugins {    id("org.jetbrains.kotlinx.kover") version "0.9.7"} kover {    reports {        total {            html { onCheck = true }            xml { onCheck = true }        }        filters {            excludes {                classes("*.generated.*", "*.config.*")            }        }        verify {            rule {                minBound(80) // Fail build below 80% coverage            }        }    }}``` #### Coverage Commands ```bash# Run tests with coverage./gradlew koverHtmlReport # Verify coverage thresholds./gradlew koverVerify # XML report for CI./gradlew koverXmlReport # View HTML report (use the command for your OS)# macOS:   open build/reports/kover/html/index.html# Linux:   xdg-open build/reports/kover/html/index.html# Windows: start build/reports/kover/html/index.html``` #### Coverage Targets | Code Type | Target ||-----------|--------|| Critical business logic | 100% || Public APIs | 90%+ || General code | 80%+ || Generated / config code | Exclude | ### Ktor testApplication Testing ```kotlinclass ApiRoutesTest : FunSpec({    test("GET /users returns list") {        testApplication {            application {                configureRouting()                configureSerialization()            }             val response = client.get("/users")             response.status shouldBe HttpStatusCode.OK            val users = response.body<List<UserResponse>>()            users.shouldNotBeEmpty()        }    }     test("POST /users creates user") {        testApplication {            application {                configureRouting()                configureSerialization()            }             val response = client.post("/users") {                contentType(ContentType.Application.Json)                setBody(CreateUserRequest("Alice", "alice@example.com"))            }             response.status shouldBe HttpStatusCode.Created        }    }})``` ### Testing Commands ```bash# Run all tests./gradlew test # Run specific test class./gradlew test --tests "com.example.UserServiceTest" # Run specific test./gradlew test --tests "com.example.UserServiceTest.getUser returns user when found" # Run with verbose output./gradlew test --info # Run with coverage./gradlew koverHtmlReport # Run detekt (static analysis)./gradlew detekt # Run ktlint (formatting check)./gradlew ktlintCheck # Continuous testing./gradlew test --continuous``` ### Best Practices **DO:**- Write tests FIRST (TDD)- Use Kotest's spec styles consistently across the project- Use MockK's `coEvery`/`coVerify` for suspend functions- Use `runTest` for coroutine testing- Test behavior, not implementation- Use property-based testing for pure functions- Use `data class` test fixtures for clarity **DON'T:**- Mix testing frameworks (pick Kotest and stick with it)- Mock data classes (use real instances)- Use `Thread.sleep()` in coroutine tests (use `advanceTimeBy`)- Skip the RED phase in TDD- Test private functions directly- Ignore flaky tests ### Integration with CI/CD ```yaml# GitHub Actions exampletest:  runs-on: ubuntu-latest  steps:    - uses: actions/checkout@v4    - uses: actions/setup-java@v4      with:        distribution: 'temurin'        java-version: '21'     - name: Run tests with coverage      run: ./gradlew test koverXmlReport     - name: Verify coverage      run: ./gradlew koverVerify     - name: Upload coverage      uses: codecov/codecov-action@v5      with:        files: build/reports/kover/report.xml        token: ${{ secrets.CODECOV_TOKEN }}``` **Remember**: Tests are documentation. They show how your Kotlin code is meant to be used. Use Kotest's expressive matchers to make tests readable and MockK for clean mocking of dependencies.