서론: 왜 지금 다시 유닛 테스트인가?
현대의 안드로이드 애플리케이션은 그 어느 때보다 복잡합니다. 다양한 화면 크기, 운영체제 버전, 하드웨어 사양을 지원해야 하며, 사용자들은 매끄러운 사용자 경험과 완벽한 안정성을 기대합니다. 이러한 복잡성 속에서 새로운 기능을 추가하거나 기존 코드를 리팩토링할 때, "혹시 다른 곳에서 문제가 생기지 않을까?" 하는 불안감은 모든 개발자가 한 번쯤 느껴봤을 것입니다. 이 불안감은 개발 속도를 저하시키고, 코드 변경을 주저하게 만들며, 결국 애플리케이션의 품질 저하로 이어질 수 있습니다.
이러한 문제에 대한 가장 효과적인 해답 중 하나가 바로 유닛 테스트(Unit Test)입니다. 많은 개발자들이 유닛 테스트의 중요성을 인지하면서도, 바쁜 일정이나 학습 곡선, 혹은 "안드로이드에서는 테스트하기 어렵다"는 막연한 선입견 때문에 실천에 옮기지 못하는 경우가 많습니다. 하지만 유닛 테스트는 단순히 버그를 찾는 행위를 넘어, 코드의 설계를 개선하고, 리팩토링에 대한 자신감을 부여하며, 팀의 생산성을 극대화하는 강력한 개발 도구입니다.
이 글에서는 유닛 테스트를 '해야만 하는 귀찮은 작업'이 아닌, '개발 경험을 향상시키는 핵심 역량'으로 재조명하고자 합니다. 유닛 테스트의 근본적인 철학부터 시작해, 안드로이드 환경에 맞는 실용적인 테스트 환경 구축, Mockito와 MockK 같은 핵심 도구의 깊이 있는 활용법, 그리고 코루틴과 같은 비동기 코드를 효과적으로 테스트하는 고급 기법까지, 체계적이고 깊이 있게 다룰 것입니다. 이 글을 통해 독자 여러분은 견고하고 유지보수하기 쉬운 안드로이드 애플리케이션을 만드는 데 필요한 테스트 역량을 갖추게 될 것입니다.
유닛 테스트의 본질과 철학
유닛 테스트, 단순한 버그 찾기를 넘어서
유닛 테스트를 "메서드 하나를 테스트하는 것"이라고 단순하게 정의하곤 합니다. 틀린 말은 아니지만, 이 정의는 유닛 테스트가 가진 더 넓은 의미를 담아내지 못합니다. 유닛 테스트에서 '유닛(Unit)'이란, 테스트 가능한 가장 작은 논리적 단위를 의미합니다. 이는 개별 메서드일 수도 있고, 하나의 클래스, 또는 긴밀하게 연관된 클래스들의 작은 그룹일 수도 있습니다. 중요한 것은 이 '유닛'이 애플리케이션의 다른 부분과 격리되어 독립적으로 검증될 수 있어야 한다는 점입니다.
유닛 테스트의 진정한 목적은 다음과 같습니다.
- 정확성 검증(Verification): 코드의 특정 부분이 주어진 입력에 대해 의도한 대로 정확하게 동작하는지 확인합니다. 이는 가장 기본적인 목적으로, 버그를 조기에 발견하는 데 도움을 줍니다.
- 설계 개선(Design Improvement): "테스트하기 어려운 코드는 잘못 설계된 코드일 가능성이 높다"는 말이 있습니다. 유닛 테스트를 작성하는 과정은 자연스럽게 코드의 결합도를 낮추고(Loosely Coupled) 응집도를 높이는(Highly Cohesive) 방향으로 설계를 유도합니다. 의존성이 많고 책임이 불분명한 클래스는 테스트하기 매우 어렵기 때문입니다. 테스트 가능한 코드를 작성하려는 노력 자체가 좋은 설계로 이어지는 선순환 구조를 만듭니다.
- 안전한 리팩토링(Safe Refactoring): 잘 작성된 유닛 테스트 스위트(Test Suite)는 코드의 내부 구조를 개선하는 리팩토링 작업을 할 때 강력한 안전망 역할을 합니다. 코드의 동작을 보장하는 테스트가 있다면, 개발자는 기능이 깨질 걱정 없이 과감하게 코드를 개선할 수 있습니다.
- 살아있는 문서(Living Documentation): 잘 작성된 테스트 코드는 그 자체로 해당 코드의 기능과 사용법을 설명하는 가장 정확한 문서가 됩니다. 주석이나 별도의 문서는 시간이 지나며 실제 코드와 달라질 수 있지만, 테스트 코드는 항상 실제 코드의 동작을 기반으로 하므로 최신 상태를 유지합니다. 새로운 팀원이 프로젝트에 합류했을 때, 테스트 코드를 통해 비즈니스 로직과 코드의 동작 방식을 빠르게 파악할 수 있습니다.
테스트 피라미드와 유닛 테스트의 역할
효과적인 테스트 전략을 수립하기 위해 '테스트 피라미드(Test Pyramid)'라는 개념을 이해하는 것이 중요합니다. 테스트 피라미드는 애플리케이션을 검증하는 여러 종류의 테스트를 비용과 속도에 따라 계층적으로 구성한 모델입니다.
마틴 파울러의 테스트 피라미드 모델
- 유닛 테스트 (Unit Tests): 피라미드의 가장 넓은 기반을 차지합니다. 가장 작고 고립된 단위의 코드를 테스트하며, 실행 속도가 매우 빠르고 작성 비용이 낮습니다. 외부 의존성(네트워크, 데이터베이스, 파일 시스템 등)이 없기 때문에 안정적으로 실행됩니다. 견고한 애플리케이션은 수백, 수천 개의 유닛 테스트로 구성된 탄탄한 기반을 가져야 합니다.
- 통합 테스트 (Integration Tests): 여러 개의 '유닛'이 함께 동작하는 방식을 검증합니다. 예를 들어, ViewModel이 Repository를 통해 실제 데이터베이스와 상호작용하는 것을 테스트하거나, 특정 API 클라이언트가 실제 네트워크 요청을 보내고 응답을 파싱하는 것을 검증하는 것이 통합 테스트에 해당합니다. 유닛 테스트보다 실행 속도가 느리고, 외부 환경(DB, 네트워크 등)에 의존하기 때문에 상대적으로 불안정할 수 있습니다.
- UI 테스트 (UI/E2E Tests): 피라미드의 가장 꼭대기에 위치합니다. 사용자의 관점에서 애플리케이션의 전체 흐름을 테스트합니다. 예를 들어, '사용자가 로그인 버튼을 클릭하고, 아이디와 비밀번호를 입력한 후, 홈 화면으로 이동한다'와 같은 시나리오를 검증합니다. 실제 기기나 에뮬레이터에서 실행되어야 하므로 가장 느리고, 작성 및 유지보수 비용이 높으며, 가장 불안정합니다.
테스트 피라미드가 주는 교훈은 명확합니다. 빠르고, 안정적이며, 저렴한 유닛 테스트에 가장 많이 투자하고, 느리고, 불안정하며, 비싼 UI 테스트는 최소한으로 유지해야 한다는 것입니다. 많은 프로젝트들이 피라미드가 역전된 '아이스크림 콘(Ice Cream Cone)' 안티 패턴에 빠지곤 합니다. 즉, 유닛 테스트는 거의 없고 대부분의 테스트를 수동으로 하거나 느린 UI 테스트에 의존하는 경우입니다. 이런 구조는 피드백 주기가 길어지고, 테스트가 자주 실패하며, 결국 테스트 스위트 전체를 불신하게 만드는 원인이 됩니다. 따라서 우리는 피라미드의 기반을 튼튼히 다지는 유닛 테스트에 집중해야 합니다.
안드로이드 개발에서 유닛 테스트가 어려운 이유
많은 안드로이드 개발자들이 유닛 테스트를 어렵게 느끼는 데에는 구조적인 이유가 있습니다. 전통적인 안드로이드 개발 방식은 다음과 같은 문제점을 가지고 있었습니다.
- 강한 결합(Tight Coupling): `Activity`나 `Fragment`와 같은 안드로이드 컴포넌트는 비즈니스 로직, UI 로직, 데이터 처리 로직 등 너무 많은 책임을 한 번에 가지고 있는 경우가 많았습니다. 예를 들어, `Activity` 안에서 직접 네트워크 요청을 보내고, 그 결과를 파싱해서 `TextView`에 값을 설정하는 코드는 테스트하기 매우 어렵습니다.
- 프레임워크 의존성: 안드로이드 SDK의 많은 클래스(`Context`, `SharedPreferences`, `Resources` 등)는 안드로이드 운영체제 런타임 환경에서만 제대로 동작하도록 설계되었습니다. 일반적인 개발 머신의 JVM(Java Virtual Machine)에서는 이러한 클래스를 직접 생성하거나 메서드를 호출할 수 없기 때문에, 프레임워크에 의존하는 코드는 순수한 유닛 테스트를 작성하기 어렵습니다. `android.jar` 파일은 실제 구현이 없는 `stub`으로만 이루어져 있어, 메서드를 호출하면 예외가 발생합니다.
- 정적 메서드와 싱글턴: 안드로이드 프레임워크의 일부 API는 정적(static) 메서드나 싱글턴 패턴으로 제공됩니다. 이러한 구조는 테스트 중에 가짜 객체(Mock)로 대체하기 어려워 테스트의 격리성을 해칩니다.
이러한 문제들 때문에 안드로이드에서 유닛 테스트는 불가능하거나 비효율적이라는 인식이 널리 퍼졌습니다. 하지만 현대 안드로이드 개발에서는 이러한 문제들을 해결하기 위한 다양한 아키텍처 패턴(MVVM, MVI), 라이브러리(Hilt, Koin), 그리고 테스트 도구(Mockito, MockK, Robolectric)가 등장했습니다. 이제 우리는 애플리케이션의 핵심 로직을 안드로이드 프레임워크로부터 분리하고, 이를 독립적으로 테스트할 수 있는 환경을 구축할 수 있게 되었습니다. 이어지는 장에서는 이러한 도구와 기법을 활용하여 어떻게 견고한 테스트를 작성할 수 있는지 구체적으로 살펴보겠습니다.
견고한 테스트를 위한 환경 구축과 핵심 도구
이론을 알았다면 이제 실전으로 나아갈 차례입니다. 효과적인 유닛 테스트를 위해서는 올바른 도구를 선택하고 테스트 환경을 적절히 구성하는 것이 매우 중요합니다. 이 장에서는 안드로이드 프로젝트의 테스트 환경을 구성하고, 가장 핵심적인 테스트 프레임워크와 라이브러리 사용법을 깊이 있게 다룹니다.
로컬 유닛 테스트 vs. 계측 테스트: 올바른 선택
안드로이드 스튜디오에서 새 프로젝트를 생성하면 `app/src` 폴더 아래에 `test`와 `androidTest`라는 두 개의 소스 셋(source set)이 기본적으로 만들어집니다. 이 둘의 차이를 명확히 이해하는 것이 테스트 전략의 첫걸음입니다.
-
로컬 유닛 테스트 (Local Unit Tests -
src/test):- 실행 환경: 개발용 컴퓨터의 JVM(Java Virtual Machine)에서 직접 실행됩니다.
- 장점: 안드로이드 기기나 에뮬레이터를 부팅할 필요가 없기 때문에 실행 속도가 매우 빠릅니다. 수천 개의 테스트도 수 초 내에 완료할 수 있어 빠른 피드백이 가능합니다. CI/CD 환경에서 빌드 시간을 단축하는 데 결정적인 역할을 합니다.
- 단점: 안드로이드 프레임워크 API(`Context`, `Activity` 등)에 직접 접근할 수 없습니다. 따라서 안드로이드 SDK에 의존하지 않는 순수한 비즈니스 로직(예: ViewModel, Repository, UseCase의 로직)을 테스트하는 데 적합합니다.
- 핵심 용도: 비즈니스 로직, 데이터 변환, 알고리즘, 유틸리티 클래스 등의 검증.
-
계측 테스트 (Instrumented Tests -
src/androidTest):- 실행 환경: 실제 안드로이드 기기나 에뮬레이터에서 실행됩니다. 테스트 APK가 별도로 빌드되어 타겟 앱과 함께 기기에 설치된 후 실행되는 방식입니다.
- 장점: 실제 앱이 구동되는 환경과 동일하므로, 안드로이드 프레임워크의 모든 API를 사용하고 컴포넌트의 생명주기와 상호작용하는 코드를 테스트할 수 있습니다. UI 상호작용, 데이터베이스 접근, `SharedPreferences` 읽기/쓰기 등을 테스트하는 데 사용됩니다.
- 단점: 기기나 에뮬레이터를 부팅하고 앱을 설치하는 과정이 필요해 실행 속도가 매우 느립니다. 테스트 하나를 실행하는 데 수 초에서 수십 초가 걸릴 수 있습니다. 또한 외부 환경의 영향(기기 상태, 다른 앱 등)으로 인해 테스트가 불안정해질(flaky) 가능성이 높습니다.
- 핵심 용도: UI 동작 검증(Espresso), 데이터베이스 스키마 및 마이그레이션 테스트(Room), 실제 컴포넌트 간의 통합 테스트.
테스트 피라미드 원칙에 따라, 우리는 가능한 모든 로직을 로컬 유닛 테스트로 검증하는 것을 목표로 해야 합니다. 프레임워크에 의존적인 부분은 아키텍처를 통해 최소화하고, 불가피한 경우에만 계측 테스트를 사용해야 합니다. 이 글의 나머지 부분은 주로 속도와 안정성이 뛰어난 로컬 유닛 테스트에 초점을 맞춰 진행됩니다.
JUnit 5: 현대적인 자바/코틀린 테스팅 프레임워크
JUnit은 자바 생태계의 사실상 표준 테스팅 프레임워크입니다. 안드로이드에서는 오랫동안 JUnit 4가 사용되었지만, 최근에는 모듈화되고 확장성이 뛰어난 JUnit 5(코드명 Jupiter)로 전환하는 추세입니다. JUnit 5는 테스트 작성을 더 편리하고 표현력 있게 만들어주는 다양한 기능을 제공합니다.
먼저 `build.gradle.kts` (또는 `build.gradle`) 파일에 JUnit 5 의존성을 추가해야 합니다.
// app/build.gradle.kts
dependencies {
// Core JUnit 5
testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.3")
// For parameterized tests
testImplementation("org.junit.jupiter:junit-jupiter-params:5.9.3")
}
JUnit 5 핵심 어노테이션
JUnit 5의 기본 사용법은 어노테이션을 통해 이루어집니다.
@Test: 해당 메서드가 테스트 케이스임을 나타냅니다.@BeforeEach/@AfterEach: 각@Test메서드가 실행되기 전/후에 실행될 메서드를 지정합니다. 테스트에 필요한 객체를 초기화하거나 리소스를 정리하는 데 사용됩니다.@BeforeAll/@AfterAll: 테스트 클래스의 모든 테스트가 실행되기 전/후에 딱 한 번만 실행될 정적(static) 메서드를 지정합니다.@DisplayName("테스트 설명"): 테스트 클래스나 메서드에 사람이 읽기 좋은 이름을 붙여줍니다. 테스트 실행 결과에서 가독성을 크게 높여줍니다.@Nested: 연관된 테스트들을 내부 클래스로 그룹화하여 테스트의 구조를 더 명확하게 만들 수 있습니다.@Disabled: 특정 테스트를 일시적으로 비활성화할 때 사용합니다.
더 나은 단언(Assertion)을 위한 라이브러리
테스트의 핵심은 '결과를 검증'하는 단언문에 있습니다. JUnit 5는 기본적인 `Assertions.*` 메서드(`assertEquals`, `assertTrue` 등)를 제공하지만, 더 나은 가독성과 표현력을 위해 추가적인 라이브러리를 사용하는 것이 좋습니다.
- AssertJ: `assertThat(result).isEqualTo(expected)`와 같이 자연어에 가까운 연쇄적인(fluent) API를 제공하여 테스트 코드를 훨씬 읽기 쉽게 만들어줍니다.
- Google Truth: AssertJ와 유사한 철학을 가진 라이브러리로, Google에서 개발했으며 안드로이드 생태계에서 널리 사용됩니다.
예를 들어, AssertJ를 사용한 테스트 코드는 다음과 같습니다.
// build.gradle.kts에 추가: testImplementation("org.assertj:assertj-core:3.24.2")
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class CalculatorTest {
@Test
@DisplayName("두 양수를 더하면 올바른 합을 반환해야 한다")
fun `addition of two positive numbers should return correct sum`() {
// Arrange (준비)
val calculator = Calculator()
val a = 5
val b = 10
// Act (실행)
val result = calculator.add(a, b)
// Assert (검증)
assertThat(result).isEqualTo(15)
}
}
위 코드에서 @DisplayName을 통해 테스트의 목적을 명확히 설명하고, AssertJ의 `assertThat`을 사용하여 검증 로직을 한눈에 파악하기 쉽게 작성했습니다.
의존성 분리를 위한 Mocking 심층 분석: Mockito와 MockK
앞서 언급했듯이, 유닛 테스트의 핵심은 '격리'입니다. 즉, 테스트 대상(System Under Test, SUT)을 그 의존성으로부터 분리해야 합니다. 예를 들어, `LoginViewModel`이 사용자 정보를 가져오기 위해 `UserRepository`에 의존한다고 가정해 봅시다. `LoginViewModel`을 테스트할 때 실제 `UserRepository`를 사용한다면, 이는 네트워크 통신이나 데이터베이스 접근을 유발할 수 있습니다. 이는 테스트를 느리고 불안정하게 만들며, 더 이상 순수한 유닛 테스트가 아니게 됩니다.
이때 필요한 것이 바로 **Mocking**입니다. Mocking은 테스트 대상이 의존하는 객체에 대한 가짜(mock) 객체를 만드는 기술입니다. 이 가짜 객체는 실제 객체처럼 보이지만, 우리가 원하는 대로 정확히 동작하도록 프로그래밍할 수 있습니다. 안드로이드에서는 Mockito와 MockK가 가장 널리 쓰이는 Mocking 라이브러리입니다.
Mockito: 자바 세계의 표준 Mocking 라이브러리
Mockito는 오랫동안 자바 개발자들에게 사랑받아온 강력하고 유연한 Mocking 프레임워크입니다. 코틀린에서도 사용할 수 있지만, 일부 코틀린의 언어적 특성(final 클래스, non-null 타입 등)과 함께 사용할 때 약간의 추가 설정이 필요할 수 있습니다.
build.gradle.kts 의존성 추가:
// Mockito core
testImplementation("org.mockito:mockito-core:5.5.0")
// Kotlin과 함께 Mockito를 더 쉽게 사용하기 위한 라이브러리
testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0")
다음은 `LoginViewModel`과 `UserRepository`를 이용한 Mockito 테스트 예제입니다.
// 테스트 대상 클래스
class UserRepository {
fun login(id: String, pass: String): Result<User> {
// 실제로는 네트워크 통신을 하겠지만, 여기서는 생략
throw UnsupportedOperationException("Not implemented")
}
}
class LoginViewModel(private val userRepository: UserRepository) {
fun performLogin(id: String, pass: String): Boolean {
val result = userRepository.login(id, pass)
return result.isSuccess
}
}
// 테스트 클래스
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any
import org.mockito.kotlin.whenever
@ExtendWith(MockitoExtension::class) // Mockito 어노테이션 활성화
class LoginViewModelTest {
@Mock
private lateinit var mockUserRepository: UserRepository // UserRepository의 Mock 객체 생성
@InjectMocks
private lateinit var loginViewModel: LoginViewModel // mockUserRepository를 loginViewModel에 주입
@Test
fun `performLogin with valid credentials should return true`() {
// Arrange (준비)
// mockUserRepository.login 메서드가 어떤 인자(any())로 호출되든
// 성공(Result.success)을 반환하도록 설정
whenever(mockUserRepository.login(any(), any())).thenReturn(Result.success(User("testId")))
// Act (실행)
val result = loginViewModel.performLogin("testId", "testPass")
// Assert (검증)
assertTrue(result)
}
}
위 예제에서 사용된 Mockito의 핵심 요소는 다음과 같습니다.
@ExtendWith(MockitoExtension.class): JUnit 5 환경에서@Mock,@InjectMocks같은 Mockito 어노테이션이 동작하도록 설정합니다.@Mock: 해당 필드를 Mock 객체로 초기화합니다.@InjectMocks:@Mock으로 생성된 Mock 객체들을 생성자를 통해 해당 필드의 인스턴스에 주입합니다.whenever(mockObject.method(arguments)).thenReturn(returnValue): Mock 객체의 특정 메서드가 호출될 때 어떤 값을 반환할지 정의하는 Stubbing 구문입니다.mockito-kotlin라이브러리 덕분에 `when` 대신 `whenever`를 사용하여 코틀린 키워드와의 충돌을 피할 수 있습니다.any(): Argument Matcher의 한 종류로, 어떤 값이 인자로 들어오든 상관없이 Stubbing이 동작하도록 합니다. `eq("specificValue")`처럼 특정 값과 일치할 때만 동작하도록 설정할 수도 있습니다.
MockK: 코틀린을 위한 더 나은 선택
MockK는 코틀린 언어 자체에 더 특화된 Mocking 라이브러리입니다. 코틀린의 first-class function, 확장 함수, 코루틴 등을 네이티브하게 지원하여 훨씬 더 간결하고 직관적인 문법으로 Mock을 작성할 수 있습니다.
build.gradle.kts 의존성 추가:
testImplementation("io.mockk:mockk:1.13.5")
위의 Mockito 예제를 MockK로 다시 작성해 보겠습니다.
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.junit5.MockKExtension
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
// @ExtendWith(MockKExtension::class) // 또는 init 블록에서 MockKAnnotations.init(this) 사용
class LoginViewModelMockKTest {
@MockK
private lateinit var mockUserRepository: UserRepository
// `relaxed = true`는 모든 메서드가 기본적으로 빈 값을 리턴하도록 함
@InjectMockKs(overrideValues = true)
private lateinit var loginViewModel: LoginViewModel
@BeforeEach
fun setUp() {
// @ExtendWith 대신 수동으로 초기화 할 수도 있다.
io.mockk.MockKAnnotations.init(this)
}
@Test
fun `performLogin with valid credentials should return true`() {
// Arrange
// every 블록을 사용하여 stubbing. 문법이 더 자연스럽다.
every { mockUserRepository.login(any(), any()) } returns Result.success(User("testId"))
// Act
val result = loginViewModel.performLogin("testId", "testPass")
// Assert
assertTrue(result)
// 특정 메서드가 호출되었는지 검증할 수도 있다.
io.mockk.verify { mockUserRepository.login("testId", "testPass") }
}
}
MockK의 특징은 다음과 같습니다.
@MockK,@InjectMockKs: Mockito의 어노테이션과 유사하게 동작합니다.every { ... } returns ...: Mockito의 `whenever().thenReturn()` 보다 더 코틀린스러운 DSL(Domain-Specific Language)을 제공합니다. 람다 블록 안에서 실제 메서드를 호출하는 것처럼 작성하면 됩니다.verify { ... }: 특정 상호작용이 Mock 객체와 발생했는지 검증합니다. 예를 들어, `verify(exactly = 1) { ... }` 처럼 정확한 호출 횟수를 검증할 수도 있습니다.- 코루틴 지원: `coEvery { ... } returns ...` 와 `coVerify { ... }` 를 통해 `suspend` 함수를 손쉽게 테스트할 수 있습니다. 이는 비동기 코드 테스트에서 매우 강력한 장점입니다.
코틀린을 주력으로 사용하는 프로젝트라면, MockK를 사용하는 것이 생산성과 코드 가독성 측면에서 더 나은 선택이 될 수 있습니다.
안드로이드 프레임워크 의존성 해결사: Robolectric
ViewModel이나 Repository처럼 안드로이드 프레임워크에 대한 의존성이 거의 없는 클래스는 Mocking만으로 충분히 테스트할 수 있습니다. 하지만 `Context`를 사용하여 문자열 리소스를 가져오거나, `SharedPreferences`에 데이터를 저장하는 등 프레임워크 API를 직접 사용하는 클래스는 어떻게 테스트해야 할까요? 이럴 때 사용하는 것이 Robolectric입니다.
Robolectric은 실제 안드로이드 SDK 클래스의 동작을 JVM에서 시뮬레이션하는 '그림자 클래스(Shadow Class)'를 제공하는 라이브러리입니다. 덕분에 우리는 에뮬레이터 없이도 로컬 JVM 환경에서 안드로이드 프레임워크에 의존하는 코드를 테스트할 수 있습니다.
build.gradle.kts 의존성 추가:
testImplementation("org.robolectric:robolectric:4.10.3")
Robolectric 테스트 예제:
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.myapp.R
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.junit.runner.RunWith
// JUnit 4 Runner를 사용해야 함. JUnit 5 지원은 아직 실험적
@RunWith(AndroidJUnit4::class)
class ResourceComparerTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun `isEqual should return true for same string resource`() {
val result = context.getString(R.string.app_name)
assertThat(result).isEqualTo("My Application") // 실제 strings.xml 값을 기반으로 테스트
}
}
Robolectric은 매우 강력하지만, 장점과 단점을 명확히 인지하고 사용해야 합니다.
- 장점: 계측 테스트보다 훨씬 빠르며, 프레임워크 의존적인 코드를 로컬에서 테스트할 수 있게 해줍니다.
- 단점: 순수 JVM 테스트보다는 훨씬 느립니다. Robolectric이 내부적으로 안드로이드 환경을 부트스트래핑하는 과정에 시간이 소요되기 때문입니다. 또한, 실제 기기 환경과 100% 동일하게 동작한다고 보장할 수 없습니다.
따라서 Robolectric은 최후의 수단으로 사용하는 것이 좋습니다. 먼저 아키텍처를 개선하여 프레임워크 의존성을 최대한 분리하고, 순수한 유닛 테스트와 Mocking으로 해결할 수 없는 부분에 한해서만 Robolectric을 신중하게 도입하는 것이 바람직한 전략입니다.
실전 테스트 설계와 고급 기법
좋은 도구를 갖췄다고 해서 저절로 좋은 테스트가 만들어지는 것은 아닙니다. 테스트하기 쉬운 코드를 작성하는 설계 능력과 복잡한 시나리오를 다루는 고급 테스트 기법이 필요합니다. 이 장에서는 테스트 가능한 아키텍처, 의존성 주입, 그리고 안드로이드 개발의 난제인 비동기 코드 테스트 방법을 집중적으로 다룹니다.
테스트 가능한 아키텍처 설계: MVVM과 클린 아키텍처
테스트의 용이성은 코드베이스의 아키텍처에 의해 크게 좌우됩니다. 모든 로직이 `Activity`나 `Fragment`에 집중된 거대한 클래스(Massive View Controller)는 테스트의 무덤과도 같습니다. 테스트 가능한 코드를 작성하기 위한 핵심은 관심사의 분리(Separation of Concerns)입니다. 각 컴포넌트가 명확하게 정의된 단일 책임을 갖도록 만드는 것입니다.
MVVM (Model-View-ViewModel)
Google이 권장하는 MVVM 아키텍처 패턴은 테스트 용이성을 크게 향상시킵니다.
- View (Activity/Fragment): UI 렌더링과 사용자 입력 처리에만 집중합니다. View는 ViewModel의 데이터를 관찰(Observe)하고 화면에 표시할 뿐, 어떤 비즈니스 로직도 포함하지 않습니다.
- ViewModel: View에 표시될 데이터를 준비하고 관리합니다. UI 상태를 `LiveData`나 `StateFlow` 같은 관찰 가능한 데이터 홀더에 보관하며, UI 로직(예: 버튼 활성화 여부)과 비즈니스 로직 호출을 담당합니다. ViewModel은 안드로이드 UI 프레임워크(`Context`, View 등)에 대한 참조를 갖지 않으므로, 순수한 JVM 유닛 테스트가 가능합니다.
- Model (Repository/DataSource): 데이터의 출처(네트워크, 데이터베이스, 캐시 등)를 추상화하고 애플리케이션의 비즈니스 로직을 포함합니다. ViewModel은 Repository를 통해 데이터를 요청합니다.
이 구조에서 우리의 주된 테스트 대상은 ViewModel과 Model(Repository, UseCase 등)이 됩니다. 이들은 안드로이드 프레임워크로부터 분리되어 있기 때문에, 의존성만 Mocking하면 빠르고 안정적인 로컬 유닛 테스트를 작성할 수 있습니다.
클린 아키텍처 (Clean Architecture)
클린 아키텍처는 MVVM을 더욱 발전시켜 계층을 더 세분화하고 의존성 규칙을 강제함으로써 테스트 용이성을 극대화합니다.
- Domain Layer (UseCases): 애플리케이션의 핵심 비즈니스 로직을 포함합니다. 다른 어떤 계층에도 의존하지 않는 순수한 코틀린/자바 모듈로 구성됩니다. 이 계층은 테스트하기 가장 쉬우며, 가장 철저하게 테스트되어야 합니다.
- Data Layer (Repositories): Domain Layer에서 정의한 인터페이스의 구현체를 제공합니다. 데이터 소스(네트워크, DB)를 다루는 구체적인 기술을 캡슐화합니다.
- Presentation Layer (UI): MVVM의 View, ViewModel이 이 계층에 해당합니다.
클린 아키텍처의 핵심인 '의존성 규칙'은 모든 의존성이 바깥쪽에서 안쪽으로만 향해야 한다는 것입니다. 즉, Presentation Layer는 Domain Layer에 의존할 수 있지만, Domain Layer는 Presentation Layer에 대해 아무것도 알지 못합니다. 이 규칙 덕분에 핵심 비즈니스 로직(Domain)이 외부 변화(UI, DB 기술 변경 등)에 영향을 받지 않고 독립적으로 테스트될 수 있습니다.
의존성 주입(DI)과 테스트: Hilt 활용법
테스트 가능한 아키텍처를 구현하는 가장 중요한 기술은 의존성 주입(Dependency Injection, DI)입니다. DI는 클래스가 필요로 하는 의존 객체(dependency)를 외부에서 생성하여 전달(주입)해주는 디자인 패턴입니다. 이를 통해 클래스는 의존성을 직접 생성하는 책임에서 벗어나고, 우리는 테스트 시에 실제 구현체 대신 Mock 객체를 손쉽게 주입할 수 있습니다.
안드로이드에서는 Hilt라는 DI 라이브러리가 널리 사용됩니다. Hilt는 보일러플레이트 코드를 크게 줄여주며, 테스트를 위한 강력한 기능을 내장하고 있습니다.
Hilt를 사용한 테스트의 핵심은 테스트용 모듈을 만들어 기존의 의존성을 테스트용 의존성(주로 Mock)으로 교체(replace)하는 것입니다.
`build.gradle.kts`에 Hilt 테스트 의존성을 추가합니다.
// Hilt-Android Testing
kaptTest("com.google.dagger:hilt-android-compiler:2.48")
testImplementation("com.google.dagger:hilt-android-testing:2.48")
다음은 Hilt를 사용하여 `UserRepository`를 Mock으로 교체하고 `MyViewModel`을 테스트하는 예제입니다.
// Production Code
@Module
@InstallIn(ViewModelComponent::class)
abstract class UserRepositoryModule {
@Binds
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
// Test Code
@HiltAndroidTest // Hilt 테스트임을 알림
@Config(application = HiltTestApplication::class) // 테스트용 Application 클래스 설정
@RunWith(RobolectricTestRunner::class) // Hilt는 Robolectric 또는 계측 테스트 환경이 필요
class MyViewModelHiltTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
// 테스트에서 사용할 가짜 모듈 정의
@Module
@InstallIn(ViewModelComponent::class)
object TestUserRepositoryModule {
@Provides
fun provideUserRepository(): UserRepository {
// Mock 객체를 반환하도록 설정
return mockk<UserRepository>()
}
}
@Inject
lateinit var userRepository: UserRepository // 주입된 Mock 객체
private lateinit var viewModel: MyViewModel
@Before
fun setup() {
hiltRule.inject() // Hilt가 의존성 주입을 수행하도록 함
viewModel = MyViewModel(userRepository)
}
@Test
fun `test something`() {
// Arrange
every { userRepository.fetchUser(any()) } returns User("test")
// Act & Assert ...
}
}
Hilt 테스트는 설정이 다소 복잡해 보일 수 있지만, 한번 환경을 구축하고 나면 실제 프로덕션 환경과 거의 동일한 의존성 그래프를 사용하면서 특정 부분만 Mock으로 교체할 수 있어 매우 강력한 통합 테스트 환경을 제공합니다.
비동기 코드 테스트: Coroutines와 Flow 정복
네트워크 요청, 데이터베이스 접근 등 안드로이드 앱의 대부분의 작업은 비동기적으로 이루어집니다. 비동기 코드는 테스트하기 까다롭습니다. 테스트 코드가 비동기 작업이 끝나기를 기다리지 않고 바로 종료되어 버리거나, 타이밍 문제로 인해 테스트가 간헐적으로 실패(flaky test)하는 문제가 발생할 수 있습니다.
코틀린 코루틴은 `kotlinx-coroutines-test` 라이브러리를 통해 이러한 문제를 해결할 수 있는 훌륭한 테스트 도구를 제공합니다.
`build.gradle.kts` 의존성 추가:
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")
`runTest`와 `TestDispatcher`
`kotlinx-coroutines-test` 라이브러리의 핵심은 `runTest` 빌더와 `TestDispatcher`입니다.
runTest: 테스트를 위한 CoroutineScope를 제공합니다. 이 스코프 내에서 실행되는 코루틴은 '가상 시간(virtual time)'을 사용하게 됩니다.delay()와 같은 지연 함수가 실제로 시간을 기다리지 않고 즉시 다음 코드로 점프하도록 만들어 테스트 시간을 극단적으로 단축시킵니다.TestDispatcher: 코루틴이 어떤 스레드에서 실행될지 결정하는 `CoroutineDispatcher`의 테스트용 구현체입니다. `runTest`는 내부적으로 `StandardTestDispatcher`를 사용하며, 이를 통해 코루틴의 실행 시점을 완벽하게 제어할 수 있습니다.
`Main` 디스패처를 사용하는 ViewModel을 테스트하기 위해서는 `Main` 디스패처를 테스트용 디스패처로 교체해주는 규칙(Rule)이 필요합니다.
// Main 디스패처를 테스트용으로 대체하는 JUnit 4 Rule (JUnit 5에서는 Extension으로 구현)
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
이를 활용한 ViewModel 테스트 예제는 다음과 같습니다.
class UserProfileViewModel(
private val userRepository: UserRepository,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState
fun loadUser(userId: String) {
viewModelScope.launch(dispatcher) {
try {
_uiState.value = UiState.Loading
val user = userRepository.fetchUser(userId) // suspend function
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
_uiState.value = UiState.Error("Failed to load user")
}
}
}
}
// Test Class
class UserProfileViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule() // Main 디스패처를 TestDispatcher로 교체
private lateinit var userRepository: UserRepository
private lateinit var viewModel: UserProfileViewModel
@BeforeEach
fun setUp() {
userRepository = mockk()
// ViewModel에 TestDispatcher를 직접 주입
viewModel = UserProfileViewModel(userRepository, mainDispatcherRule.testDispatcher)
}
@Test
fun `loadUser should update uiState to Success on successful fetch`() = runTest {
// Arrange
val testUser = User("testId", "John Doe")
coEvery { userRepository.fetchUser("testId") } returns testUser
// Act
viewModel.loadUser("testId")
// Assert
// runTest가 코루틴이 끝날 때까지 기다려줌
assertThat(viewModel.uiState.value).isEqualTo(UiState.Success(testUser))
}
}
위 예제에서 runTest 블록 덕분에 우리는 `delay`나 복잡한 동기화 메커니즘 없이 비동기 코드를 마치 동기 코드처럼 순차적으로 테스트할 수 있습니다. `coEvery`를 사용해 `suspend` 함수를 stubbing하고, `MainDispatcherRule`을 통해 `viewModelScope`가 사용하는 `Main` 디스패처를 제어하는 것이 핵심입니다.
테스트 가독성과 효율성 극대화
Arrange-Act-Assert (AAA) 패턴
테스트 코드의 가독성을 높이는 가장 좋은 방법 중 하나는 일관된 구조를 따르는 것입니다. AAA 패턴은 테스트를 세 부분으로 명확하게 나누어 구조화합니다.
- Arrange (준비): 테스트에 필요한 모든 사전 조건을 설정합니다. 객체를 생성하고, Mock 객체를 Stubbing하며, 테스트에 필요한 데이터를 준비하는 단계입니다.
- Act (실행): 테스트하려는 실제 동작(메서드 호출 등)을 수행합니다. 이 부분은 보통 한 줄의 코드로 이루어집니다.
- Assert (검증): 실행(Act)의 결과가 예상과 일치하는지 확인합니다. 단언문(assertion)을 사용하여 결과를 검증합니다.
주석으로 // Arrange, // Act, // Assert를 명시적으로 달아주는 것만으로도 테스트의 의도를 파악하기 훨씬 쉬워집니다.
JUnit 5의 Parameterized Tests
동일한 테스트 로직을 여러 다른 입력 값에 대해 반복적으로 실행하고 싶을 때가 있습니다. 예를 들어, 이메일 유효성을 검사하는 함수를 테스트한다면 여러 개의 유효한 이메일과 유효하지 않은 이메일 케이스를 모두 확인해야 합니다. 이때 각 케이스마다 별도의 테스트 메서드를 만드는 것은 비효율적입니다.
JUnit 5의 @ParameterizedTest를 사용하면 이 문제를 우아하게 해결할 수 있습니다.
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
class EmailValidatorTest {
private val validator = EmailValidator()
@ParameterizedTest
@ValueSource(strings = ["test@example.com", "hello.world@company.co.uk"])
fun `isValid should return true for valid emails`(email: String) {
assertThat(validator.isValid(email)).isTrue()
}
@ParameterizedTest
@CsvSource(
"test@.com, false",
"hello@world, false",
"plainaddress, false"
)
fun `isValid should return expected result for various emails`(email: String, expected: Boolean) {
assertThat(validator.isValid(email)).isEqualTo(expected)
}
}
@ValueSource, @CsvSource, @MethodSource 등 다양한 소스를 통해 테스트에 사용할 파라미터를 제공할 수 있어 코드 중복을 크게 줄이고 테스트 커버리지를 효율적으로 높일 수 있습니다.
유닛 테스트의 가치와 팀 문화
유닛 테스트는 단순히 기술적인 실천(practice)을 넘어, 소프트웨어 개발의 품질과 생산성에 대한 팀의 철학을 반영하는 문화적 요소입니다. 잘 정착된 테스트 문화는 개발자 개개인의 성장은 물론, 프로젝트 전체의 지속 가능한 발전을 이끄는 원동력이 됩니다.
단순한 이점을 넘어서는 전략적 가치
유닛 테스트의 이점은 버그 감소나 코드 안정성 향상과 같은 직접적인 효과를 넘어섭니다. 장기적인 관점에서 유닛 테스트는 다음과 같은 전략적 가치를 제공합니다.
- 개발 속도의 가속화: 단기적으로는 테스트 코드를 작성하는 시간이 추가로 소요되는 것처럼 보일 수 있습니다. 하지만 프로젝트가 복잡해질수록 수동 테스트와 디버깅에 드는 시간은 기하급수적으로 늘어납니다. 잘 구축된 자동화 테스트 스위트는 코드 변경에 대한 피드백을 수 초 내에 제공함으로써, 개발자가 자신감을 가지고 빠르게 코드를 수정하고 새로운 기능을 추가할 수 있게 해줍니다. 이는 장기적으로 전체 개발 사이클을 단축시키는 효과를 가져옵니다.
- 기술 부채 감소: 테스트 없는 코드는 시간이 지남에 따라 수정하기 어려운 '기술 부채(Technical Debt)'가 될 가능성이 높습니다. 개발자들은 기존 코드를 건드렸을 때 발생할 사이드 이펙트를 두려워하게 되고, 임시방편적인 '땜질' 코드를 추가하게 됩니다. 유닛 테스트는 코드의 동작을 보장하는 안전망 역할을 함으로써, 개발자들이 지속적으로 코드를 리팩토링하고 건강한 상태로 유지하도록 장려합니다.
- 살아있는 문서(Living Documentation)로서의 가치 심화: 테스트 코드는 '무엇을' 하는지에 대한 저수준의 문서가 될 뿐만 아니라, '왜' 그렇게 동작해야 하는지에 대한 비즈니스 요구사항을 담는 고수준의 문서 역할도 합니다. 예를 들어, `given_userIsPremium_when_downloading_then_highQualityIsAvailable()` 와 같은 테스트 이름은 그 자체로 '프리미엄 유저는 고화질 다운로드가 가능하다'는 비즈니스 규칙을 명확하게 보여줍니다.
- 설계 피드백 루프: 테스트를 먼저 작성하는 TDD(Test-Driven Development) 방법론은 이 가치를 극대화합니다. 테스트를 작성하는 행위 자체가 개발자로 하여금 해당 모듈의 API와 역할을 명확하게 정의하도록 강제합니다. "이 클래스를 어떻게 테스트하지?"라는 질문은 "이 클래스는 어떤 책임을 가져야 하는가?"라는 설계에 대한 근본적인 질문으로 이어지며, 이는 자연스럽게 더 나은 설계로 귀결됩니다.
팀에 테스트 문화 정착시키기
테스트 문화는 한두 사람의 노력만으로는 만들어지지 않습니다. 팀 전체의 동의와 꾸준한 노력이 필요합니다.
- 코드 리뷰(Code Review)에 테스트 코드 포함: Pull Request(또는 Merge Request)를 리뷰할 때, 프로덕션 코드뿐만 아니라 테스트 코드도 동일한 비중으로 꼼꼼하게 리뷰해야 합니다. 테스트 케이스가 충분한지, 테스트 이름이 명확한지, 불필요한 테스트는 없는지 등을 확인함으로써 팀 전체의 테스트 코드 품질을 상향 평준화할 수 있습니다. "테스트 없는 코드는 머지하지 않는다"는 원칙을 세우는 것도 좋은 방법입니다.
- 지속적 통합(Continuous Integration, CI) 파이프라인 구축: GitHub Actions, Jenkins, CircleCI와 같은 CI 도구를 사용하여 새로운 코드가 제출될 때마다 자동으로 모든 테스트가 실행되도록 구성해야 합니다. CI 서버에서 테스트가 실패하면 빌드 자체가 실패하도록 설정하여, 버그가 포함된 코드가 메인 브랜치에 통합되는 것을 원천적으로 차단해야 합니다. 이는 테스트 스위트를 항상 신뢰할 수 있는 상태로 유지하는 데 필수적입니다.
- 코드 커버리지(Code Coverage)의 현명한 활용: 코드 커버리지는 테스트 스위트가 프로덕션 코드의 몇 퍼센트를 실행했는지를 나타내는 지표입니다. JaCoCo와 같은 도구를 사용하여 커버리지를 측정하고 시각화할 수 있습니다. 하지만 커버리지 수치 자체를 맹신하는 것은 위험합니다. 100% 커버리지가 버그 없는 코드를 의미하지는 않으며, 의미 없는 테스트로 커버리지만 높이는 것은 오히려 해가 될 수 있습니다. 커버리지는 '테스트되지 않은 코드'를 찾아내는 도구로 활용하되, 절대적인 목표 수치로 삼기보다는 팀의 현재 상태를 진단하고 점진적으로 개선해나가는 지표로 활용하는 것이 바람직합니다. 예를 들어, "새로 작성되는 코드의 커버리지는 80% 이상을 유지한다"와 같은 점진적인 목표를 설정할 수 있습니다.
- 점진적인 도입과 교육: 기존에 테스트가 전혀 없던 대규모 프로젝트에 하루아침에 완벽한 테스트 문화를 도입하기는 어렵습니다. 새로 작성하는 기능이나 버그를 수정하는 코드에 대해서만이라도 반드시 유닛 테스트를 추가하는 것부터 시작하는 것이 현실적인 접근법입니다. 또한, 팀 내에서 정기적인 스터디나 페어 프로그래밍을 통해 테스트 작성 노하우를 공유하고 함께 성장하는 문화를 만드는 것이 중요합니다.
결론: 지속 가능한 성장을 위한 테스트 여정
지금까지 우리는 안드로이드 유닛 테스트의 철학적 배경부터 시작해, 실용적인 환경 구축 방법, 핵심 도구의 깊이 있는 사용법, 그리고 테스트 가능한 설계를 위한 아키텍처와 비동기 처리 기법까지 폭넓게 살펴보았습니다. 유닛 테스트는 단순히 코드의 결함을 찾는 부가적인 활동이 아니라, 소프트웨어의 설계를 개선하고, 유지보수 비용을 절감하며, 개발자의 생산성과 자신감을 높여주는 개발 프로세스의 핵심적인 부분입니다.
물론, 테스트 코드를 작성하는 데는 시간과 노력이 듭니다. 때로는 프로덕션 코드보다 더 많은 고민이 필요할 때도 있습니다. 하지만 그 투자는 버그를 수정하고, 예측 불가능한 사이드 이펙트를 디버깅하는 데 드는 훨씬 더 큰 비용을 절약해 줍니다. 잘 작성된 테스트 스위트는 미래의 나 자신과 동료들에게 주는 가장 큰 선물이며, 프로젝트가 시간이 지나도 흔들리지 않고 지속적으로 성장할 수 있게 하는 단단한 반석이 될 것입니다.
이 글에서 다룬 내용들을 바탕으로, 오늘 당장 여러분의 프로젝트에 작은 유닛 테스트 하나를 추가하는 것부터 시작해 보시길 권합니다. 그 작은 시작이 모여 견고하고 신뢰할 수 있는 애플리케이션을 만들고, 궁극적으로는 개발자로서의 여러분의 역량을 한 단계 끌어올리는 의미 있는 여정의 첫걸음이 될 것입니다.
Post a Comment