안드로이드 테스트 구현 (Kotlin, Instrument Test, Compose, Hilt)
안드로이드 통합 테스트 소개
통합 테스트는 여러개의 클래스와 메소드를 모아 의도대로 수행하는지 확인하고, 다른 모듈과의 상호작용이 잘 이루어지는지 확인한다. (아래에 통합Ui테스트를 편의상 통합 테스트로 명시하였음.)
통합 테스트를 위해 아래와 같은 라이브러리를 추가 하였다.
// Instrumentation tests
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.37'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.37'
androidTestImplementation "junit:junit:4.13.2"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1"
androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
androidTestImplementation "com.google.truth:truth:1.1.3"
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation "com.squareup.okhttp3:mockwebserver:4.9.1"
androidTestImplementation "io.mockk:mockk-android:1.10.5"
androidTestImplementation 'androidx.test:runner:1.4.0'
1년전 테스트 예제 소스를 가져온 것이기에 지금(2023.02.03)와 버전에서 차이가 있다. Hilt를 사용하는 프로젝트 였기에 Hilt 테스팅 라이브러리와, junit4, truth, mockk을 확인 할 수 있다. (이 외에도 Robolectric, espresso가 있다.)
코드와 함께 통합 테스트를 알아보도록 하자.
@ExperimentalAnimationApi
@Composable
fun NotesScreen(
navController: NavController,
viewModel: NotesViewModel = hiltViewModel()
) {
val state = viewModel.state.value
val scaffoldState = rememberScaffoldState()
val scope = rememberCoroutineScope()
Scaffold(
floatingActionButton = {
FloatingActionButton(
onClick = {
navController.navigate(Screen.AddEditNoteScreen.route)
},
backgroundColor = MaterialTheme.colors.primary
) {
Icon(imageVector = Icons.Default.Add, contentDescription = "Add note")
}
},
scaffoldState = scaffoldState
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Your note",
style = MaterialTheme.typography.h4
)
IconButton(
onClick = {
viewModel.onEvent(NotesEvent.ToggleOrderSection)
},
) {
Icon(
imageVector = Icons.Default.Sort,
contentDescription = "Sort"
)
}
}
AnimatedVisibility(
visible = state.isOrderSectionVisible,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
OrderSection(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
.testTag(TestTags.ORDER_SECTION),
noteOrder = state.noteOrder,
onOrderChange = {
viewModel.onEvent(NotesEvent.Order(it))
}
)
}
// 중략
}
}
}
위 코드는 Compose로 작성된 함수로, NotesViewModel의 상태에 따라서 컴포저블 함수가 호출된다. 해당 함수에서 테스트 해볼 것은 버튼을 눌렀을 때 OrderSection이 잘 나타나는지, 다시 버튼을 누르면 OrderSection이 사라지는지, 라디오 버튼을 클릭하고 해당 라디오 버튼을 눌렀을 때 잘 선택이 되는지 테스트할 수 있다. 해당 함수는 의존성 주입을 위한 Hilt 라이브러리를 활용한 예제로 Hilt를 사용한 클래스를 테스트하기 위해 몇 가지 설정이 필요하다.
Hilt 테스트 설정하기
HiltTestRunner.kt
package com.plcoding.cleanarchitecturenoteapp
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
가장 먼저 테스트에서 사용할 JUnitRunner를 따로 생성하였다.
build.gradle(module)
TestInstrumentationRunner를 방금 구현한 HiltTestRunner로 변경한다.
TestAppModule.kt
@Module
@InstallIn(SingletonComponent::class)
object TestAppModule {
@Provides
@Singleton
fun provideNoteDatabase(app: Application): NoteDatabase {
return Room.inMemoryDatabaseBuilder( // 실제 저장되는 것이 아닌 InMemoryBuilder 사용
app,
NoteDatabase::class.java,
).build()
}
@Provides
@Singleton
fun provideNoteRepository(db: NoteDatabase): NoteRepository {
return NoteRepositoryImpl(db.noteDao)
}
@Provides
@Singleton
fun provideNoteUseCases(repository: NoteRepository): NoteUseCases {
return NoteUseCases(
getNotes = GetNotes(repository),
deleteNote = DeleteNote(repository),
addNote = AddNote(repository),
getNote = GetNote(repository)
)
}
}
실제 AppModule이 install 되어 테스트 데이터가 DB에 들어가는 것을 방지하기 위해 TestAppModule을 생성한다. 해당 TestAppModule은 Room InMemoryDatabaseBuilder를 사용하여 메모리 형태의 데이터베이스에 접근한다.
NoteScreenTest.kt
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.plcoding.cleanarchitecturenoteapp.di.AppModule
import com.plcoding.cleanarchitecturenoteapp.feature_note.presentation.MainActivity
import com.plcoding.cleanarchitecturenoteapp.feature_note.presentation.util.Screen
import com.plcoding.cleanarchitecturenoteapp.ui.theme.CleanArchitectureNoteAppTheme
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import org.junit.Before
import org.junit.Rule
@HiltAndroidTest
@UninstallModules(AppModule::class) // AppMoudle을 사용하지 않고, 테스트 AppModule을 사용
class NotesScreenTest{
// 매번 테스트 전에 인젝션
@get:Rule(order = 0) // 순서
val hiltRule = HiltAndroidRule(this)
// 새로운 컴포넌트 액티비티
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()
@OptIn(ExperimentalAnimationApi::class)
@Before
fun setUp(){
hiltRule.inject()
composeRule.setContent {
val navController = rememberNavController()
CleanArchitectureNoteAppTheme {
NavHost(
navController = navController,
startDestination = Screen.NotesScreen.route
) {
composable(route = Screen.NotesScreen.route){
NotesScreen(navController = navController)
}
}
}
}
}
}
다음으로 테스트 코드로 돌아와서, Hilt를 사용하기 위해 @HiltAndroidTest를 추가한다. AppModule 대신 TestAppModule로 바인딩을 교체하기 위해 UnistallModule을 통해 바인딩을 해제한다. 그 후 Junit Rule을 통해 HiltAndroidRule과 createAndroidComposeRule을 초기화 한다. Junit Rule은 모든 테스트 케이스에서 사용되는 함수를 재정의하거나 추가할 때 도움을 주는 어노테이션이다.
@Before에서 inject() 함수로 모든 테스트 함수에서 의존성을 주입할 수 있게 되었고, ComposeRule을 사용하여 화면을 설정하였다.
NoteScreen.kt 전체코드
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.plcoding.cleanarchitecturenoteapp.core.util.TestTags
import com.plcoding.cleanarchitecturenoteapp.di.AppModule
import com.plcoding.cleanarchitecturenoteapp.feature_note.presentation.MainActivity
import com.plcoding.cleanarchitecturenoteapp.feature_note.presentation.util.Screen
import com.plcoding.cleanarchitecturenoteapp.ui.theme.CleanArchitectureNoteAppTheme
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@HiltAndroidTest
@UninstallModules(AppModule::class) // AppModule 을 사용하지 않고, 테스트 AppModule 을 사용
class NotesScreenTest{
// 매번 테스트 전에 인젝션
@get:Rule(order = 0) // 순서
val hiltRule = HiltAndroidRule(this)
// 새로운 컴포넌트 액티비티
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()
@OptIn(ExperimentalAnimationApi::class)
@Before
fun setUp(){
hiltRule.inject()
composeRule.setContent {
val navController = rememberNavController()
CleanArchitectureNoteAppTheme {
NavHost(
navController = navController,
startDestination = Screen.NotesScreen.route
) {
composable(route = Screen.NotesScreen.route){
NotesScreen(navController = navController)
}
}
}
}
}
// 버튼을 클릭 후, 섹션이 보이는 지
@Test
fun clickToggleOrderSection_isVisible(){
composeRule.onNodeWithTag(TestTags.ORDER_SECTION).assertDoesNotExist()
composeRule.onNodeWithContentDescription("Sort").performClick() // 클릭 시뮬레이션
composeRule.onNodeWithTag(TestTags.ORDER_SECTION).assertIsDisplayed()
}
// 토글 버튼을 누르고 모든 라디오 버튼 누르기
@Test
fun clickToggleRadioButton_isClickable(){
val tagNameList = mutableListOf(TestTags.TITLE, TestTags.DATE, TestTags.COLOR,
TestTags.ASCENDING, TestTags.DESCENDING )
composeRule.onNodeWithTag(TestTags.ORDER_SECTION).assertDoesNotExist()
composeRule.onNodeWithContentDescription("Sort").performClick()
composeRule.onNodeWithTag(TestTags.ORDER_SECTION).assertIsDisplayed()
tagNameList.forEach{ tagName ->
composeRule.onNodeWithTag(tagName).performClick()
composeRule.onNodeWithTag(tagName).assertIsSelected()
}
composeRule.onNodeWithContentDescription("Sort").performClick()
composeRule.onNodeWithTag(TestTags.ORDER_SECTION).assertDoesNotExist()
}
}
위의 코드와 같이 통합 테스트를 구현하였다. Compose에서 각 컴포넌트 마다 Description 및 TestTags를 설정할 수 있다.
클릭 시뮬레이션과 같은 이벤트를 발생시켜 컴포넌트가 보이는지, 라디오 버튼에서 제대로 선택되었는지 확인할 수 있다.
https://www.charlezz.com/?p=44343
[Hilt] 6.1 Testing – Testing 개요 | 찰스의 안드로이드
https://dagger.dev/hilt/testing 6.1 Testing - Testing 개요 소개 Note : 현재 Hilt는 안드로이드 Instrumentation 과 Robolectric 테스트만 지원한다. Hilt는 vanilla JVM 테스트에서는 사용할 수 없지만 평소와 같이 이러한
www.charlezz.com
https://github.com/philipplackner/CleanArchitectureNoteApp/tree/testing
GitHub - philipplackner/CleanArchitectureNoteApp
Contribute to philipplackner/CleanArchitectureNoteApp development by creating an account on GitHub.
github.com