Skip to content

Latest commit

 

History

History
134 lines (109 loc) · 6.9 KB

File metadata and controls

134 lines (109 loc) · 6.9 KB

TESTING.md — Rodi 단위 테스트 컨벤션

테스트 선택과 집행 범위

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())
}

JUnit5

  • @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 예외

  • 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이 제외한다.

MockK

  • 동기 함수는 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()
    }
}

Flow 검증

  • StateFlow와 Flow의 방출 순서는 Turbine(app.cash.turbine)으로 확인한다.
  • Compose mutableStateOf 프로퍼티는 Flow가 아니므로 호출 후 값을 직접 읽어 검증한다.
viewModel.state.test {
    assertEquals(initial, awaitItem())

    viewModel.onIntent(intent)

    assertEquals(expected, awaitItem())
    cancelAndIgnoreRemainingEvents()
}