Kotest, MockK, coroutine testi, property-based testing ve Kover coverage ile Kotlin test kalıpları. İdiomatic Kotlin uygulamalarıyla TDD metodolojisini takip eder.
Kotest ve MockK ile TDD metodolojisini takip ederek güvenilir, sürdürülebilir testler yazmak için kapsamlı Kotlin test kalıpları.
./gradlew koverHtmlReport çalıştır ve %80+ coverage'ı doğrulaRED -> Önce başarısız bir test yaz
GREEN -> Testi geçmek için minimal kod yaz
REFACTOR -> Testleri yeşil tutarken kodu iyileştir
REPEAT -> Sonraki gereksinimle devam et
// Adım 1: Interface/signature tanımla
// EmailValidator.kt
package com.example.validator
fun validateEmail(email: String): Result<String> {
TODO("not implemented")
}
// Adım 2: Başarısız test yaz (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("[email protected]").shouldBeSuccess("[email protected]")
}
"empty email returns failure" {
validateEmail("").shouldBeFailure()
}
"email without @ returns failure" {
validateEmail("userexample.com").shouldBeFailure()
}
})
// Adım 3: Testleri çalıştır - FAIL doğrula
// $ ./gradlew test
// EmailValidatorTest > valid email returns success FAILED
// kotlin.NotImplementedError: An operation is not implemented
// Adım 4: Minimal kodu uygula (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)
}
// Adım 5: Testleri çalıştır - PASS doğrula
// $ ./gradlew test
// EmailValidatorTest > valid email returns success PASSED
// EmailValidatorTest > empty email returns failure PASSED
// EmailValidatorTest > email without @ returns failure PASSED
// Adım 6: Gerekirse refactor et, testlerin hala geçtiğini doğrula
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
}
})
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")
}
}
})
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)
}
}
}
}
})
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.*
// Eşitlik
result shouldBe expected
result shouldNotBe unexpected
// String'ler
name shouldStartWith "Al"
name shouldEndWith "ice"
name shouldContain "lic"
name shouldMatch Regex("[A-Z][a-z]+")
name.shouldBeBlank()
// Koleksiyonlar
list shouldContain "item"
list shouldHaveSize 3
list.shouldBeSorted()
list.shouldContainAll("a", "b", "c")
list.shouldBeEmpty()
// Null'lar
result.shouldNotBeNull()
result.shouldBeNull()
// Tipler
result.shouldBeInstanceOf<User>()
// Sayılar
count shouldBeGreaterThan 0
price shouldBeInRange 1.0..100.0
// Exception'lar
shouldThrow<IllegalArgumentException> {
validateAge(-1)
}.message shouldBe "Age must be positive"
shouldNotThrow<Exception> {
validateAge(25)
}
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val logger = mockk<Logger>(relaxed = true) // Relaxed: varsayılanları döndürür
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()
}
})
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) // Async çalışmayı simüle et
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
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() // > 100ms sürer
}
}
}
}
})
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") // Sadece bu aramayı tetiklemeli
advanceTimeBy(500)
results shouldHaveSize 1
job.cancel()
}
}
})
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
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 protected]")
}) { user ->
val json = Json.encodeToString(user)
val decoded = Json.decodeFromString<User>(json)
decoded shouldBe user
}
}
})
// 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) // %80 coverage'ın altında build başarısız
}
}
}
}
# Testleri coverage ile çalıştır
./gradlew koverHtmlReport
# Coverage eşiklerini doğrula
./gradlew koverVerify
# CI için XML raporu
./gradlew koverXmlReport
# HTML raporunu görüntüle (OS'nize göre komutu kullanın)
# 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
| Kod Tipi | Hedef |
|---|---|
| Kritik business mantığı | %100 |
| Public API'ler | %90+ |
| Genel kod | %80+ |
| Generated / config kodu | Hariç tut |
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", "[email protected]"))
}
response.status shouldBe HttpStatusCode.Created
}
}
})
# Tüm testleri çalıştır
./gradlew test
# Belirli test class'ını çalıştır
./gradlew test --tests "com.example.UserServiceTest"
# Belirli testi çalıştır
./gradlew test --tests "com.example.UserServiceTest.getUser returns user when found"
# Verbose çıktı ile çalıştır
./gradlew test --info
# Coverage ile çalıştır
./gradlew koverHtmlReport
# Detekt çalıştır (statik analiz)
./gradlew detekt
# Ktlint çalıştır (formatlama kontrolü)
./gradlew ktlintCheck
# Sürekli test
./gradlew test --continuous
YAPILMASI GEREKENLER:
coEvery/coVerify'ını kullanrunTest kullandata class test fixture'ları kullanYAPILMAMASI GEREKENLER:
Thread.sleep() kullanma (advanceTimeBy kullan)# GitHub Actions örneği