들어가며
스타카토에는 닉네임 입력 시 피드백을 제공하는 기능을 검증하는 UI 테스트가 있습니다.
초기의 로그인 화면 UI 테스트에서는 JUnitParams를 이용해 테스트를 진행했습니다. 하나의 테스트에서 여러 개의 테스트 케이스를 제공하기 위해 설정했던 Runner입니다.
@RunWith(JUnitParamsRunner::class)
class LoginActivityTest {
// ...
@Test
@Parameters(method = "invalidFormatNicknames")
fun `잘못된_형식의_닉네임을_입력하면_닉네임_입력_란에서_에러_메세지를_보여준다`(invalidNickname: String) {
// when
nicknameInputEditText.perform(replaceText(invalidNickname))
// then
nicknameInputLayout
.check(
matches(
allOf(
hasDescendant(
withText(R.string.login_nickname_error_message_format),
),
isDisplayed(),
),
),
)
}
private fun invalidFormatNicknames(): List<String> =
listOf(
"!valid",
"쓸수없는닉네임!@#",
)
// ...
}
하지만 시간이 지나고, 팀원들과 상의 끝에 JUnitParams를 사용하지 않기로 했습니다.
여기엔 몇 가지 이유가 있었는데요.
- 파라미터를 제공하는 다수의 메서드로 인해 테스트 가독성이 떨어짐
- UI 테스트 실행 시간이 오래 걸림
- 테스트 확장성을 고려해 Hilt를 적용할 필요성
우선 파라미터를 위한 메서드들이 많아서 코드 가독성이 썩 좋지 않았습니다.
그리고 CI 단계에서 UI 테스트를 실행하고 있었는데, 시간이 오래걸려 답답한 부분이 있었습니다. 안그래도 실행 시간이 긴 UI 테스트인데, 한 테스트에서 여러 개의 테스트 케이스까지 실행시키니 시간이 더 오래 걸렸습니다. (CI 하나가 도는데 8분이 넘게 걸렸기 때문에... 급하게 병합을 진행해야하는 경우에는 꽤나 답답했습니다.)
그리고 가장 큰 이유는 테스트의 확장성을 고려한 것이었습니다. 네트워크 요청 결과에 따라 UI의 변경을 검증할 수 있도록 변경하기 위해서는 Hilt를 적용하여 의존성 주입이 가능하게 해야했기 때문입니다.
그러기 위해서는 JUnitParams 러너 대신 AndroidJUnit4 러너를 사용해야 합니다. 둘 다 사용하거나 둘 중 하나의 러너로 대체할 수 없었고, "다수의 테스트 케이스를 두는 것"과 "테스트 확장성을 높이는 것" 중 후자가 더 많은 이점을 가져올 것이라 판단했습니다.
이에 JUnitParams를 사용하지 않고 테스트에 Hilt를 적용하기로 했는데요.
이번 포스트에서는 UI 테스트에 Hilt를 적용했던 이유와 그 과정을 담아보고자 합니다.
UI 테스트에도 Hilt를 적용해야할까?
우선 UI 테스트를 반드시 Hilt로 적용해야하는 것은 아닙니다. UI 테스트에 Hilt를 적용하지 않더라도 테스트는 문제 없이 잘 동작합니다.
Hilt를 적용하지 않은 경우
UI 테스트는 기본적으로 프로덕션의 Application을 실행해 동작합니다. Hilt가 적용된 프로젝트라면, @HiltAndroidApp 어노테이션이 붙은 Application이 실행됩니다. 이에 자동적으로 Hilt가 프로덕션의 DI 그래프에 따라 의존성을 주입하며 테스트가 동작합니다.
Hilt를 적용하지 않은 방식은 아래의 특징을 갖습니다.
- 프로덕션 Application(@HiltAndroidApp)과 DI 그래프를 그대로 사용
- 실제 프로덕션의 네트워크, 로컬 저장소 사용
- 별 다른 설정 없이 빠르게 테스트 작성 가능
UI 테스트에 Hilt를 적용하지 않는 경우, 구현이 간단하고 실제 사용자 환경과 유사한 조건에서 UI 흐름을 확인할 수 있다는 장점이 있습니다.
그러나 단점이 존재합니다.
- 네트워크 상태 등의 외부 환경에 따라 테스트가 불안정
- 테스트 실패 시, 원인이 UI 문제인지 로직·환경 문제인지 구분하기 어려움
- 재현성이 낮아 CI 환경에서 flaky 테스트가 되기 쉬움
만약 로컬 저장소나 네트워크 환경에 영향을 받지 않는 기능이라면 UI 테스트에 Hilt를 적용하지 않아도 충분히 테스트할 수 있습니다. 그러나 외부 환경에 영향을 받거나 추후에 이러한 환경으로 확장될 가능성이 있는 기능이라면 테스트가 어려워질 것입니다.
Hilt를 적용한 경우
UI 테스트에 Hilt를 적용하면 @HiltAndroidTest, HiltAndroidRule, 테스트용 Application 등으로 테스트 전용 DI 그래프를 구성할 수 있습니다. 이를 통해 프로덕션 모듈을 테스트용 모듈로 교체할 수 있습니다.
그렇기에 아래와 같은 장점을 가집니다.
- 테스트의 결과가 외부 환경에 영향을 받지 않아 안정적
- 프로덕션 의존성을 가짜 구현체(Fake, Mock 객체)로 교체
- 성공/실패/에러 상태 등 다양한 시나리오를 의도적으로 재현 가능
- 네트워크, DB, 로그인 상태 등을 테스트 코드에서 제어 가능
- UI 테스트가 검증하는 범위가 명확해짐
물론 초반에 설정을 위한 시간과 노력이 필요하고, 테스트의 목적이 단순한 UI 동작을 확인하는 목적인 경우에는 과한 구성일 수 있습니다.
그러나 “테스트를 더욱 테스트답게” 만들 수 있다는 장점이 있습니다.
안드로이드 공식 문서에서도 Hilt를 적용한 통합 테스트(그리고 UI 테스트)의 경우 테스트가 더 쉬워진다는 점을 언급하고 있습니다. 이는 즉, 테스트하기 좋은 환경을 만들 수 있다는 의미입니다.
Hilt testing guide | App architecture | Android Developers
Hilt 테스트 가이드 | App architecture | Android Developers
Hilt 테스트 가이드 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Hilt와 같은 종속 항목 삽입 프레임워크를 사용하여 얻을 수 있는 이점 중 하나는 코드를
developer.android.com
안드로이드 공식 문서 - Hilt testing guide
One of the benefits of using dependency injection frameworks like Hilt is that it makes testing your code easier. For integration tests, Hilt injects dependencies as it would in your production code. Testing with Hilt requires no maintenance because Hilt automatically generates a new set of components for each test.
Hilt와 같은 의존성 주입 프레임워크를 사용하면 코드 테스트가 더 쉬워진다는 장점이 있습니다. 통합 테스트의 경우, Hilt는 프로덕션 코드에서와 동일하게 종속성을 주입합니다. Hilt를 사용한 테스트는 유지보수가 필요하지 않습니다. Hilt가 각 테스트마다 새로운 컴포넌트 세트를 자동으로 생성하기 때문입니다.
필자는 처음에 쉬워진다는 표현으로 인해 Hilt 적용이 무조건 좋은 것으로 받아들였는데요. 단순히 편리하다는 이유보다는, 위에서의 설명과 같이 테스트의 목적과 성격에 따라 충분히 적용할 가치가 있기 때문에, 공식 문서에서도 UI 테스트의 Hilt 적용을 추천하는 것입니다.
UI 테스트에 Hilt 적용하기
스타카토 프로젝트에서 로그인 화면의 ‘닉네임 입력 형식 피드백 기능’에 대한 UI 테스트를 작성할 때, 처음에는 Hilt를 적용하지 않았습니다.
그때는 테스트에 Hilt를 적용했을 때의 장점을 자세히 이해하지 못하기도 했지만, 가장 큰 이유는 네트워크 통신과 같은 외부 영향 없이 입력 값에 따른 UI 변화만을 검증하는 테스트였기 때문입니다.
그러나 추후에 서버로부터 닉네임 중복을 체크하는 테스트 시나리오가 추가되는 등 테스트에 네트워크 통신 의존성이 발생할 가능성이 있었습니다. 이 경우 테스트가 불안정해지기 때문에 안정성을 높이고자 UI 테스트에 Hilt를 적용하기로 했습니다.
적용 방법
적용 방법은 공식 문서에도 자세히 설명되어 있습니다.
Hilt testing guide | App architecture | Android Developers
Hilt 테스트 가이드 | App architecture | Android Developers
Hilt 테스트 가이드 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Hilt와 같은 종속 항목 삽입 프레임워크를 사용하여 얻을 수 있는 이점 중 하나는 코드를
developer.android.com
Hilt 테스트 의존성 추가하기
우선 Hilt 테스트를 위한 의존성을 추가해야 합니다.
UI 테스트 도구로 Espresso를 사용하고 있다면 아래의 의존성을 추가합니다.
dependencies {
androidTestImplementation("com.google.dagger:hilt-android-testing:2.57.1")
kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.57.1")
}
만약 Robolectric을 사용하고 있다면 아래와 같이 추가합니다.
dependencies {
testImplementation("com.google.dagger:hilt-android-testing:2.57.1")
kaptTest("com.google.dagger:hilt-android-compiler:2.57.1")
}
테스트 코드 수정하기
이제 UI 테스트 코드를 Hilt 기반으로 수정합니다.
만약 기존의 UI 테스트 코드가 아래와 같이 작성되어 있다고 한다면,
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
/* 테스트 코드... */
}
@HiltAndroidTest 어노테이션을 붙이고 HiltAndroidRule을 추가합니다.
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class LoginActivityTest() {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Before
fun setup() {
hiltRule.inject()
}
/* 테스트 코드... */
}
Hilt 테스트 애플리케이션 설정하기
마지막으로 Hilt가 테스트 의존성을 주입할 수 있도록, Hilt 전용 Test Runner를 추가해야합니다.
Hilt 전용 Test Runner는 AndroidJUnitRunner를 상속 받습니다.
안드로이드의 계측 테스트는 기본적으로 AndroidJUnitRunner 에서 실행됩니다. 우리가 UI 테스트에서 흔히 사용한 @RunWith(AndroidJUnit4::class)가 붙은 테스트를 실행할 때 내부적으로 사용되는 Runner 입니다. 이 Runner로부터 테스트가 실행되는 Application이 반환됩니다.
우선 UI 테스트가 들어있는 androidTest 폴더에 아래와 같이 Test Runner 클래스를 작성합니다.
// androidTest/java/.../HiltTestRunner.kt
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application =
super.newApplication(
cl,
HiltTestApplication::class.java.name,
context,
)
}
클래스명은 자유롭게 작성하셔도 상관 없습니다. 중요한 점은, newApplication 매서드를 재정의하여 HiltTestApplication을 반환하도록 하는 것입니다.
작성한 Test Runner를 적용하기 위해 build.gradle 파일(app 또는 모듈 레벨)에서 아래와 같이 작성합니다.
android {
defaultConfig {
testInstrumentationRunner = "패키지명.HiltTestRunner"
}
}
만약 Robolectric을 사용하는 경우에는 robolectric.properties 파일에서 테스트 전용 애플리케이션을 지정해야 합니다.
# robolectric.properties
application = dagger.hilt.android.testing.HiltTestApplication
또는 Robolectric의 @Config 어노테이션을 사용하여 각 테스트마다 개별적으로 애플리케이션을 설정할 수도 있습니다.
@HiltAndroidTest
// @Config로 실행할 Application 설정
@Config(application = HiltTestApplication::class)
class SomeActivityTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
// ...
}
테스트에서 사용할 의존성 주입하기
위의 과정대로 설정을 완료하셨다면 Hilt로 의존성을 제어할 수 있는 테스트 환경이 갖춰졌습니다. 이제 UI 테스트에서 실제 프로덕션의 의존성이 아닌 테스트용 의존성을 주입시키는 방법을 알아봅시다.
LoginActivity에서 사용하는 LoginViewModel의 의존성이 아래와 같은 구조로 주입 받고 있다고 가정해봅니다.
@InstallIn(SingletonComponent::class)
@Module
abstract class RepositoryModule {
@Binds
abstract fun bindLoginRepository(loginDefaultRepository: LoginDefaultRepository): LoginRepository
@Binds
abstract fun bindNotificationRepository(notificationDefaultRepository: NotificationDefaultRepository): NotificationRepository
}
@HiltViewModel
class LoginViewModel
@Inject
constructor(
private val loginRepository: LoginRepository,
private val notificationRepository: NotificationRepository,
) : ViewModel() { /* ... */ }
이제 테스트 환경에서는 LoginViewModel에 주입되는 두 개의 Repository를 Fake/Mock 객체로 변경하고자 합니다.
1️⃣ 프로덕션 Module 제거하기
실제 Module 코드를 제거하는게 아닙니다
우선 Hilt가 테스트 코드에서는 프로덕션의 Module을 사용하지 않도록 알려야 합니다. 테스트 코드에 @UninstallModules 을 추가하면, Hilt가 RepositoryModule을 사용하지 않고 제거하도록 할 수 있습니다.
@HiltAndroidTest
@UninstallModules(RepositoryModule::class)
class LoginActivityTest {
...
}
2️⃣ 테스트 전용 Repository 구현체 만들기
다음으로 LoginRepository와 NotificationRepository를 구현하는 Mock 또는 Fake 클래스를 만듭니다.
class FakeLoginRepository @Inject constructor() : LoginRepository {
override suspend fun loginWithNickname(nickname: String): ApiResult<Unit> =
// ...
}
class FakeNotificationRepository @Inject constructor() : NotificationRepository {
suspend fun getNotificationExistence(): ApiResult<NotificationExistence> =
// ...
}
중요한 점은 테스트에서 상태를 제어할 수 있어야 하고, 네트워크나 DB 등의 외부 의존성이 없어야 합니다.
만약 요청 실패/성공과 같이 응답 결과가 달라져야 한다면 상태에 따라 적절한 반환값을 갖도록 구현해야겠죠.
3️⃣ 테스트 전용 Module 설치하기
이제 Fake Repository를 제공하는 Test Module을 만듭니다.
@TestInstallIn 어노테이션으로 아래와 같이 설정하여 프로덕션의 RepositoryModule을 대체할 수 있습니다.
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
abstract FakRepositoryModule {
@Binds
abstract fun bindLoginRepository(
fakeLoginRepository: FakeLoginRepository
): LoginRepository
@Binds
abstract fun bindNotificationRepository(
fakeNotificationRepository: FakeNotificationRepository
): NotificationRepository
}
이렇게 하면 LoginViewModel에 FakeLoginRepository와 FakeNotificationRepository가 주입됩니다.
마치며
이렇게 UI 테스트에 Hilt를 적용하는 방법을 간단히 알아보았습니다.
통합 테스트, UI 테스트는 Hilt를 적용하지 않아도 충분히 테스트가 가능하지만, 네트워크 요청이나 로컬 저장소 접근 등 외부 환경에 따른 UI의 변경을 검증해야하는 경우에 Hilt를 이용한 의존성 주입이 가능하도록 변경하는게 유리합니다.
테스트를 더욱 유연하고 테스트답게 만들 수 있다는 장점이 있으므로 Hilt의 적용을 고려해보시는 것도 좋은 방법이 될 것 같습니다.
참고 자료
https://developer.android.com/training/dependency-injection/hilt-testing
'개발 > Android' 카테고리의 다른 글
| [Android] GitHub Actions로 CI에서 UI 테스트 수행하기 (0) | 2025.11.14 |
|---|---|
| [Android] AndroidManifest.xml은 무엇일까? (0) | 2025.09.01 |
| [Android] GitHub Actions로 QA용 CD 구축하기 - Firebase 활용 (0) | 2025.08.03 |
| [Android] Compose에서 Pinch Zoom 구현하기 (0) | 2025.06.13 |
| [Android] GitHub Actions로 CI 적용하기 - 간단 가이드 (0) | 2025.04.02 |