mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-11 03:43:30 +08:00
feat: add kotlin commands and skill pack
This commit is contained in:
824
skills/kotlin-testing/SKILL.md
Normal file
824
skills/kotlin-testing/SKILL.md
Normal file
@@ -0,0 +1,824 @@
|
||||
---
|
||||
name: kotlin-testing
|
||||
description: 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 test
|
||||
2. **Write a Kotest spec** — Choose a spec style (StringSpec, FunSpec, BehaviorSpec) matching the test scope
|
||||
3. **Mock dependencies** — Use MockK to isolate the unit under test
|
||||
4. **Run tests (RED)** — Verify the test fails with the expected error
|
||||
5. **Implement code (GREEN)** — Write minimal code to pass the test
|
||||
6. **Refactor** — Improve the implementation while keeping tests green
|
||||
7. **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 first
|
||||
GREEN -> Write minimal code to pass the test
|
||||
REFACTOR -> Improve code while keeping tests green
|
||||
REPEAT -> Continue with next requirement
|
||||
```
|
||||
|
||||
### Step-by-Step TDD in Kotlin
|
||||
|
||||
```kotlin
|
||||
// Step 1: Define the interface/signature
|
||||
// EmailValidator.kt
|
||||
package com.example.validator
|
||||
|
||||
fun validateEmail(email: String): Result<String> {
|
||||
TODO("not implemented")
|
||||
}
|
||||
|
||||
// Step 2: Write failing test (RED)
|
||||
// EmailValidatorTest.kt
|
||||
package com.example.validator
|
||||
|
||||
import io.kotest.core.spec.style.StringSpec
|
||||
import io.kotest.matchers.result.shouldBeFailure
|
||||
import 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)
|
||||
|
||||
```kotlin
|
||||
class 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)
|
||||
|
||||
```kotlin
|
||||
class 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)
|
||||
|
||||
```kotlin
|
||||
class 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)
|
||||
|
||||
```kotlin
|
||||
class 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
|
||||
|
||||
```kotlin
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import io.kotest.matchers.string.*
|
||||
import io.kotest.matchers.collections.*
|
||||
import io.kotest.matchers.nulls.*
|
||||
|
||||
// Equality
|
||||
result shouldBe expected
|
||||
result shouldNotBe unexpected
|
||||
|
||||
// Strings
|
||||
name shouldStartWith "Al"
|
||||
name shouldEndWith "ice"
|
||||
name shouldContain "lic"
|
||||
name shouldMatch Regex("[A-Z][a-z]+")
|
||||
name.shouldBeBlank()
|
||||
|
||||
// Collections
|
||||
list shouldContain "item"
|
||||
list shouldHaveSize 3
|
||||
list.shouldBeSorted()
|
||||
list.shouldContainAll("a", "b", "c")
|
||||
list.shouldBeEmpty()
|
||||
|
||||
// Nulls
|
||||
result.shouldNotBeNull()
|
||||
result.shouldBeNull()
|
||||
|
||||
// Types
|
||||
result.shouldBeInstanceOf<User>()
|
||||
|
||||
// Numbers
|
||||
count shouldBeGreaterThan 0
|
||||
price shouldBeInRange 1.0..100.0
|
||||
|
||||
// Exceptions
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
validateAge(-1)
|
||||
}.message shouldBe "Age must be positive"
|
||||
|
||||
shouldNotThrow<Exception> {
|
||||
validateAge(25)
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Matchers
|
||||
|
||||
```kotlin
|
||||
fun 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" },
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
user should beActiveUser()
|
||||
```
|
||||
|
||||
## MockK
|
||||
|
||||
### Basic Mocking
|
||||
|
||||
```kotlin
|
||||
class 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
|
||||
|
||||
```kotlin
|
||||
class 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
|
||||
|
||||
```kotlin
|
||||
test("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
|
||||
|
||||
```kotlin
|
||||
test("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
|
||||
|
||||
```kotlin
|
||||
import 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
|
||||
|
||||
```kotlin
|
||||
import io.kotest.matchers.collections.shouldContainInOrder
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import 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
|
||||
|
||||
```kotlin
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import 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
|
||||
|
||||
```kotlin
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.property.Arb
|
||||
import io.kotest.property.arbitrary.*
|
||||
import io.kotest.property.forAll
|
||||
import io.kotest.property.checkAll
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.encodeToString
|
||||
import 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
|
||||
|
||||
```kotlin
|
||||
val 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
|
||||
|
||||
```kotlin
|
||||
class 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
|
||||
|
||||
```kotlin
|
||||
class 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 extension
|
||||
class 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.kts
|
||||
plugins {
|
||||
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
|
||||
|
||||
```kotlin
|
||||
class 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 example
|
||||
test:
|
||||
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.
|
||||
Reference in New Issue
Block a user