Skip to content

Latest commit

 

History

History
121 lines (99 loc) · 5.77 KB

File metadata and controls

121 lines (99 loc) · 5.77 KB

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

파일 위치

  • 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

  • @Testorg.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에 남긴다. 예를 들어 CourseRegistrationTutorialContentTestHorizontalPager에서 swipeLeft 후 페이지가 넘어가지 않는다.

스냅샷 검증과 갱신

  • ./gradlew test는 기준 이미지와 비교하지 않는다. 비교는 ./gradlew verifyRoborazziDebug가 하고, CI는 이 태스크가 실패하면 빌드를 막는다.
  • UI를 의도적으로 바꿨다면 ./gradlew recordRoborazziDebugsrc/test/snapshots/의 기준 이미지를 갱신해 같은 PR에 커밋한다.
  • CI에서 실패하면 roborazzi-diff 아티팩트의 *_compare.png로 차이를 확인한다.

커버리지

  • ./gradlew verifyRoborazziDebug koverHtmlReport는 전체 모듈을 합친 리포트를 build/reports/kover/html/에 만든다. 모듈 하나만 볼 때는 ./gradlew :core:data:koverHtmlReport를 쓴다.
  • verifyRoborazziDebug를 빼면 안 된다. Roborazzi 테스트는 verify 모드일 때만 컴포저블을 렌더링하므로, 빼면 core:uifeature:home 수치가 낮게 나오고 CI 수치와 달라진다.
  • 커버리지는 테스트가 무엇을 덮는지 보려고 쓰는 도구다. 임계값을 걸어 CI를 실패시키지 않는다. CI는 kover-report 아티팩트로 리포트를 올린다.
  • 생성 코드(Hilt·Dagger·Room·BuildConfig·R·ComposableSingletons)와 @Preview 함수는 KoverConventionPlugin이 제외한다.

MockK

  • 동기 함수는 every { } returnsverify { }를 사용한다.
  • suspend 함수는 coEvery { } returnscoVerify { }를 사용한다.
  • 기본은 엄격 모크(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 검증

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

    viewModel.onIntent(intent)

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