천복만복 프로그래밍/천복만복 안드로이드

[Android Test] 안드로이드 Integration Test Basic

U&MeBlue 2022. 2. 23. 07:59

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)
		)
	}
}
728x90