npx claudepluginhub sam42-lab/everything-claude-code-krThis skill uses the workspace's default tool permissions.
Kotest와 MockK를 사용하여 견고하고 표현력 있는 Kotlin 애플리케이션을 구축하기 위한 포괄적인 테스트 가이드입니다.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
Kotest와 MockK를 사용하여 견고하고 표현력 있는 Kotlin 애플리케이션을 구축하기 위한 포괄적인 테스트 가이드입니다.
class UserServiceTest : FunSpec({
test("user name should not be empty") {
val user = User(id = "1", name = "Alice")
user.name.shouldNotBeEmpty()
}
context("login validation") {
test("should fail with invalid credentials") {
// 테스트 로직
}
}
})
// 기본
result shouldBe expected
result shouldNotBe null
// 컬렉션
list shouldHaveSize 3
list shouldContain "Kotlin"
list.shouldNotBeEmpty()
// 문자열
str shouldStartWith "abc"
str shouldMatch Regex("[a-z]+")
// 숫자
count shouldBeGreaterThan 0
price shouldInRange 1.0..100.0
// 예외
shouldThrow<IllegalArgumentException> {
validateAge(-1)
}.message shouldBe "Age must be positive"
shouldNotThrow<Exception> {
validateAge(25)
}
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" },
)
}
// 사용
user should beActiveUser()
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val logger = mockk<Logger>(relaxed = true) // relaxed: 기본값 반환
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 suspend 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) // 비동기 작업 시뮬레이션
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
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()
}
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() } // 오버라이드된 메서드 검증
// 다른 메서드들은 실제 구현을 사용함
}
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 이상 소요됨
}
}
}
}
})
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") // 이 시점만 검색 트리거
advanceTimeBy(500)
results shouldHaveSize 1
job.cancel()
}
}
})
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
}
}
})
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
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
}
}
})
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)
}
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)
}
}
}
})
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"
}
})
// 재사용 가능한 테스트 확장
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) {
// 정리 작업
}
}
class UserRepositoryTest : FunSpec({
val dbExt = DatabaseExtension()
register(dbExt)
test("save and find user") {
val repo = UserRepository(dbExt.db)
// ...
}
})
// 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% 미만 시 빌드 실패
}
}
}
}
# 테스트 실행 및 커버리지 보고서 생성
./gradlew koverHtmlReport
# 커버리지 임계값 검증
./gradlew koverVerify
# CI용 XML 보고서 생성
./gradlew koverXmlReport
| 코드 유형 | 목표 |
|---|---|
| 핵심 비즈니스 로직 | 100% |
| 퍼블릭 API | 90%+ |
| 일반 코드 | 80%+ |
| 자동 생성 / 설정 코드 | 제외 |
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
}
}
})
# 모든 테스트 실행
./gradlew test
# 특정 테스트 클래스 실행
./gradlew test --tests "com.example.UserServiceTest"
# 특정 테스트 실행
./gradlew test --tests "com.example.UserServiceTest.getUser returns user when found"
# 상세 출력과 함께 실행
./gradlew test --info
# 정적 분석 (detekt) 실행
./gradlew detekt
# 포맷팅 체크 (ktlint) 실행
./gradlew ktlintCheck
# 연속 테스트 실행
./gradlew test --continuous
권장 사항:
coEvery/coVerify를 사용하세요runTest를 사용하세요data class 테스트 피처(fixtures)를 사용하세요피해야 할 사항:
Thread.sleep()을 사용하지 마세요 (advanceTimeBy 사용)기억하세요: 테스트는 문서입니다. Kotlin 코드가 어떻게 사용되어야 하는지 보여줍니다. Kotest의 표현력 있는 매처를 사용하여 테스트를 읽기 쉽게 만들고, MockK를 사용하여 의존성을 깔끔하게 모킹하세요.