UseCase 길이보다 validation/mapping/retry/ordering/cancellation/Result/business 계약을 본다. pure forwarding의 호출만 복제하는 테스트는 항상 추가할 필요가 없다. 공통 wrapper 계약은 공통 테스트로 검증할 수 있다. 기존 테스트는 이 원칙만으로 삭제하지 않는다.
- 현재 CI: workflow에 연결된 build/lint/test/Roborazzi task와 convention BLOCK을 확인한다.
- 수동 요구: convention WARN 비증가는 작업 전후 합계를 비교한다. 현재 script는 WARN을 출력하며 자동 실패시키지 않는다.
- 향후 후보: strict UseCase 경계 검사와 WARN baseline 자동화. 현재 구현됐다고 보고하지 않는다.
- 커밋 직전 최종 Gradle 검증은
--rerun-tasks로 UP-TO-DATE 캐시 재사용을 배제한다. 작업 중 반복 확인 빌드에는 강제하지 않는다. - 문서·Skill만 수정하면 해당 링크/metadata/behavior 검증을 수행한다. production 검증과 혼동하지 않는다.
- configuration change, collector restart, entry recreation, process death의 증거를 구분한다.
src/main/kotlin/...에 있는 JVM 모듈 소스(core:domain,core:common)는src/test/kotlin/...에 둔다.src/main/java/...에 있는 Android 모듈 소스(app,core:data,core:ui,feature:*전부)는src/test/java/...에 둔다.- 패키지 경로는 대상 클래스와 동일하게 미러링한다.
- Compose UI 테스트(
createComposeRule)도src/androidTest가 아니라src/test에 두고 Robolectric으로 실행한다. CI에는 에뮬레이터가 없어서 androidTest는 실행되지 않기 때문이다. 아래 "Robolectric 예외"를 따른다.
- 파일명은
<대상클래스>Test.kt로 쓴다. - 테스트 함수명은 백틱으로 감싼 영어 서술형을 쓴다.
- Given/When/Then 주석은 쓰지 않고 빈 줄로 구획한다.
@Test
fun `invoke returns success when repository returns route`() = runTest {
val repository = mockk<CourseRepository>()
coEvery { repository.getRoute(course) } returns routeResult
val useCase = GetRouteUseCase(repository)
val result = useCase(course)
assertEquals(routeResult, result.getOrThrow())
}@Test는org.junit.jupiter.api.Test를 사용한다.@BeforeEach,@AfterEach도 JUnit5 패키지를 사용한다.- 예외 검증은
org.junit.jupiter.api.assertThrows를 사용한다.
@Test
fun `rethrows cancellation`() = runTest {
assertThrows<CancellationException> {
runSuspendCatching { throw CancellationException("cancelled") }
}
}- Robolectric에서 실행하는 테스트(Roborazzi 스크린샷, Compose UI 테스트)는
org.junit.jupiter.api.Test가 아니라org.junit.Test와@RunWith(AndroidJUnit4::class)를 사용하는 JUnit4 예외를 적용한다. - Robolectric이 JUnit4 러너 생태계에 묶여 있기 때문이며,
junit-vintage-engine으로 JUnit5 플랫폼과 연결한다. 모듈에는libs.bundles.robolectric.test,testRuntimeOnly(libs.junit.vintage.engine),testOptions { unitTests.isIncludeAndroidResources = true }가 필요하다. - 렌더링 환경을 고정하려고
@Config(sdk = [36], qualifiers = "w375dp-h812dp")를 붙인다. 참고:LoginContentTest.kt,LevelReviewSectionRoborazziTest.kt - 이 예외는
@RunWith(AndroidJUnit4::class)를 쓰는 Robolectric 테스트에만 적용하고, 나머지 단위 테스트는 여전히 JUnit5를 사용한다. - Robolectric에서 재현되지 않는 상호작용은
src/androidTest에 남긴다. 예를 들어CourseRegistrationTutorialContentTest는HorizontalPager에서swipeLeft후 페이지가 넘어가지 않는다.
./gradlew test는 기준 이미지와 비교하지 않는다. 비교는./gradlew verifyRoborazziDebug가 하고, CI는 이 태스크가 실패하면 빌드를 막는다.- UI를 의도적으로 바꿨다면
./gradlew recordRoborazziDebug로src/test/snapshots/의 기준 이미지를 갱신해 같은 PR에 커밋한다. - CI에서 실패하면
roborazzi-diff아티팩트의*_compare.png로 차이를 확인한다.
./gradlew verifyRoborazziDebug koverHtmlReport는 전체 모듈을 합친 리포트를build/reports/kover/html/에 만든다. 모듈 하나만 볼 때는./gradlew :core:data:koverHtmlReport를 쓴다.verifyRoborazziDebug를 빼면 안 된다. Roborazzi 테스트는 verify 모드일 때만 컴포저블을 렌더링하므로, 빼면core:ui와feature:home수치가 낮게 나오고 CI 수치와 달라진다.- 커버리지는 테스트가 무엇을 덮는지 보려고 쓰는 도구다. 임계값을 걸어 CI를 실패시키지 않는다. CI는
kover-report아티팩트로 리포트를 올린다. - 생성 코드(Hilt·Dagger·Room·
BuildConfig·R·ComposableSingletons)와@Preview함수는KoverConventionPlugin이 제외한다.
- 동기 함수는
every { } returns와verify { }를 사용한다. suspend함수는coEvery { } returns와coVerify { }를 사용한다.- 기본은 엄격 모크(
mockk<T>())다. 반환값이 테스트와 무관한 부수 의존성에만relaxed = true를 예외적으로 쓴다.
val draftRepository = mockk<CourseDraftRepository>()
val courseRepository = mockk<CourseRepository>()
every { draftRepository.observe() } returns flowOf(draft)
coEvery { courseRepository.getRoute(course) } returns route
verify(exactly = 1) { draftRepository.observe() }
coVerify(exactly = 1) { courseRepository.getRoute(course) }- suspend 코드는
kotlinx.coroutines.test.runTest안에서 실행한다. viewModelScope처럼Dispatchers.Main을 참조하는 대상은 테스트마다 Main 디스패처를 지정하고 해제한다.
@OptIn(ExperimentalCoroutinesApi::class)
class SomeViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@BeforeEach
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `does async work`() = runTest(testDispatcher) {
viewModel.doWork()
advanceUntilIdle()
}
}StateFlow와Flow의 방출 순서는 Turbine(app.cash.turbine)으로 확인한다.- Compose
mutableStateOf프로퍼티는 Flow가 아니므로 호출 후 값을 직접 읽어 검증한다.
viewModel.state.test {
assertEquals(initial, awaitItem())
viewModel.onIntent(intent)
assertEquals(expected, awaitItem())
cancelAndIgnoreRemainingEvents()
}