[Jetpack Compose] Mockk + Hilt.test 라이브러리를 활용하여 UI Test 를 진행해보자.

오규성·4일 전
0

기존에 작성한 티스토리 글을 옮겨온 것입니다.


흔히 TDD (Test-Driven Development) 라고 말하는 개발 방법을 알고 있을 것이다.

테스트 주도 개발이라고 불리는 기법인데, 테스트를 진행하기 위해서 우리는 Unit Test, UI Test, Integration Test 등을 진행해야한다.

Integration Test 의 경우 실제 앱 환경과 동일하게 전체 모듈을 아우르는 App Module 에서 테스트를 진행하면 되므로 의존성 관련 문제가 없겠지만, 각 모듈별로 테스트를 진행해야하는 Unit Test, UI Test 에서는 의존성 문제가 생길 때가 많다.

Android Clean Architecture 로 예를 들자면 Presentation -> Domain <- Data 의존 관계를 가지고 App Module 은 이 모두를 알고 있으니 실제 앱 구동환경에서는 모든 빌드가 통합되어 정상적으로 실행되지만 테스트를 위해 Presentation 모듈만 빌드하게 된다면 Domain 모듈이 Data 모듈에 의존하지만, Data 모듈은 빌드되지 않으므로 예상했던 결과가 나오지 않게 되는 것이다.

그렇다면 Presentation Module 에서는 Data -> Domain 을 경유해서 가져오는 데이터를 어떻게 가져올 수 있을까? 또한 Data Module 은 존재하지 않는데 Hilt 를 통한 의존성을 어떻게 주입해줄 수 있을까?

이럴 때 필요한 것이 MockkHilt Test 라이브러리이다.

# Mockk 이 무엇일까?

MockkMock (Mockito) 라이브러리의 코틀린 특화 버전으로 테스트 코드 작성 시 모의 객체를 생성하게 해주는 아주 좋은 라이브러리이다.

우리는 이 Mockk 을 통해 Data -> Domain 을 경유하여 Presentation 으로 가져오는 데이터의 모의 값, 모의 동작을 쉽게 정의해줄 수 있기에 테스트 시 실행 결과를 예측할 수 있게 된다.

만약 이 라이브러리가 없었다면 개발자가 Data Module 세부 구현들을 일일이 직접 구현해줘야 하므로 테스트가 어려웠을텐데 Mockk 을 통해 의존성이 필요한 경우 이를 손쉽게 구현해주므로 독립적인 테스트가 간편하게 가능해지는 것이다 !

# Hilt Test 라이브러리?

Jetpack 에서 제공해주는 Hilt 의 Test 용 코드들이 추가된 버전이라고 보면 된다.

Hilt 를 활용한 Android Test 시 Hilt 의존성 주입이 가능한 환경을 만들어주어야 하는데 필요한 @HiltAndroidTest AnnotationHiltAndroidRule 클래스를 제공한다.

@HiltAndroidTest + HiltAndroidRule 가 함께 동작하는 방식은 다음과 같다.

  1. @HiltAndroidTest가 붙은 클래스는 Hilt 환경에서 실행됨
  2. HiltAndroidRule가 테스트 실행 전에 Hilt 컨테이너를 초기화
  3. hiltRule.inject()를 호출하여 필요한 의존성을 주입
  4. Hilt 기반의 객체들이 정상적으로 주입된 상태에서 테스트 수행

# UI Test 를 진행해보자

자, Mockk 과 Hilt Test 라이브러리에 대해 대충 설명을 했으니 이제 UI Test 를 진행해보자.

1. 의존성 추가 및 build.gradle.kts 설정

우선 Mockk, HiltTest 라이브러리를 모듈에 추가해준다.

mockk = "1.13.12"
hilt-android = "2.55"

[libraries]
mockk-androidTest = { module = "io.mockk:mockk-android", version.ref = "mockk"}
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt-android" }

다음으로 모듈의 build.gradle.kts 로 넘어가 다음 의존성을 추가해준다.

androidTestImplementation(libs.mockk.androidTest)
androidTestImplementation(libs.hilt.android.testing)

// 밑은 그럴리 없겠지만 implementation, ksp 로 힐트 추가 안한경우 ..
implementation(힐트안드로이드)
ksp(힐트안드로이드컴파일러)

의존성을 추가해준경우 아래처럼 AndroidTestFolder 로 이동하여 HiltTestRunner class File 을 생성해준다.

class HiltTestRunner : AndroidJUnitRunner() {
    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

그리고 위의 코드를 넣어준다음 Module build.gradle.kts 로 이동한다

내부를 보면 다음과 같이 android 스코프 안에 defaultConfig 가 존재하고 testInstrumentationRunner 가 존재할텐데, 이를 HiltTestRunner 클래스 위치로 변경해준다.

defaultConfig {
    // 기존은 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    testInstrumentationRunner = "devgyu.app.presentation.HiltTestRunner"
}

2. UI Test 를 위한 환경 설정

위의 모든 설정을 끝마쳤다면 androidTest 폴더로 다시 이동하자.

그리고 아래와 같이 UI Test class 를 설정해준다.

@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@HiltAndroidTest
class DevGyuUiTest {
    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val composeTestRule = createComposeRule()

    @get:Rule(order = 2)
    val mockkRule = MockKRule(this)
}
  • RunWith(AndroidJunit4::class) : 해당 테스트 클래스의 실행을 AndroidJUnit4 기반으로 설정
  • FixMethodOrder(MethodSorters.NAME_ASCENDING) : 테스트 순서를 이름 오름차순으로 실행. 이 부분은 설정하지 않아도 되지만, 그런 경우 테스트 순서가 랜덤으로 실행됩니다.
  • HiltAndroidTest : 해당 테스트 환경을 Hilt Test 환경으로 설정
  • HiltAndroidRule(this) : 해당 테스트 환경에서 Hilt Container 가 초기화되도록 설정
  • createComposeRule() : 내부적으로 createAndroidComposeRule<ComponentActivity> 를 생성하여 실제 액티비티가 아닌, 테스트용 액티비티를 생성하여 개별 컴포넌트들을 테스트 할 수 있게 설정해준다
    만약 자신의 액티비티로 실행하고 싶으면 createAndroidComposeRule<액티비티> 로 설정해주면 되지만, 이 경우 앱의 전체 UI환경에서 실행되므로 특정 컴포넌트만 테스트하는 것이 어렵다.
  • MockkRule(this) : 테스트 실행 시 Mockk의 내부 설정을 자동으로 초기화해준다

위와 같은 기본 설정을 끝마쳤다면 ViewModel 에 주입되어 있는 Usecase 들을 Mockk 객체로 재정의해줄 것이다.

그 전에 우선 다음 코드를 봐보자

// Domain Module
interface DevGyuRepository {
    fun getName(): String
}

// Data Module
class DevGyuRepositoryImpl: DevGyuRepository {
    override fun getName(): String {
        return "DevGyu Test"
    }
}

// Domain Module
class GetNameUseCase @Inject constructor(
    private val repository: Repository
) {
    operator fun invoke(): String = repository.getName()
}

@HiltViewModel
class DevGyuTestViewModel @Inject constructor(
    private val getNameUseCase: GetNameUseCase
): BaseViewModel(){
    val name = getNameUseCase()
}

위는 클린 아키텍처 구조에 따른 뷰모델에서 데이터를 가져올 때 필요한 클래스들이다.

Repository 는 Hilt 를 통해 Impl 이 바인딩 되도록 설정하였다 가정하고 진행하겠다.

DevGyuViewModel 에서는 @Inject 를 통해 UseCase 를 주입받고 있으며, UseCase 는 @Binds 를 통해 DI 그래프에 구현 클래스와 바인딩된 Repository 를 의존하고 있다.

우리가 테스트 과정에서 ViewModel 을 사용하기 위해서는 HiltViewModel 로 선언되어 있는 DevGyuViewModel 의 의존성 주입 문제들을 해결해줘야한다.

이 과정에서 우리는 Usecase 는 mockk() 을 통한 모의 객체 생성 및 Stubbing 을 통한 return 값을 지정해주고, @BindValue 를 통해 ViewModel @Inject 때 주입될 Usecase 를 모의 객체로 바꿔 넣어줄 것이다.

이렇게 함으로써 실제로는 Repository 가 필요하던 UseCase 가 테스트 시 의존성 주입이 없는 상태의 mockk() 객체로 되니 repository 를 주입해줄 필요가 없어진다.

// mockk 객체 생성 후 BindValue 로 mockk 을 DI 그래프에 추가
@BindValue
val useCase: GetNameUseCase = mockk()

// viewModel 늦은 초기화 처리
lateinit var viewModel: DevGyuTestViewModel

@Before
fun init(){
	// 힐트 의존성 적용
    hiltRule.inject()

    // useCase() 를 호출하는경우 항상 "DevGyu Mockk" 이 return 되도록 설정. 
    // suspend 인 경우 coEvery 를 사용. 다른 기능들은 인터넷 검색 참조
    every { useCase() } returns "DevGyu Mockk"
    
    // 스터빙 이후에 뷰모델을 설정해줘야 한다. 순서 주의 !
    viewModel = DevGyuTestViewModel(useCase)
}

우선 테스트 전에 Usecase 목업 객체를 만들어주고 실객체 대신 바인딩해준다.

그리고 추후 viewModel 을 컴포넌트에 넣어주기 위해 늦은 초기화 처리를 해주고, 이후 여러 환경 설정들을 위해 @Before Annotation 을 사용한 함수를 만들어준다.

이곳에서 hiltRule.inject() 를 통해 힐트의 의존성 주입을 시도해주고 mockk 스터빙을 해준다음 viewModel 을 초기화 해주면 끝이다.

단, 이때 viewModel 은 스터빙 이후 초기화해줘야 정상적인 동작이 이루어지므로 주의하자.

3. UI Test 코드 작성 및 진행

이제 테스트를 진행해주기 위해 테스트 코드를 작성해보자

// UI 컴포넌트
@Composable
fun DevGyuTestScreen(
    viewModel: DevGyuTestViewModel = hiltViewModel()
){
    Text(
        modifier = Modifier.testTag("이름 테스트"),
        text = viewModel.name
    )
}

@Test
fun `t0_Usecase는_Mockk으로_변경되어야한다`(){
    composeTestRule.setContent { DevGyuTestScreen(viewModel) }

    println("t0 유즈케이스 결과 - ${useCase()}")

    composeTestRule
        .onNodeWithTag("이름 테스트")
        .assertTextEquals("DevGyu Mockk")
}

UI 컴포넌트 DevGyuTestScreen 에 Text Component 이 있다고 해보자.

이 Text 는 ViewModel 에서 GetNameUsecase() 를 통해 DevGyu Test 라는 값을 가져오고 있다.

그러나 우리는 테스트 환경에서 목업 객체를 생성하였고, 이를 뷰모델에서 실객체 대신 constructor 에 주입되어 사용하고 있기 때문에 테스트중에는 이전에 Stubbing 한 값인 DevGyu Mockk 이 출력되어야 정상적으로 진행된 것이다.

4. UI Test 결과

테스트 실행 결과 DevGyuTestScreen 의 Text Componenet 는 기존 설정 값인 DevGyu Test 가 아닌, 테스트 환경에서 스터빙해준 DevGyu Mockk 값으로 설정된 것을 알 수 있다.


기존에 테스트 코드에서 목업 객체를 설정하는 것에 대해서는 어느 정도 알고 있었으나, 각 컴포넌트를 개별적으로 테스트하는 방법에 대해 좀 무지했어서 createAndroidComposeRule<내메인액티비티> 로 하면 setContent 가 안되는 이유를 몰랐다.

그래서 매일 AndroidTest Manifest 설정 후 테스트 전용 커스텀 액티비티를 생성하여 createAndroidComposeRule<커스텀액티비티> 로 선언 후 테스트하였는데 공부하다보니 createComposeRule 을 사용하면 된다는 것을 알게 되었다.

또한 Stubbing 이후에 뷰모델을 초기화해줘야하는 것도 몰랐고, @BindValue 의 정확한 동작에 대해 잘 몰랐으나 이번 게시글을 계기로 어느정도 알 수 있게 되어 뿌듯하다.

profile
안드로이드 개발자 Gyu 의 개발 블로그 !

0개의 댓글