본문 바로가기

안드로이드

안드로이드 테스트 UnitTest, UI Test

반응형

테스트는 코드 품질을 유지하고 리팩터링을 안전하게 만드는 가장 확실한 안전망이다. Android에서는 Unit Test → Integration Test → UI Test 순서로 레이어를 나누어 테스트 전략을 세운다. 테스트 피라미드 원칙에 따라 작성 비용이 낮고 빠른 Unit Test부터 충분히 쌓고, 비용이 큰 UI Test는 핵심 시나리오 위주로만 가져가는 것이 핵심이다.

1. 테스트 피라미드

테스트 피라미드는 아래로 내려갈수록 빠르고, 위로 갈수록 느리고 비싸다.

         ┌──────────-┐
         │  UI Test  │  ← 느리고 비쌈, 적게 작성
         ├──────────-┤
         │Integration│
         ├─────────-─┤
         │ Unit Test │  ← 빠르고 저렴, 많이 작성
         └──────────-┘

각 레이어의 역할

레이어 목적 속도 범위
Unit Test 함수/클래스 단위 로직 검증 빠름 좁음
Integration Test 모듈 간 연동·흐름 검증 중간 중간
UI Test 사용자 시나리오·화면 동작 검증 느림 넓음

실제 프로젝트에서는 Domain/Data 레이어는 Unit Test로, 화면 동작은 핵심 플로우만 UI Test로 커버하면 테스트 속도와 유지보수 비용을 모두 챙길 수 있다.

2. Unit Test

Unit Test는 JVM에서 실행되며, Android 프레임워크에 의존하지 않는 순수 Kotlin/Java 코드를 대상으로 한다. 빌드 속도가 빠르고 셋업이 가볍기 때문에 비즈니스 로직은 모두 여기서 검증한다.

2-1. 테스트 환경 설정

// build.gradle.kts
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.1")
    testImplementation("io.mockk:mockk:1.13.8")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
    testImplementation("app.cash.turbine:turbine:1.0.0")
    testImplementation("com.google.truth:truth:1.1.5")
}

tasks.withType<Test> {
    useJUnitPlatform()
}
라이브러리 용도
kotlinx-coroutines-test 코루틴 환경 제어
mockk Kotlin 친화적 mocking
Turbine Flow 테스트 (awaitItem 등)
Truth 가독성 좋은 assertion

2-2. UseCase 테스트

class GetMoviesUseCaseTest {

    private val repository: MovieRepository = mockk()
    private val useCase = GetMoviesUseCase(repository)

    @Test
    fun `returns movies from repository`() = runTest {
        // Given
        val movies = listOf(Movie(1, "Test"))
        every { repository.getMovies() } returns flowOf(movies)

        // When
        val result = useCase().first()

        // Then
        assertThat(result).isEqualTo(movies)
        verify(exactly = 1) { repository.getMovies() }
    }
}

 

runTest는 코루틴용 테스트 함수다. mockk로 의존성을 모킹하고 verify로 호출 여부를 검증하면 로직 + 협력 객체 호출을 동시에 확인할 수 있다.

2-3. ViewModel 테스트

ViewModel은 상태(State)와 비즈니스 이벤트를 다루는 핵심 레이어이므로 테스트 우선순위가 높다. Dispatchers.Main을 테스트 디스패처로 교체하고, Turbine으로 상태 스트림을 검증한다.

@OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val getMoviesUseCase: GetMoviesUseCase = mockk()

    @Test
    fun `loading state when fetching movies`() = runTest {
        // Given
        val movies = listOf(Movie(1, "Test"))
        every { getMoviesUseCase() } returns flowOf(movies)

        // When
        val viewModel = HomeViewModel(getMoviesUseCase)

        // Then
        viewModel.state.test {
            assertThat(awaitItem().isLoading).isTrue()
            assertThat(awaitItem().movies).isEqualTo(movies)
        }
    }

    @Test
    fun `error state when fetch fails`() = runTest {
        // Given
        every { getMoviesUseCase() } returns flow {
            throw IOException("Network error")
        }

        // When
        val viewModel = HomeViewModel(getMoviesUseCase)

        // Then
        viewModel.state.test {
            awaitItem() // loading
            val errorState = awaitItem()
            assertThat(errorState.error).isNotNull()
        }
    }
}

 

MainDispatcherRule은 테스트 시작 시 Dispatchers.Main을 테스트 디스패처로 교체하고, 종료 시 원복하는 Rule이다.

class MainDispatcherRule(
    private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {

    override fun starting(description: Description) {
        Dispatchers.setMain(dispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

2-4. Reducer 테스트 (MVI)

MVI 패턴에서 Reducer는 입력(State, Result)에 따라 항상 같은 출력(State)을 만드는 순수 함수이므로 테스트가 가장 쉽다. 외부 의존성이 없어서 Mock도 필요 없다.

class HomeReducerTest {

    private val initialState = HomeState()

    @Test
    fun `Loading result sets isLoading true`() {
        val newState = reduce(initialState, HomeResult.Loading)

        assertThat(newState.isLoading).isTrue()
        assertThat(newState.error).isNull()
    }

    @Test
    fun `MoviesLoaded result updates movies`() {
        val movies = listOf(Movie(1, "Test"))
        val state = initialState.copy(isLoading = true)

        val newState = reduce(state, HomeResult.MoviesLoaded(movies))

        assertThat(newState.isLoading).isFalse()
        assertThat(newState.movies).isEqualTo(movies)
    }

    @Test
    fun `Error result preserves existing movies`() {
        val movies = listOf(Movie(1, "Test"))
        val state = initialState.copy(movies = movies, isLoading = true)

        val newState = reduce(state, HomeResult.Error("Error"))

        assertThat(newState.movies).isEqualTo(movies)
        assertThat(newState.error).isEqualTo("Error")
    }
}

 

Reducer를 분리하면 상태 전이(State transition)를 빠르게 검증할 수 있어 장기적으로 유지보수성이 올라간다.

3. Repository 테스트

Repository 테스트의 목적은 두 가지다.

  1. API/DB를 올바르게 호출하는지
  2. 응답을 도메인 모델로 올바르게 매핑하는지

3-1. Fake Repository

테스트에서 실제 네트워크나 DB를 매번 연결하면 느리고 불안정하다. Fake 구현체를 만들어 실제 동작을 단순화한 버전으로 교체하는 것이 좋다.

class FakeMovieRepository : MovieRepository {

    private val movies = mutableListOf<Movie>()
    var shouldThrowError = false

    override fun getMovies(): Flow<List<Movie>> = flow {
        if (shouldThrowError) throw IOException("Network error")
        emit(movies.toList())
    }

    fun addMovie(movie: Movie) {
        movies.add(movie)
    }
}

Fake는 동작이 명확하고 재사용성이 좋아서 여러 테스트에서 공통으로 쓰기 좋다. 복잡한 행위를 표현할 때는 Mock보다 가독성이 좋은 경우가 많다.

3-2. API 응답 매핑 테스트

class MovieRepositoryImplTest {

    private val api: MovieApi = mockk()
    private val repository = MovieRepositoryImpl(api, UnconfinedTestDispatcher())

    @Test
    fun `maps API response to domain model`() = runTest {
        // Given
        val dto = MovieDto(id = 1, title = "Test", voteAverage = 8.5)
        coEvery { api.getPopularMovies() } returns MovieResponseDto(results = listOf(dto))

        // When
        val result = repository.getPopularMovies().first()

        // Then
        assertThat(result).hasSize(1)
        assertThat(result[0].title).isEqualTo("Test")
        assertThat(result[0].voteAverage).isEqualTo(8.5)
    }
}

 

Repository 층에서 매핑 로직을 확실히 테스트해두면 API 응답 스펙이 바뀌었을 때 빠르게 감지할 수 있고, ViewModel이나 UI 테스트가 불필요하게 깨지는 것도 줄일 수 있다.

4. UI Test (Compose)

Compose UI Test는 androidTest 디렉터리에서 실행되며, 실제 디바이스 또는 에뮬레이터 위에서 사용자 관점의 플로우를 검증한다. 작성 비용이 크므로 핵심 시나리오 위주로 선별해서 작성한다.

4-1. 테스트 환경 설정

// build.gradle.kts
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")

4-2. 기본 UI 테스트 예시

class HomeScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun shows_loading_indicator() {
        composeTestRule.setContent {
            HomeContent(
                state = HomeState(isLoading = true),
                onIntent = {}
            )
        }

        composeTestRule
            .onNodeWithTag("loading_indicator")
            .assertIsDisplayed()
    }

    @Test
    fun shows_movie_list() {
        val movies = listOf(Movie(1, "Test Movie"))

        composeTestRule.setContent {
            HomeContent(
                state = HomeState(movies = movies),
                onIntent = {}
            )
        }

        composeTestRule
            .onNodeWithText("Test Movie")
            .assertIsDisplayed()
    }

    @Test
    fun clicks_movie_dispatches_intent() {
        var clickedId: Long? = null
        val movies = listOf(Movie(1, "Test Movie"))

        composeTestRule.setContent {
            HomeContent(
                state = HomeState(movies = movies),
                onIntent = { intent ->
                    if (intent is HomeIntent.MovieClicked) {
                        clickedId = intent.movieId
                    }
                }
            )
        }

        composeTestRule
            .onNodeWithText("Test Movie")
            .performClick()

        assertThat(clickedId).isEqualTo(1)
    }
}

4-3. TestTag 활용

@Composable
fun LoadingView(modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .fillMaxSize()
            .testTag("loading_indicator")
    ) {
        CircularProgressIndicator()
    }
}

 

testTag를 달아두면 UI 구조나 텍스트가 바뀌더라도 태그 이름으로 노드를 안정적으로 찾을 수 있다. 중요한 컴포넌트마다 TestTag를 일관되게 부여해두면 UI Test 작성과 유지가 훨씬 편해진다.

5. 테스트 패턴

5-1. Given-When-Then

테스트 코드는 "무엇을 테스트하는지"가 한눈에 보이는 것이 중요하다. Given-When-Then 패턴은 준비-실행-검증 단계를 명확하게 나눈다.

@Test
fun `toggleFavorite sets isFavorite true`() {
    // Given - 테스트 준비
    val movie = Movie(1, "Test")

    // When - 동작 실행
    val result = movie.toggleFavorite()

    // Then - 결과 검증
    assertThat(result.isFavorite).isTrue()
}

5-2. 테스트 네이밍

테스트 이름만 읽어도 어떤 동작을 검증하는지 이해되면 테스트 실패 시 디버깅 속도가 빨라진다. 백틱(`)을 활용하면 문장처럼 이름을 쓸 수 있어 가독성이 좋다.

@Test
fun `returns empty list when no movies found`() { }

@Test
fun `throws exception when network unavailable`() { }

@Test
fun `updates state with movies on successful load`() { }

5-3. 테스트 데이터 빌더

테스트에서 매번 긴 생성자를 호출하면 코드가 지저분해지고 변경에 취약해진다. 기본값이 있는 빌더 함수를 만들어두면 훨씬 깔끔하다.

object TestData {
    fun movie(
        id: Long = 1,
        title: String = "Test Movie",
        voteAverage: Double = 8.0,
        isFavorite: Boolean = false
    ) = Movie(
        id = id,
        title = title,
        overview = "Test overview",
        posterPath = null,
        backdropPath = null,
        releaseDate = LocalDate.now(),
        voteAverage = voteAverage,
        voteCount = 100,
        isFavorite = isFavorite
    )
}

// 사용 예시
val movie = TestData.movie(title = "Custom Title")

 

공통 테스트 데이터를 모아두면 새 필드가 추가되더라도 한 곳만 수정하면 되어 유지보수가 수월해진다.

6. 테스트 커버리지

6-1. JaCoCo 설정

커버리지는 "어디까지 테스트가 닿았는지"를 수치로 확인할 수 있는 지표다. JaCoCo를 사용하면 시각화된 커버리지 레포트를 확인할 수 있다.

// build.gradle.kts
plugins {
    id("jacoco")
}

tasks.withType<Test> {
    finalizedBy(tasks.jacocoTestReport)
}

tasks.jacocoTestReport {
    reports {
        xml.required.set(true)
        html.required.set(true)
    }
}

6-2. 커버리지 목표 설정

커버리지는 숫자를 맞추기 위한 목표가 아니다. 리스크가 큰 영역이 테스트로 보호되고 있는지를 판단하기 위한 보조 지표다.

레이어 커버리지 기준
Domain (UseCase) 모든 분기와 예외 케이스 검증
Data (Repository) 외부 의존성 호출과 매핑 로직 검증
ViewModel 주요 상태 전이와 에러 흐름 검증
UI 핵심 사용자 시나리오만 검증

중요한 것은 수치가 아니라 테스트가 프로젝트의 의도를 대신 설명해주는가이다.

7. CI에서 테스트 실행

로컬에서만 테스트를 돌리면 사람이 깜빡해서 테스트 없이 머지되는 순간이 생긴다. CI에서 테스트를 자동 실행하도록 설정하면 "테스트가 통과되지 않으면 머지 불가"라는 기본 안전망을 만들 수 있다.

# .github/workflows/ci.yml

- name: Run Unit Tests
  run: ./gradlew testDebugUnitTest

- name: Run Instrumented Tests
  uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: 34
    script: ./gradlew connectedDebugAndroidTest

 

정리

  • 테스트 피라미드 기준으로 Unit Test를 많이, UI Test는 핵심 시나리오만 작성한다
  • runTest + Turbine 조합으로 코루틴/Flow를 안정적으로 테스트한다
  • MVI의 Reducer처럼 순수 함수는 Mock 없이도 쉽게 테스트할 수 있다
  • Compose UI Test에서는 TestTag를 적극 활용해 노드를 안정적으로 찾는다
  • Given-When-Then 패턴과 읽기 좋은 테스트 네이밍으로 테스트 자체를 문서처럼 만든다
  • JaCoCo로 커버리지를 확인하되 중요한 도메인 로직을 우선 커버한다
  • CI에 테스트 실행을 연결해 "테스트 실패 = 머지 불가"라는 기본 안전망을 만든다

테스트를 처음부터 완벽하게 갖추려 하기보다 가장 자주 깨지는 부분 하나를 골라 테스트를 추가하는 것부터 시작하면 된다. 안전망의 효율을 경험하고 나면 자연스럽게 테스트 범위를 넓혀가게 된다.

샘플코드

참고

반응형