Android

안드로이드 테스트 구현 (Kotlin, Instrument Test, Compose, Hilt)

정자이노 2023. 2. 3. 17:18

안드로이드 통합 테스트 소개 

통합 테스트는 여러개의 클래스와 메소드를 모아 의도대로 수행하는지 확인하고, 다른 모듈과의 상호작용이 잘 이루어지는지 확인한다. (아래에 통합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