안드로이드 유닛 테스트: 생존을 넘어 예술로 (JUnit 5, MockK, Turbine)

기능을 추가할 때마다 기존 코드가 깨질까 봐 두려워 배포 버튼을 누르기 주저한 적이 있는가? "내 컴퓨터에서는 잘 돌아가는데"라는 변명은 프로덕션 환경에서 아무런 효력이 없다. 테스트 코드는 단순한 버그 탐지기가 아니다. 그것은 엔지니어가 자신 있게 구조를 변경(Refactoring)할 수 있게 해주는 유일한 '안전장치'다. 수많은 안드로이드 프로젝트를 거치며 얻은, '숙제'가 아닌 '무기'로서의 테스트 전략을 공유한다.

안드로이드 유닛 테스트(Unit Test)란?
앱의 가장 작은 단위(함수, 클래스)가 의도대로 작동하는지 격리된 환경에서 검증하는 과정이다. 안드로이드 프레임워크(Context 등)에 의존하지 않고 JVM 위에서 실행되므로 속도가 빠르고 피드백이 즉각적이다. 최신 안드로이드 개발에서는 JUnit 5, MockK, Turbine이 표준 도구로 자리 잡았다.

1. 테스트의 본질: 건물 비계(Scaffolding)와 안전망

Analogy: 테스트 코드는 건축 현장의 '비계(Scaffolding)'와 같다. 건물이 완공되면 비계는 보이지 않지만, 비계 없이 고층 빌딩을 올릴 수는 없다. 또한, 테스트는 서커스의 '안전 그물'이다. 그물이 튼튼해야 곡예사(개발자)가 과감한 동작(리팩토링)을 시도할 수 있다.

많은 팀이 "시간이 없다"는 이유로 테스트를 건너뛴다. 하지만 테스트 없는 코드는 기술 부채(Technical Debt)의 가장 큰 원인이다. 2024~2025년의 안드로이드 테스팅 트렌드는 명확하다. "가능한 모든 로직을 순수 Kotlin으로 작성하고, JVM에서 테스트하라." 에뮬레이터를 띄워야 하는 계측 테스트(Instrumented Test)는 느리고 불안정하므로(Flaky), 꼭 필요한 UI 검증에만 최소화해야 한다.

2. 최신 기술 스택: JUnit 5와 MockK

과거의 JUnit 4와 Mockito 조합은 이제 놓아줄 때가 되었다. JUnit 5@Nested를 통한 계층적 테스트 구조를 지원하며, MockK는 코틀린의 최상위 함수, Final 클래스, Coroutines를 완벽하게 지원한다.


// build.gradle.kts (App Module)
dependencies {
    // JUnit 5 (Jupiter)
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
    // MockK (Kotlin 전용 모킹 라이브러리)
    testImplementation("io.mockk:mockk:1.13.9")
    // Coroutine Testing
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
    // Turbine (Flow 테스트용)
    testImplementation("app.cash.turbine:turbine:1.0.0")
}

실전 패턴: ViewModel 테스트

MVVM 패턴에서 ViewModel은 비즈니스 로직의 핵심이다. 여기서 가장 까다로운 부분은 비동기 처리(Coroutines)데이터 스트림(Flow) 테스트다. runTestTurbine을 사용하면 이 복잡성을 우아하게 해결할 수 있다.


// UserViewModelTest.kt
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {

    private lateinit var viewModel: UserViewModel
    private val userRepository: UserRepository = mockk() // MockK 생성

    // Dispatcher 교체를 위한 Rule (JUnit 5 Extension)
    @JvmField
    @RegisterExtension
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun `fetchUserData 성공 시 Success 상태를 방출한다`() = runTest {
        // Given (준비)
        val fakeUser = User(id = 1, name = "Dev")
        // Coroutine Mocking: coEvery 사용
        coEvery { userRepository.getUser(1) } returns Result.success(fakeUser)

        viewModel = UserViewModel(userRepository)

        // When (실행) & Then (검증) - Turbine 사용
        viewModel.uiState.test {
            // 초기 상태 확인
            assertEquals(UiState.Loading, awaitItem()) 
            
            // 액션 트리거
            viewModel.fetchUserData(1)

            // 결과 상태 확인
            val successState = awaitItem() as UiState.Success
            assertEquals("Dev", successState.user.name)
            
            cancelAndIgnoreRemainingEvents()
        }
    }
}

주의: runBlocking 대신 반드시 runTest를 사용해야 한다. runTest는 내부적으로 TestDispatcher를 사용하여 delay()를 자동으로 스킵하므로, 10초 대기 로직이 있는 테스트도 1밀리초 만에 끝난다.

3. Android 의존성 해결: Robolectric vs Hilt

단위 테스트의 적은 '안드로이드 의존성'이다. Context, SharedPreferences 등을 테스트해야 할 때 선택지는 두 가지다.

Robolectric: 에뮬레이터 없는 안드로이드

Robolectric은 실제 기기 없이 JVM 위에서 안드로이드 프레임워크의 동작을 시뮬레이션한다. 계측 테스트보다 10배 이상 빠르다.


@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34]) // 특정 SDK 버전 명시 가능
class LocalStorageTest {
    @Test
    fun `SharedPreference에 데이터가 저장된다`() {
        val context = RuntimeEnvironment.getApplication()
        val prefs = context.getSharedPreferences("test_pref", Context.MODE_PRIVATE)
        
        prefs.edit().putString("KEY", "VALUE").commit()
        
        assertEquals("VALUE", prefs.getString("KEY", ""))
    }
}

Hilt 의존성 주입 테스트

DI(Hilt)를 사용 중이라면 @HiltAndroidTest를 통해 테스트용 모듈을 갈아끼울 수 있다. 이는 통합 테스트에 가깝지만 매우 강력하다.


@HiltAndroidTest
@UninstallModules(NetworkModule::class) // 실제 네트워크 모듈 해제
class MainIntegrationTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @BindValue // 테스트용 가짜 구현체 주입
    @JvmField
    val fakeRepo: UserRepository = FakeUserRepository()

    @Test
    fun `의존성이 주입된 상태로 동작 확인`() {
        hiltRule.inject()
        // ... 테스트 로직
    }
}

자주 묻는 질문 (FAQ)

Q. Private 메서드는 어떻게 테스트하나요?

A. 테스트하지 않는 것이 원칙이다. Private 메서드는 구현 세부 사항(Implementation Detail)에 해당한다. 이를 억지로 테스트하면 리팩토링 시 테스트가 깨지기 쉽다. 대신 해당 Private 메서드를 사용하는 Public 메서드의 '동작(Behavior)'을 테스트해야 한다. 만약 Private 메서드가 너무 복잡해서 테스트하고 싶다면, 별도의 클래스로 분리해야 한다는 신호다.

Q. MockK와 Mockito 중 무엇을 써야 하나요?

A. Kotlin 프로젝트라면 무조건 MockK를 추천한다. Mockito는 자바 기반이라 Kotlin의 final 클래스, object 싱글톤, 코루틴 등을 테스트하려면 복잡한 설정이 필요하다. 반면 MockK는 코틀린을 위해 만들어졌으며 coEvery, coVerify 등 코루틴 지원이 네이티브 수준이다.

Q. 테스트 커버리지(Coverage)는 몇 %가 좋은가요?

A. 숫자에 집착하지 마라. 100% 커버리지는 불필요한 비용을 초래한다. 일반적으로 70~80%를 목표로 하되, 비즈니스 로직(Domain Layer)과 유틸리티는 철저히 검증하고, 단순한 UI 바인딩 코드는 제외하는 것이 효율적이다.

Q. 로컬 유닛 테스트와 계측 테스트(Instrumented Test)의 차이는?

A. 로컬 유닛 테스트는 JVM(PC)에서 돌며 빠르지만 안드로이드 API를 모킹해야 한다. 계측 테스트는 실제 기기나 에뮬레이터에서 돌며 느리지만 실제 환경을 보장한다. 구글의 권장 비율은 로컬 70%, 통합 20%, UI(계측) 10%이다.

Post a Comment