Use when implementing any feature or bugfix, before writing implementation code
npx claudepluginhub kitbetter-web/muzlive-claude-code-plugin --plugin muzlive-android-pluginThis skill uses the workspace's default tool permissions.
테스트를 먼저 작성한다. 실패를 확인한다. 통과하는 최소한의 코드를 작성한다.
Enforces strict TDD workflow: write failing tests first, add minimal code to pass, refactor. For new features, bug fixes, refactors before production code.
Guides Test-Driven Development (TDD) for new features, bug fixes, refactors: write failing test first, minimal passing code, then refactor. Enforces Red-Green-Refactor cycle.
Guides Next.js Cache Components and Partial Prerendering (PPR): 'use cache' directives, cacheLife(), cacheTag(), revalidateTag() for caching, invalidation, static/dynamic optimization. Auto-activates on cacheComponents: true.
Share bugs, ideas, or general feedback.
테스트를 먼저 작성한다. 실패를 확인한다. 통과하는 최소한의 코드를 작성한다.
핵심 원칙: 테스트가 실패하는 것을 보지 않았다면, 올바른 것을 테스트하는지 알 수 없다.
규칙의 문자를 어기는 것은 규칙의 정신을 어기는 것이다.
항상:
예외 (파트너에게 먼저 확인):
"이번 한 번만 TDD를 건너뛰자"는 생각이 든다면? 멈춰라. 그건 합리화다.
TDD는 Plan Mode 산출물(PLAN.md)을 입력으로 받아 시작한다.
## 테스트 목록
### LoginViewModel
- [ ] 로그인 성공 시 LoggedIn 상태로 전환
- [ ] 잘못된 비밀번호 시 Error 상태 + 메시지 표시
- [ ] 로그인 중 로딩 상태 표시
### LoginUseCase
- [ ] 이메일이 비어있으면 ValidationError 반환
- [ ] 유효한 입력 시 Repository.login() 호출
- [x]로 체크Plan Mode를 먼저 실행해 테스트 목록을 확정한다. 목록 없이 TDD를 시작하면 방향을 잃는다.
실패하는 테스트 없이는 프로덕션 코드를 작성하지 않는다
테스트보다 코드를 먼저 작성했는가? 삭제하고 처음부터 시작한다.
예외 없음:
테스트로부터 새로 구현한다. 끝.
TDD는 유닛/통합 레벨에 집중한다. UI 테스트와 E2E는 TDD 범위 밖이다.
[E2E] ← TDD 범위 밖. 실기기 + 백엔드 필요. 소수만 유지.
[UI Tests] ← TDD 범위 밖. 선택적 보완 (아래 참고).
[Integration] ← TDD 적용. Room, DataStore 등 (JVM 가능).
[Unit ← TDD 핵심] ← TDD 적용. 대부분의 로직은 여기서 해결.
| 레이어 | 테스트 위치 | 실행 환경 |
|---|---|---|
| ViewModel, UseCase, 유틸 | src/test/ | JVM |
| Repository (fake 구현체) | src/test/ | JVM |
| Room, DataStore | src/test/ | JVM (in-memory) |
| Context 필요한 경우 | src/test/ | JVM (Robolectric) |
기본 원칙: 가능하면 src/test/에서 해결한다. 기기가 필요한 이유가 없으면 instrumented를 쓰지 않는다.
UI 테스트와 E2E는 "동작하는가?"를 검증할 뿐, 설계를 이끌지 않는다. TDD로 커버하려 하지 말 것.
대신 아래 전략으로 선택적 보완:
| 목적 | 도구 | 비고 |
|---|---|---|
| Compose UI 동작 검증 | Robolectric + createComposeRule() | JVM에서 실행, 빠름 |
| UI 회귀 방지 | Paparazzi (Square) | 스냅샷, 기기 불필요 |
| 전체 Flow 검증 | Espresso / UI Automator | 에뮬레이터 필수, 느림 — 최소화 |
| 백엔드 포함 E2E | 별도 파이프라인 | TDD 사이클 외부에서 관리 |
테스트하기 어렵다는 신호는 플랫폼에 강결합됐다는 신호다.
TDD로 설계하면 자연스럽게 비즈니스 로직이 플랫폼 밖으로 나온다:
Fragment / Composable ← 최대한 얇게 (TDD 대상 아님)
↓
ViewModel ← 순수 Kotlin, runTest로 테스트 가능
↓
UseCase ← 순수 Kotlin, 빠른 단위 테스트
↓
Repository (interface) ← fake로 대체 가능
UI 레이어가 얇아질수록 UI 테스트의 필요성도 줄어든다.
digraph tdd_cycle {
rankdir=LR;
red [label="RED\n실패하는 테스트 작성", shape=box, style=filled, fillcolor="#ffcccc"];
verify_red [label="실패\n확인", shape=diamond];
green [label="GREEN\n최소한의 코드", shape=box, style=filled, fillcolor="#ccffcc"];
verify_green [label="통과\n확인", shape=diamond];
refactor [label="REFACTOR\n정리", shape=box, style=filled, fillcolor="#ccccff"];
next [label="다음", shape=ellipse];
red -> verify_red;
verify_red -> green [label="예"];
verify_red -> red [label="잘못된\n실패"];
green -> verify_green;
verify_green -> refactor [label="예"];
verify_green -> green [label="아니오"];
refactor -> verify_green [label="그린\n유지"];
verify_green -> next;
next -> red;
}
무엇이 일어나야 하는지를 보여주는 최소한의 테스트 하나를 작성한다.
모든 테스트에 // given, // when, // then 주석을 반드시 붙인다.
// ViewModel StateFlow 테스트
@Test
fun `로그인 성공 시 LoggedIn 상태로 전환`() = runTest {
// given
val viewModel = LoginViewModel(fakeAuthRepository)
// when
viewModel.login("user@test.com", "password")
advanceUntilIdle()
// then
assertEquals(LoginUiState.LoggedIn, viewModel.uiState.value)
}
명확한 이름, 실제 동작을 테스트, 하나의 동작만, given/when/then 구조
@Test
fun `login works`() = runTest {
val mockRepo = mockk<AuthRepository>()
coEvery { mockRepo.login(any(), any()) } returns Result.success(Unit)
viewModel.login("user", "pass")
coVerify { mockRepo.login("user", "pass") }
}
모호한 이름, mock 동작만 검증, given/when/then 주석 없음
// UseCase 단위 테스트
@Test
fun `재시도 실패 작업을 3번 반복`() = runTest {
// given
var attempts = 0
val operation: suspend () -> String = {
attempts++
if (attempts < 3) throw IOException("fail")
"success"
}
// when
val result = retryOperation(operation)
// then
assertEquals("success", result)
assertEquals(3, attempts)
}
조건:
// given, // when, // then 주석 필수필수. 절대 건너뛰지 않는다.
# 유닛 테스트 (모듈명 포함)
./gradlew :feature:login:testDebugUnitTest --tests "*.LoginViewModelTest.로그인 성공 시 LoggedIn 상태로 전환"
# 전체 모듈 유닛 테스트
./gradlew :feature:login:testDebugUnitTest
# 기기 테스트 (instrumented)
./gradlew :feature:login:connectedDebugAndroidTest
확인:
테스트가 통과한다? 기존 동작을 테스트하고 있는 것이다. 테스트를 수정한다.
테스트가 에러를 낸다? 에러를 고치고, 올바르게 실패할 때까지 다시 실행한다.
테스트를 통과하는 가장 단순한 코드를 작성한다.
fun <T> retryOperation(fn: suspend () -> T): T {
var lastException: Exception? = null
repeat(3) {
try {
return fn()
} catch (e: Exception) {
lastException = e
}
}
throw lastException!!
}
통과하기에 딱 충분한 코드
fun <T> retryOperation(
fn: suspend () -> T,
maxRetries: Int = 3,
backoffStrategy: BackoffStrategy = BackoffStrategy.LINEAR,
onRetry: ((attempt: Int) -> Unit)? = null,
retryOn: List<KClass<out Exception>> = listOf(Exception::class),
): T {
// YAGNI
}
과잉 설계
테스트 범위를 넘어 기능을 추가하거나, 다른 코드를 리팩토링하거나, "개선"하지 않는다.
필수.
./gradlew :feature:login:testDebugUnitTest --tests "*.LoginViewModelTest.로그인 성공 시 LoggedIn 상태로 전환"
확인:
테스트가 실패한다? 테스트가 아닌 코드를 수정한다.
다른 테스트가 실패한다? 지금 바로 수정한다.
그린 상태에서만:
테스트를 그린으로 유지한다. 동작을 추가하지 않는다.
다음 기능을 위한 다음 실패 테스트로 넘어간다.
| 품질 | 좋음 | 나쁨 |
|---|---|---|
| 최소성 | 한 가지. 이름에 "and"가 들어간다면? 쪼갠다. | 이메일_검증_and_도메인_확인_and_공백_처리 |
| 명확성 | 이름이 동작을 설명한다 | fun test1() |
| 의도 표현 | 원하는 API를 보여준다 | 코드가 해야 할 일을 모호하게 만든다 |
| 구조 | // given // when // then 주석이 항상 있다 | 주석 없이 코드가 뭉쳐 있다 |
모든 테스트에 반드시 세 주석을 붙인다. 예외 없다.
@Test
fun `테스트 이름`() = runTest {
// given — 사전 조건, 테스트 대상 생성, 입력값 정의
// when — 테스트할 동작 실행
// then — 결과 검증
}
주석이 없으면: given/when/then을 구분할 수 없어 테스트의 의도를 알기 어렵다.
given이 길다면: 설계가 복잡한 신호다. 헬퍼로 추출하거나 인터페이스를 단순화한다.
when이 여러 줄이라면: 동작이 하나가 아닌 것이다. 테스트를 쪼갠다.
"동작을 확인하고 나서 테스트를 작성하겠다"
코드 작성 후 테스트를 쓰면 즉시 통과한다. 즉시 통과는 아무것도 증명하지 않는다:
테스트 우선은 테스트가 실패하는 것을 보게 강제하여, 실제로 무언가를 테스트하고 있음을 증명한다.
"모든 엣지 케이스를 이미 수동으로 테스트했다"
수동 테스트는 임기응변이다. 모든 것을 테스트했다고 생각하지만:
자동화 테스트는 체계적이다. 매번 동일한 방식으로 실행된다.
"X시간의 작업을 삭제하는 건 낭비다"
매몰 비용의 오류. 시간은 이미 지나갔다. 지금 선택지:
"낭비"는 신뢰할 수 없는 코드를 유지하는 것이다. 실제 테스트 없는 동작하는 코드는 기술 부채다.
"TDD는 교조적이다. 실용적이라는 건 적응하는 것이다"
TDD는 실용적이다:
"실용적" 지름길 = 프로덕션에서 디버깅 = 더 느림.
"나중에 테스트해도 같은 목표를 달성한다 — 의식이 아닌 정신이 중요하다"
아니다. 나중 테스트는 "이게 뭘 하는가?"에 답한다. 먼저 테스트는 "이게 뭘 해야 하는가?"에 답한다.
나중 테스트는 구현에 편향되어 있다. 필요한 것이 아닌 만든 것을 테스트한다. 발견된 케이스가 아닌 기억한 엣지 케이스를 검증한다.
먼저 테스트는 구현 전에 엣지 케이스 발견을 강제한다. 나중 테스트는 모든 것을 기억했는지 검증한다 (기억하지 못했다).
30분의 나중 테스트 ≠ TDD. 커버리지는 얻지만, 테스트가 동작한다는 증명은 잃는다.
| 변명 | 현실 |
|---|---|
| "너무 단순해서 테스트할 필요 없다" | 단순한 코드도 깨진다. 테스트는 30초면 된다. |
| "나중에 테스트하겠다" | 즉시 통과하는 테스트는 아무것도 증명하지 않는다. |
| "나중 테스트도 같은 목표를 달성한다" | 나중 = "이게 뭘 하는가?", 먼저 = "이게 뭘 해야 하는가?" |
| "이미 수동으로 테스트했다" | 임기응변 ≠ 체계적. 기록 없고, 재실행 불가. |
| "X시간 삭제는 낭비다" | 매몰 비용의 오류. 검증되지 않은 코드를 유지하는 게 기술 부채다. |
| "참고용으로 남기고 테스트 먼저 작성" | 어차피 응용하게 된다. 그게 나중 테스트다. 삭제는 삭제다. |
| "먼저 탐색이 필요하다" | 좋다. 탐색 코드를 버리고, TDD로 시작한다. |
| "테스트하기 어렵다 = 설계가 불명확하다" | 테스트에 귀를 기울여라. 테스트하기 어려움 = 사용하기 어려움. |
| "TDD는 속도를 늦춘다" | TDD가 디버깅보다 빠르다. 실용적 = 테스트 우선. |
| "수동 테스트가 더 빠르다" | 수동은 엣지 케이스를 증명하지 않는다. 변경할 때마다 재테스트해야 한다. |
| "기존 코드에 테스트가 없다" | 개선하고 있는 것이다. 기존 코드에 테스트를 추가한다. |
| "UI 테스트가 없으니 TDD 못 한다" | UI는 TDD 범위 밖. ViewModel/UseCase를 TDD로 작성하면 된다. |
| "E2E가 없으면 검증이 안 된다" | E2E는 별도 전략. TDD는 유닛/통합 레벨에서 가치를 만든다. |
// given // when // then 주석 누락모두 동일한 의미: 코드를 삭제하고 TDD로 처음부터 시작한다.
버그: 빈 이메일이 허용됨
RED
@Test
fun `빈 이메일은 거부`() {
// given
val email = ""
// when
val result = submitForm(email = email)
// then
assertEquals("이메일을 입력하세요", result.error)
}
RED 확인
$ ./gradlew :feature:login:testDebugUnitTest --tests "*.FormValidatorTest.빈 이메일은 거부"
FAILED: expected: <이메일을 입력하세요> but was: <null>
GREEN
fun submitForm(email: String): FormResult {
if (email.isBlank()) {
return FormResult(error = "이메일을 입력하세요")
}
// ...
}
GREEN 확인
$ ./gradlew :feature:login:testDebugUnitTest --tests "*.FormValidatorTest.빈 이메일은 거부"
BUILD SUCCESSFUL
REFACTOR 여러 필드 검증이 필요하면 validation 로직 추출.
작업을 완료로 표시하기 전:
// given, // when, // then 주석이 있다모든 항목을 체크할 수 없다? TDD를 건너뛴 것이다. 처음부터 시작한다.
| 문제 | 해결 |
|---|---|
| 테스트 방법을 모르겠다 | 원하는 API를 먼저 작성한다. assertion을 먼저 작성한다. 파트너에게 물어본다. |
| 테스트가 너무 복잡하다 | 설계가 너무 복잡한 것이다. 인터페이스를 단순화한다. |
| 모든 것을 mock해야 한다 | 코드가 너무 결합되어 있다. 의존성 주입을 사용한다. |
| 테스트 설정이 너무 크다 | 헬퍼를 추출한다. 여전히 복잡하다면? 설계를 단순화한다. |
| Context가 필요하다 | ApplicationProvider.getApplicationContext() 또는 @RunWith(RobolectricTestRunner::class) |
| Main dispatcher 오류 | Dispatchers.setMain(StandardTestDispatcher()) → @Before에서 설정 |
| Flow 검증이 어렵다 | Turbine 라이브러리: flow.test { assertEquals(..., awaitItem()) } |
| Room DB 테스트 | Room.inMemoryDatabaseBuilder() → instrumented 불필요 |
| Hilt 의존성이 필요하다 | @HiltAndroidTest + HiltAndroidRule + @UninstallModules |
| 코루틴 타이밍 문제 | advanceUntilIdle() 또는 advanceTimeBy(ms) — Thread.sleep() 금지 |
버그를 발견했는가? 재현하는 실패 테스트를 먼저 작성한다. TDD 사이클을 따른다. 테스트가 수정을 증명하고 회귀를 방지한다.
테스트 없이 버그를 수정하지 않는다.
mock이나 테스트 유틸리티를 추가할 때, @testing-anti-patterns.md 를 읽어 흔한 함정을 피한다:
Dispatchers.Main 직접 사용Thread.sleep()으로 코루틴 대기프로덕션 코드 → 테스트가 먼저 존재했고 실패했다
그렇지 않으면 → TDD가 아니다
파트너의 허락 없이는 예외 없다.