Fragment Scenario, Activity Scenario
Fragment 와 ViewModel 간의 Integeration Test 를 진행하기 위해서 AndroidX Test Library 의 FragmentScenario api 를 이용할 수 있다. 이 api 는 테스트 코드에서 Fragment 를 생성하고, Fragment 의 상태나 LifeCycle 을 조작할 수 있도록 도와준다.
FragmentScenario api 를 사용하기 위해서는 다음 Dependency 가 필요하다.
// Dependencies for Android instrumented unit tests
androidTestImplementation "junit:junit:$junitVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
// Testing code should not be included in the main code.
// Once <https://issuetracker.google.com/128612536> is fixed this can be fixed.
implementation "androidx.fragment:fragment-testing:$fragmentVersion"
implementation "androidx.test:core:$androidXTestCoreVersion"
그리고 다음과 같이 FragmentScenario 를 생성할 수 있다.
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {
@Test
fun activeTaskDetails_DisplayedInUi() {
...
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
...
}
}
- Fragment 에 전달해야 하는 인자를 Bundle 형태로 전달할 수 있다.
- FragmentScenario api 를 이용하여 실행되는 프래그먼트는 empty activity 에 실행된다. 보통 프래그먼트는 액티비티의 Theme 을 상속하여 화면을 구성하기 때문에 Theme 스타일도 전달해준다.
Service Locator
Fragment 나 Activity 는 프로그래머가 명시적으로 생성하는 객체가 아니다. 이 클래스들의 생성자는 안드로이드 시스템에 의해서 호출되기 때문에 Constructor Injection 을 사용할 수 없다.
Fragment 나 Activity 의 Dependency Injection 을 위해서는 다음 2가지 방법이 있을 수 있다.
- Setter Injection : Dependency 에 대한 Setter 메소드를 구현하여 Injection 하는 방법. 매 테스트 코드마다 Injection 을 수행해야하는 단점이 있다.
- Service Locator Pattern : Dependency 를 Provide 하는 Service Locator 를 구현하고, Dependency 가 필요할 때마다 이 Service Locator 에서 받아오는 방법. Test 를 위해서는 이 Service Locator 에 Fake Implementation 을 집어 넣어 다른 모든 곳에서 한번에 Fake Implementation 을 사용하도록 바꿔치기 할 수 있다.
object ServiceLocator {
private val lock = Any()
private var database: ToDoDatabase? = null
@Volatile
var tasksRepository: TasksRepository? = null
@VisibleForTesting set
fun provideTasksRepository(context: Context): TasksRepository {
synchronized(this) {
return tasksRepository ?: createTasksRepository(context)
}
}
private fun createTasksRepository(context: Context): TasksRepository {
val newRepo = DefaultTasksRepository(TasksRemoteDataSource, createTasksLocalDataSource(context))
tasksRepository = newRepo
return newRepo
}
private fun createTasksLocalDataSource(context: Context): TasksDataSource {
val database = database ?: createDatabase(context)
return TasksLocalDataSource(database.taskDao())
}
private fun createDatabase(context: Context): ToDoDatabase {
val result = Room.databaseBuilder(
context.applicationContext,
ToDoDatabase::class.java,
"Task.db"
).build()
database = result
return result
}
@VisibleForTesting
fun resetRepository() {
synchronized(lock) {
runBlocking {
TasksRemoteDataSource.deleteAllTasks()
}
// Clear all data to avoid test pollution.
database?.apply {
clearAllTables()
close()
}
database = null
tasksRepository = null
}
}
}
UI Test with Espresso
안드로이드에서 UI 테스트를 하기 위해서는 보통 Espresso 를 사용한다. Espresso 를 사용하기 위해서는 다음과 같은 Dependency 추가가 필요하다.
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"
Espresso 를 사용하여 UI 테스트를 할때 가장 많이 사용하는 것에는 다음과 같은 것들이 있다.
UI 테스트를 진행하기 전에 Instrumented Test 를 진행할 단말이나 에뮬레이터에 에니메이션 설정을 해줄 필요가 있다. 에니메이션은 뷰가 보여지는 타이밍등에 관여하여 UI Test 를 Flaky 하게 만들어줄 수 있기 때문에 테스트 중에는 설정하지 않는 것이 좋다. 개발자 옵션에 들어가서 3가지 에니메이션 배율 관련 옵션을 모두 사용안함으로 설정해준다.
Expresso UI Test Sample
@Test
fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
// GIVEN - Add completed task to the DB
val activeTask = Task("Complete Task", "AndroidX Rocks", true)
repository.saveTask(activeTask)
// WHEN - Details fragment launched to display task
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
// THEN - Task details are displayed on the screen
// make sure that the title/description are both shown and correct
onView(withId(R.id.task_detail_title_text)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_title_text)).check(matches(withText("Complete Task")))
onView(withId(R.id.task_detail_description_text)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_description_text)).check(matches(withText("AndroidX Rocks")))
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isChecked()))
}
Mock
Mock 은 Test Double 의 한 종류로서 클래스의 어떤 메소드가 호출되었는지를 판단할 수 있는 Test Double 이다. 테스트는 어떤 action 을 하고, 그 결과로 나타나는 State 의 Change 를 테스트하고 싶은 경우가 많은데, 보통의 경우에는 Mock 보다는 Fake 를 사용하는 것이 좋은데 다음과 같은 이유가 있다.
- Mock 을 활용하여 결과 State 를 변경하는 api 가 호출되었는지를 판단하더라도, 실제 State 가 변경되지 않는 시나리오가 있을 수 있다. (false success)
- 클래스의 api 변경으로 인해 Mock 으로 테스트하는 api 가 호출되지 않더라도 결과 State 가 원하는 형태로 바뀔 수도 있다. (false failure)
그래서 Mock 은 보통 다음과 같은 경우에 사용한다.
- 테스트 해야하는 결과 State Change 가 현재 테스트하고자 하는 범위를 넘어서는 경우나 그 방법이 까다로운 경우
- 클래스 api 변경이 거의 일어나지 않는 경우
안드로이드 Test 를 작성할 때 Mock 을 생성하는 가장 간단한 방법은 라이브러리를 사용하는 것이다. 대표적으로 Mockito 라이브러리가 있다. 다음과 같은 형태로 사용할 수 있다.
val navController = mock(NavController::class.java)
verify(navController).navigate(
TasksFragmentDircections.actionTasksFragmentToTaskDetailFragment("id")
)
Mockito 를 사용하기 위해서는 다음 Dependency 가 필요하다.
// Dependencies for Android instrumented unit tests
androidTestImplementation "org.mockito:mockito-core:$mockitoVersion"
// Mockito 가 런타임에 클래스를 생성할 수 있도록 해주는 라이브러리
androidTestImplementation "com.linkedin.dexmaker:dexmaker-mockito:$dexMakerVersion"
예제 샘플
@RunWith(AndroidJUnit4::class)
@MediumTest
@ExperimentalCoroutinesApi
class TasksFragmentTest {
private lateinit var repository: TasksRepository
@Before
fun initRepository() {
repository = FakeAndroidTestRepository()
ServiceLocator.tasksRepository = repository
}
@After
fun cleanupDb() = runBlockingTest {
ServiceLocator.resetRepository()
}
@Test
fun clickTask_navigateToDetailFragmentOne() = runBlockingTest {
repository.saveTask(Task("TITLE1", "DESCRIPTION1", false, "id1"))
repository.saveTask(Task("TITLE2", "DESCRIPTION2", true, "id2"))
// GIVEN - On the home screen
val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
val navController = mock(NavController::class.java)
scenario.onFragment {
Navigation.setViewNavController(it.requireView(), navController)
}
// When - Click on the first list item
onView(withId(R.id.tasks_list))
.perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(hasDescendant(withText("TITLE1")), click()))
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment("id1")
)
}
@Test
fun clickAddTaskButton_navigateToAddEditFragment() {
// Given - On the Home Screen
val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
val navController = mock(NavController::class.java)
scenario.onFragment {
Navigation.setViewNavController(it.requireView(), navController)
}
// When - Click on the FAB
onView(withId(R.id.add_task_fab)).perform(click())
// Then - navigate to AddEditTaskFragment
val title = ApplicationProvider.getApplicationContext<TodoApplication>().resources.getString(R.string.add_task)
verify(navController).navigate(
TasksFragmentDirections.actionTasksFragmentToAddEditTaskFragment(null, title)
)
}
}