테스트 더블 및 종속 항목 삽입 소개

이 Codelab은 Kotlin 기반 Android 고급 교육 과정의 일부입니다. Codelab을 순서대로 진행하는 경우 학습 효과를 극대화할 수 있지만 순서를 바꿔 진행해도 괜찮습니다. 모든 과정 Codelab은 Kotlin Codelab의 고급 Android Codelab 방문 페이지에 나열되어 있습니다.

소개

이 두 번째 테스트 Codelab은 테스트 더블에 관한 것으로, Android에서 사용하는 경우와 종속 항목 삽입, 서비스 로케이터 패턴, 라이브러리를 사용하여 구현하는 방법을 참조하세요. 이를 통해 다음과 같은 방법을 배우게 됩니다.

  • 저장소 단위 테스트
  • 프래그먼트 및 ViewModel 통합 테스트
  • 프래그먼트 탐색 테스트

기본 요건

다음을 잘 알고 있어야 합니다.

  • Kotlin 프로그래밍 언어
  • 첫 번째 Codelab에서 다룬 테스트 개념: JUnit, Hamcrest, AndroidX 테스트, Robolectric, LiveData 테스트 사용을 사용하여 Android에서 단위 테스트 작성 및 실행
  • ViewModel, LiveData, 탐색 구성요소와 같은 핵심 Android Jetpack 라이브러리
  • 앱 아키텍처 가이드Android Fundamentals Codelab의 패턴을 따르는 애플리케이션 아키텍처
  • Android의 코루틴 기본사항

학습할 내용

  • 테스트 전략 계획 방법
  • 테스트 더블(예: 가짜 및 모의)을 만들고 사용하는 방법
  • 단위 및 통합 테스트에 Android에서 수동 종속 항목 삽입을 사용하는 방법
  • 서비스 로케이터 패턴 적용 방법
  • 저장소, 프래그먼트, 뷰 모델 및 탐색 구성요소를 테스트하는 방법

다음 라이브러리와 코드 개념을 사용하게 됩니다.

실습할 내용

  • 테스트 double 및 종속 항목 삽입을 사용하여 저장소의 단위 테스트를 작성합니다.
  • 테스트 double 및 종속 항목 삽입을 사용하여 뷰 모델의 단위 테스트를 작성합니다.
  • Espresso UI 테스트 프레임워크를 사용하여 프래그먼트와 뷰 모델의 통합 테스트를 작성합니다.
  • Mockito 및 Espresso를 사용하여 탐색 테스트를 작성합니다.

일련의 Codelab에서는 할 일 메모 앱을 사용하여 작업합니다. 이 앱을 사용하여 완료할 작업을 작성하고 목록에 표시할 수 있습니다. 그런 다음 완료로 표시하거나 필터링하거나 삭제할 수 있습니다.

이 앱은 Kotlin으로 작성되었으며 몇 개의 화면이 있고 Jetpack 구성요소를 사용하며 앱 아키텍처 가이드의 아키텍처를 따릅니다. 이 앱의 테스트 방법을 학습하면 동일한 라이브러리와 아키텍처를 사용하는 앱을 테스트할 수 있습니다.

코드 다운로드

시작하려면 코드를 다운로드합니다.

ZIP 파일 다운로드

또는 코드에 관한 GitHub 저장소를 클론해도 됩니다.

$ git clone https://github.com/googlecodelabs/android-testing.git
$ cd android-testing
$ git checkout end_codelab_1

잠시 아래 안내에 따라 코드를 숙지하세요.

1단계: 샘플 앱 실행

할 일 앱을 다운로드한 후 Android 스튜디오에서 앱을 열고 실행합니다. 컴파일됩니다. 다음 단계에 따라 앱을 탐색합니다.

  • 더하기 작업 버튼을 사용하여 새 작업을 만듭니다. 먼저 제목을 입력하고 할 일에 대한 추가 정보를 입력합니다. 녹색 체크표시 FAB로 저장합니다.
  • 할 일 목록에서 방금 완료한 할 일의 제목을 클릭하고 할 일의 세부정보 화면을 통해 설명의 나머지 부분을 확인합니다.
  • 목록이나 세부정보 화면에서 작업의 체크박스를 선택하여 완료됨으로 설정합니다.
  • 할 일 화면으로 돌아가서 필터 메뉴를 열고 활성완료 상태로 할 일을 필터링합니다.
  • 탐색 창을 열고 통계를 클릭합니다.
  • 개요 화면으로 돌아가서 탐색 창 메뉴에서 완료를 선택하여 완료됨 상태의 모든 작업을 삭제합니다.

2단계: 샘플 앱 코드 살펴보기

TO-DO 앱은 인기 있는 아키텍처 청사진 테스트 및 아키텍처 샘플 (샘플의 반응형 아키텍처 버전 사용)을 기반으로 합니다. 앱은 앱 아키텍처 가이드의 아키텍처를 따릅니다. 프래그먼트, 저장소, Room과 함께 ViewModel을 사용합니다. 위의 예시에 익숙한 경우 이 앱의 아키텍처는 비슷합니다.

어떤 레이어에서든 로직을 심층적으로 이해하기보다는 앱의 일반적인 아키텍처를 이해하는 것이 중요합니다.

찾은 패키지 요약은 다음과 같습니다.

패키지: com.example.android.architecture.blueprints.todoapp

.addedittask

할 일 화면 추가 또는 수정: 할 일을 추가하거나 수정하기 위한 UI 레이어 코드입니다.

.data

데이터 영역: 작업의 데이터 영역을 처리합니다. 데이터베이스, 네트워크, 저장소 코드가 포함되어 있습니다.

.statistics

통계 화면: 통계 화면의 UI 레이어 코드입니다.

.taskdetail

작업 세부정보 화면: 단일 작업의 UI 레이어 코드입니다.

.tasks

작업 화면: 모든 작업 목록의 UI 레이어 코드

.util

유틸리티 클래스: 앱의 여러 부분에서 사용되는 공유 클래스입니다(예: 여러 화면에서 사용되는 스와이프 새로고침 레이아웃).

데이터 영역 (.data)

이 앱은 remote 패키지에 있는 시뮬레이션된 네트워킹 레이어와 local 패키지에 데이터베이스 레이어가 포함되어 있습니다. 편의를 위해 이 프로젝트에서는 실제 네트워크 요청이 아닌 지연으로 HashMap만 사용하여 네트워킹 레이어가 시뮬레이션됩니다.

DefaultTasksRepository는 네트워킹 레이어와 데이터베이스 레이어 사이를 조정하거나 조정하며 UI 레이어에 데이터를 반환합니다.

UI 레이어 ( .addedittask, .statistics, .taskdetail, .tasks)

각 UI 레이어 패키지에는 프래그먼트, 뷰 모델과 함께 UI에 필요한 다른 모든 클래스 (예: 작업 목록용 어댑터)가 포함되어 있습니다. TaskActivity는 모든 프래그먼트를 포함하는 활동입니다.

탐색

앱 탐색은 탐색 구성요소를 통해 제어됩니다. 이 파일은 nav_graph.xml 파일에 정의되어 있습니다. 탐색 모델은 뷰 모델에서 Event 클래스를 사용하여 트리거됩니다. 또한 뷰 모델에 전달할 인수를 결정합니다. 프래그먼트는 Event를 관찰하고 화면 간 실제 탐색을 실행합니다.

이 Codelab에서는 테스트 더블과 종속 항목 삽입을 사용하여 저장소, 모델, 프래그먼트를 테스트하는 방법을 알아봅니다. 자세히 알아보기 전에 이러한 테스트를 작성하는 방법과 작성 방법을 안내하는 이유를 이해하는 것이 중요합니다.

이 섹션에서는 Android에 적용되는 일반적인 테스트 권장사항을 다룹니다.

테스트 피라미드

테스트 전략에 관해 고려할 때는 세 가지 관련 테스트 측면이 있습니다.

  • 범위 - 테스트에서 얼마나 많은 코드를 검사하나요? 테스트는 단일 메서드, 전체 애플리케이션 또는 중간 위치에서 실행할 수 있습니다.
  • 속도: 테스트 실행 속도는 어떤가요? 테스트 속도는 밀리초에서 몇 분까지 다양합니다.
  • 충실도: 시험은 얼마나 실제적인가요? 예를 들어 테스트 중 일부에서 네트워크 요청을 해야 하는 경우, 테스트 코드가 실제로 이 네트워크 요청을 하게 되나요, 아니면 결과를 가짜로 요청하나요? 테스트가 네트워크와 실제로 통신한다면 충실도가 더 높다는 의미입니다. 단, 테스트를 실행하는 데 시간이 더 오래 걸리거나 네트워크가 다운되면 오류가 발생하거나 사용 비용이 많이 들 수 있습니다.

이러한 특성 사이에는 절충점이 존재합니다. 예를 들어 속도와 충실도는 절충입니다. 일반적으로 테스트 속도가 빠를수록 충실도가 적고 반대의 경우도 마찬가지입니다. 자동 테스트를 분류하는 한 가지 일반적인 방법은 다음 세 가지 카테고리로 나눌 수 있습니다.

  • 단위 테스트: 고도로 집중된 단일 테스트로, 보통 이 클래스의 단일 메서드로 실행됩니다. 단위 테스트가 실패하면 코드에서 문제가 있는 위치를 정확히 파악할 수 있습니다. 실제 상황에서는 앱에서 하나의 메서드 또는 클래스를 실행하는 것보다 훨씬 많은 충실도가 있기 때문입니다. 충분히 빠르게 코드를 변경할 때마다 실행됩니다. 대부분 test 소스 세트에서 로컬로 실행되는 테스트입니다. 예: 뷰 모델 및 저장소에서 단일 메서드를 테스트합니다.
  • 통합 테스트: 여러 클래스의 상호작용을 테스트하여 함께 사용할 때 예상대로 작동하는지 확인합니다. 통합 테스트를 구조화하는 한 가지 방법은 작업 저장 기능과 같은 단일 기능을 테스트하도록 하는 것입니다. 단위 테스트보다 더 넓은 코드 범위를 테스트하지만, 충실도가 더 높고 빠른 실행이 특징입니다. 상황에 따라 로컬에서 실행하거나 계측 테스트로 실행할 수 있습니다. 예: 단일 프래그먼트와 뷰 모델 쌍의 모든 기능을 테스트합니다.
  • 엔드 투 엔드 테스트 (E2e): 여러 기능을 함께 사용하여 테스트합니다. 앱의 많은 부분을 테스트하고 실제 사용량을 면밀히 시뮬레이션하므로 일반적으로 속도가 느립니다. 충실도가 가장 높고 애플리케이션이 전체적으로 작동하도록 합니다. 일반적으로 이러한 테스트는 androidTest 소스 세트에 있는 계측 테스트입니다.
    예: 전체 앱을 시작하고 몇 가지 기능을 함께 테스트합니다.

이 테스트에서 권장되는 비율은 피라미드로 나타나며, 대부분의 테스트가 단위 테스트입니다.

아키텍처 및 테스트

다양한 수준의 테스트 피라미드에서 앱을 테스트하는 기능은 본질적으로 앱 아키텍처에 연결되어 있습니다. 예를 들어 극히 잘못 설계된 애플리케이션은 모든 로직을 하나의 메서드 내에 배치할 수 있습니다. 이 테스트는 앱의 대부분을 테스트하는 경향이 있기 때문에 엔드 투 엔드 테스트를 작성할 수 있지만 단위 테스트나 통합 테스트는 작성해야 하나요? 코드를 모두 한곳에 배치하면 단일 단위나 기능과 관련된 코드만 테스트하기가 어렵습니다.

더 나은 접근 방식은 애플리케이션 로직을 여러 메서드와 클래스로 분할하여 각 요소를 개별적으로 테스트하는 것입니다. 아키텍처는 코드를 분할하고 체계화하는 방법으로, 단위 및 통합 테스트를 더 쉽게 할 수 있습니다. 테스트할 할 일 앱은 다음과 같은 특정 아키텍처를 따릅니다.



이 강의에서는 위 아키텍처의 일부를 개별적으로 격리하는 방법을 알아봅니다.

  1. 먼저 저장소단위 테스트합니다.
  2. 그런 다음 뷰 모델에서 이체 테스트 및 뷰 모델 통합 테스트에 필요한 테스트를 두 번 사용합니다.
  3. 다음으로 프래그먼트와 뷰 모델통합 테스트를 작성하는 방법을 알아봅니다.
  4. 마지막으로 탐색 구성요소가 포함된 통합 테스트를 작성하는 방법을 알아봅니다.

엔드 투 엔드 테스트는 다음 강의에서 다룰 예정입니다.

클래스의 일부 (메서드 또는 소규모 메서드 모음)에 대한 단위 테스트를 작성하는 경우 목표는 해당 클래스의 코드만 테스트하는 것입니다.

특정 클래스의 코드만 테스트하기는 까다로울 수 있습니다. 예를 살펴보겠습니다. main 소스 세트에서 data.source.DefaultTaskRepository 클래스를 엽니다. 이는 앱의 저장소이며, 다음에 사용할 단위 테스트를 작성할 클래스입니다.

목표는 해당 클래스의 코드만 테스트하는 것입니다. 그러나 DefaultTaskRepositoryLocalTaskDataSourceRemoteTaskDataSource와 같은 다른 클래스에 종속되어 작동합니다. LocalTaskDataSourceRemoteTaskDataSourceDefaultTaskRepository종속 항목이라는 방법도 있습니다.

따라서 DefaultTaskRepository의 모든 메서드는 데이터 소스 클래스의 메서드를 호출하여 다른 클래스의 메서드를 호출하여 데이터베이스에 정보를 저장하거나 네트워크와 통신합니다.



예를 들어 DefaultTasksRepo에서 이 메서드를 살펴보세요.

    suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>> {
        if (forceUpdate) {
            try {
                updateTasksFromRemoteDataSource()
            } catch (ex: Exception) {
                return Result.Error(ex)
            }
        }
        return tasksLocalDataSource.getTasks()
    }

getTasks는 저장소에 대해 호출할 수 있는 가장 기본적인 '기본' 호출 중 하나입니다. 이 메서드에는 SQLite 데이터베이스에서 읽기, 네트워크 호출 (updateTasksFromRemoteDataSource 호출)이 포함됩니다. 여기에는 저장소 코드 사용하는 것보다 훨씬 많은 코드가 포함됩니다.

다음은 저장소 테스트가 어려운 몇 가지 구체적인 이유입니다.

  • 이 저장소에 가장 간단한 테스트를 실행하기 위해 데이터베이스를 만들고 관리하는 것에 관해 생각해야 합니다. 여기에서 '로컬 또는 계측 테스트'여야 하고, AndroidX 테스트를 사용하여 시뮬레이션된 Android 환경을 가져와야 하는 경우 등의 질문이 표시됩니다.
  • 네트워킹 코드와 같은 코드의 일부는 실행하는 데 시간이 오래 걸리거나 실패할 때도 있으며, 오래 실행되는 불안정한 테스트를 만들 수도 있습니다.
  • 테스트는 테스트 실패로 인해 어떤 코드가 잘못되었는지 진단하지 못할 수 있습니다. 테스트로 저장소가 아닌 코드 테스트가 시작되었을 수 있습니다. 예를 들어 가정된 "저장소" 단위 테스트는 데이터베이스 코드와 같은 일부 종속 코드 문제로 인해 실패할 수 있습니다.

테스트 더블

이 문제를 해결하는 방법은 저장소를 테스트할 때 실제 네트워킹 또는 데이터베이스 코드를 사용하지 않고 테스트 더블을 사용하는 것입니다. 테스트 더블은 테스트용으로 특별히 만들어진 클래스의 버전입니다. 테스트에서 클래스의 실제 버전을 대체하기 위한 것입니다. 스턴트가 스턴트를 전문적으로 하는 반면 실제 작업자는 위험한 행동을 대신하는 것과 유사합니다.

다음은 테스트 더블의 몇 가지 유형입니다.

가짜

클래스의 구현이 있지만 테스트용으로는 적합하지만 프로덕션에 적합하지 않은 방식으로 구현된 테스트 더블입니다.

모의

호출된 메서드를 추적하는 테스트 더블입니다. 그런 다음, 메서드의 올바르게 호출되었는지에 따라 테스트를 통과하거나 실패합니다.

스텁

로직이 포함되지 않고 반환하도록 프로그래밍한 결과만 반환하는 테스트 더블입니다. 예를 들어 StubTaskRepository를 프로그래밍하여 getTasks에서 특정 작업 조합을 반환할 수 있습니다.

더미

매개변수로 전달되어야 하는 경우와 같이 전달되었으나 사용되지 않는 테스트 더블입니다. NoOpTaskRepository가 있다면 어떤 메서드에서든 아니요 코드로 TaskRepository를 구현하면 됩니다.

스파이

일부 추가 정보를 추적하는 테스트 더블입니다. 예를 들어 SpyTaskRepository를 만든 경우 addTask 메서드가 호출된 횟수를 추적할 수 있습니다.

테스트 더블에 관한 자세한 내용은 변기에서 테스트: 테스트 더블 이해하기를 참고하세요.

Android에서 사용되는 가장 일반적인 테스트 더블은 가짜모의입니다.

이 작업에서는 FakeDataSource 테스트를 이중으로 만들어 단위 테스트를 DefaultTasksRepository하고 실제 데이터 소스에서 분리합니다.

1단계: FakeDataSource 클래스 만들기

이 단계에서는 FakeDataSouce의 클래스를 만듭니다. 이 클래스는 LocalDataSourceRemoteDataSource의 테스트 더블입니다.

  1. test 소스 세트에서 New -> Package를 마우스 오른쪽 버튼으로 클릭합니다.

  1. 안에 source 패키지가 포함된 data 패키지를 만듭니다.
  2. data/source 패키지에 FakeDataSource이라는 새 클래스를 만듭니다.

2단계: TasksDataSource 인터페이스 구현

새 클래스 FakeDataSource을 테스트 더블로 사용하려면 다른 데이터 소스를 교체할 수 있어야 합니다. 데이터 소스는 TasksLocalDataSourceTasksRemoteDataSource입니다.

  1. 두 가지 모두 TasksDataSource 인터페이스를 구현하는 방식에 주목하세요.
class TasksLocalDataSource internal constructor(
    private val tasksDao: TasksDao,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }

object TasksRemoteDataSource : TasksDataSource { ... }
  1. FakeDataSource에서 TasksDataSource를 구현하도록 합니다.
class FakeDataSource : TasksDataSource {

}

Android 스튜디오는 TasksDataSource에 필요한 메서드를 구현하지 않았다고 불만을 제기합니다.

  1. 빠른 수정 메뉴를 사용하여 멤버 구현을 선택합니다.


  1. 모든 방법을 선택하고 OK를 누릅니다.

3단계: FakeDataSource에서 getTasks 메서드 구현

FakeDataSource가짜라는 특정한 유형의 테스트 더블입니다. 가짜는 클래스의 '작업 중'인 구현을 사용하지만 테스트용으로는 적합하지만 프로덕션에는 적합하지 않은 방식으로 구현되어 있습니다. "Working" 구현은 클래스가 지정된 입력에 사실적인 출력을 생성한다는 것을 의미합니다.

예를 들어 가짜 데이터 소스는 네트워크에 연결하거나 데이터베이스에 아무것도 저장하지 않는 대신 메모리 내 목록만 사용합니다. 이렇게 하면 작업을 가져오거나 저장하는 메서드가 예상한 결과를 반환하지만, 프로덕션 환경에서 서버나 데이터베이스에 저장되지 않으므로 이 구현을 사용하면 안 됩니다.

FakeDataSource

  • 실제 데이터베이스나 네트워크에 의존하지 않고 DefaultTasksRepository에서 코드를 테스트할 수 있습니다.
  • 테스트를 위한 \'실제로 충분한\' 구현을 제공합니다.
  1. FakeDataSource 생성자를 변경하여 변경 가능한 빈 목록의 기본값이 있는 MutableList<Task>?tasks라는 var를 만듭니다.
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }


데이터베이스 또는 서버 응답인 '가짜' 작업 목록입니다. 일단 목표는 repository'sgetTasks 메서드를 테스트하는 것입니다. 이를 통해 데이터 소스 getTasks, deleteAllTasks, saveTask 메서드가 호출됩니다.

이러한 메서드의 가짜 버전을 작성합니다.

  1. getTasks 쓰기: tasksnull가 아니면 Success 결과를 반환합니다. tasksnull이면 Error 결과를 반환합니다.
  2. deleteAllTasks을 작성합니다. 변경 가능한 할 일 목록을 삭제합니다.
  3. saveTask를 작성하면 목록에 작업을 추가합니다.

FakeDataSource용으로 구현된 이러한 메서드는 아래 코드와 같습니다.

override suspend fun getTasks(): Result<List<Task>> {
    tasks?.let { return Success(ArrayList(it)) }
    return Error(
        Exception("Tasks not found")
    )
}


override suspend fun deleteAllTasks() {
    tasks?.clear()
}

override suspend fun saveTask(task: Task) {
    tasks?.add(task)
}

필요한 경우 import 문은 다음과 같습니다.

import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task

이는 실제 로컬 및 원격 데이터 소스의 작동 방식과 유사합니다.

이 단계에서는 방금 만든 가짜 테스트를 사용할 수 있도록 수동 종속 항목 삽입이라는 기법을 사용합니다.

주요 문제는 FakeDataSource가 있지만 테스트에서 그것을 어떻게 사용하는지 명확하지 않다는 점입니다. 테스트에서만 TasksRemoteDataSourceTasksLocalDataSource를 대체해야 합니다. TasksRemoteDataSourceTasksLocalDataSource은 모두 DefaultTasksRepository의 종속 항목입니다. 즉, DefaultTasksRepositories를 실행하려면 이 클래스에서 TasksLocalDataSource 또는 필수사항입니다.

현재 종속 항목은 DefaultTasksRepositoryinit 메서드 내에서 구성됩니다.

DefaultTasksRepository.kt

class DefaultTasksRepository private constructor(application: Application) {

    private val tasksRemoteDataSource: TasksDataSource
    private val tasksLocalDataSource: TasksDataSource

   // Some other code

    init {
        val database = Room.databaseBuilder(application.applicationContext,
            ToDoDatabase::class.java, "Tasks.db")
            .build()

        tasksRemoteDataSource = TasksRemoteDataSource
        tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
    }
    // Rest of class
}

DefaultTasksRepository 내에서 taskLocalDataSourcetasksRemoteDataSource를 만들고 할당하므로 기본적으로 하드 코딩됩니다. 테스트를 중복으로 대체할 수 있는 방법은 없습니다.

하드 코딩하는 대신 이러한 데이터 소스를 클래스에 제공하면 됩니다. 종속 항목을 제공하는 것을 종속 항목 삽입이라고 합니다. 종속 항목을 제공하는 데는 여러 가지 방법이 있으므로 종속 항목 유형도 다릅니다.

생성자 종속 항목 삽입을 사용하면 생성자에 전달하여 테스트에서 두 번 교체할 수 있습니다.

삽입되지 않음

삽입

1단계: DefaultTasksRepository에서 생성자 종속 항목 삽입 사용

  1. DefaultTaskRepository의 생성자를 Application에서 가져온 다음 데이터 소스와 코루틴 디스패처를 모두 사용하도록 변경합니다. 이는 테스트를 위해 스왑해야 합니다. 자세한 내용은 코루틴에 관한 세 번째 과정 섹션을 참조하세요.

DefaultTasksRepository.kt

// REPLACE
class DefaultTasksRepository private constructor(application: Application) { // Rest of class }

// WITH

class DefaultTasksRepository(
    private val tasksRemoteDataSource: TasksDataSource,
    private val tasksLocalDataSource: TasksDataSource,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { // Rest of class }
  1. 종속 항목을 전달했으므로 init 메서드를 삭제합니다. 이제 종속 항목을 만들 필요가 없습니다.
  2. 이전 인스턴스 변수도 삭제합니다. 생성자에서 정의합니다.

DefaultTasksRepository.kt

// Delete these old variables
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
  1. 마지막으로 getRepository 메서드를 업데이트하여 새 생성자를 사용합니다.

DefaultTasksRepository.kt

    companion object {
        @Volatile
        private var INSTANCE: DefaultTasksRepository? = null

        fun getRepository(app: Application): DefaultTasksRepository {
            return INSTANCE ?: synchronized(this) {
                val database = Room.databaseBuilder(app,
                    ToDoDatabase::class.java, "Tasks.db")
                    .build()
                DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
                    INSTANCE = it
                }
            }
        }
    }

이제 생성자 종속 항목 삽입을 사용하고 있습니다.

2단계: 테스트에서 FakeDataSource 사용

이제 코드에서 생성자 종속 항목 삽입을 사용하고 있으므로 가짜 데이터 소스를 사용하여 DefaultTasksRepository를 테스트할 수 있습니다.

  1. DefaultTasksRepository 클래스 이름을 마우스 오른쪽 버튼으로 클릭하고 GenerateTest를 차례로 선택합니다.
  2. 안내에 따라 테스트 소스 세트에 DefaultTasksRepositoryTest을 만듭니다.
  3. DefaultTasksRepositoryTest 클래스 상단에 아래 멤버 변수를 추가하여 가짜 데이터 소스의 데이터를 나타냅니다.

DefaultTasksRepositoryTest.kt

    private val task1 = Task("Title1", "Description1")
    private val task2 = Task("Title2", "Description2")
    private val task3 = Task("Title3", "Description3")
    private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
    private val localTasks = listOf(task3).sortedBy { it.id }
    private val newTasks = listOf(task3).sortedBy { it.id }
  1. 변수 3개, 즉 FakeDataSource 멤버 변수 2개 (저장소의 각 데이터 소스당 1개)와 테스트할 DefaultTasksRepository 변수 1개를 만듭니다.

DefaultTasksRepositoryTest.kt

    private lateinit var tasksRemoteDataSource: FakeDataSource
    private lateinit var tasksLocalDataSource: FakeDataSource

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

테스트 가능한 DefaultTasksRepository를 설정하고 초기화하는 메서드를 만듭니다. 이 DefaultTasksRepository는 테스트 더블 FakeDataSource를 사용합니다.

  1. createRepository라는 메서드를 만들고 @Before 주석을 추가합니다.
  2. remoteTaskslocalTasks 목록을 사용하여 가짜 데이터 소스를 인스턴스화합니다.
  3. 방금 만든 두 개의 가짜 데이터 소스와 Dispatchers.Unconfined를 사용하여 tasksRepository를 인스턴스화합니다.

최종 메서드는 아래의 코드와 같이 표시됩니다.

DefaultTasksRepositoryTest.kt

    @Before
    fun createRepository() {
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
        // Get a reference to the class under test
        tasksRepository = DefaultTasksRepository(
            // TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
            //  this requires understanding more about coroutines + testing
            //  so we will keep this as Unconfined for now.
            tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }

3단계: DefaultTasksRepository getTasks() 테스트 작성

이제 DefaultTasksRepository 테스트를 작성할 차례입니다.

  1. 저장소의 getTasks 메서드 테스트를 작성합니다. true를 사용하여 getTasks를 호출할 때(즉, 원격 데이터 소스에서 새로고침해야 함) 로컬 데이터 소스가 아닌 원격 데이터 소스에서 데이터를 반환하는지 확인합니다.

DefaultTasksRepositoryTest.kt

@Test
    fun getTasks_requestsAllTasksFromRemoteDataSource(){
        // When tasks are requested from the tasks repository
        val tasks = tasksRepository.getTasks(true) as Success

        // Then tasks are loaded from the remote data source
        assertThat(tasks.data, IsEqual(remoteTasks))
    }

getTasks:에 전화하면 오류가 발생합니다.

4단계: runBlockingTest 추가

getTaskssuspend 함수이며 코루틴을 호출하여 호출해야 하므로 코루틴 오류가 예상됩니다. 이를 위해 코루틴 범위가 필요합니다. 이 오류를 해결하려면 테스트에서 코루틴 실행을 처리하기 위해 Gradle 종속 항목을 추가해야 합니다.

  1. testImplementation를 사용하여 코루틴을 테스트하는 데 필요한 종속 항목을 테스트 소스 세트에 추가합니다.

app/build.gradle

testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

동기화를 잊지 마세요

kotlinx-coroutines-test는 코루틴 테스트를 위한 코루틴 테스트 라이브러리입니다. 테스트를 실행하려면 runBlockingTest 함수를 사용합니다. 코루틴 테스트 라이브러리에서 제공하는 함수입니다. 동기식으로 즉시 실행되는 특수한 코루틴 컨텍스트에서 코드 블록을 가져온 다음 이 코드 블록을 실행합니다. 즉, 확정적인 순서로 작업이 실행됩니다. 이렇게 하면 코루틴이 코루틴이 아닌 코루틴처럼 실행되므로 코드를 테스트할 수 있습니다.

suspend 함수를 호출할 때 테스트 클래스에서 runBlockingTest를 사용합니다. 이 시리즈의 다음 Codelab에서 runBlockingTest의 작동 방식과 코루틴을 테스트하는 방법을 자세히 알아봅니다.

  1. 클래스 위에 @ExperimentalCoroutinesApi를 추가합니다. 이는 클래스에서 실험용 코루틴 API(runBlockingTest)를 사용하고 있다는 것을 나타냅니다. 이 기능이 없으면 경고를 받게 됩니다.
  2. DefaultTasksRepositoryTest로 돌아가서 runBlockingTest를 추가하여 전체 테스트를 '코드'로 가져옵니다.

이 최종 테스트는 아래 코드와 같습니다.

DefaultTasksRepositoryTest.kt

import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.hamcrest.core.IsEqual
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test


@ExperimentalCoroutinesApi
class DefaultTasksRepositoryTest {

    private val task1 = Task("Title1", "Description1")
    private val task2 = Task("Title2", "Description2")
    private val task3 = Task("Title3", "Description3")
    private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
    private val localTasks = listOf(task3).sortedBy { it.id }
    private val newTasks = listOf(task3).sortedBy { it.id }

    private lateinit var tasksRemoteDataSource: FakeDataSource
    private lateinit var tasksLocalDataSource: FakeDataSource

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

    @Before
    fun createRepository() {
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
        // Get a reference to the class under test
        tasksRepository = DefaultTasksRepository(
            // TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
            //  this requires understanding more about coroutines + testing
            //  so we will keep this as Unconfined for now.
            tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }

    @Test
    fun getTasks_requestsAllTasksFromRemoteDataSource() = runBlockingTest {
        // When tasks are requested from the tasks repository
        val tasks = tasksRepository.getTasks(true) as Success

        // Then tasks are loaded from the remote data source
        assertThat(tasks.data, IsEqual(remoteTasks))
    }

}
  1. getTasks_requestsAllTasksFromRemoteDataSource 테스트를 실행하고 오류가 해결되었는지 확인합니다.

저장소 단위 테스트 방법을 살펴보았습니다. 다음 단계에서는 종속 항목 삽입을 다시 사용하고 또 다른 테스트를 만들어 보겠습니다. 이번에는 뷰 모델의 단위 및 통합 테스트를 작성하는 방법을 알아봅니다.

단위 테스트는 관심 있는 클래스 또는 메서드 테스트해야 합니다. 이를 격리 테스트라고 합니다. 이때 '단위'를 명확하게 격리하여 해당 단위의 일부인 코드만 테스트합니다.

따라서 TasksViewModelTestTasksViewModel 코드만 테스트해야 합니다. 데이터베이스, 네트워크, 저장소 클래스에서 테스트해서는 안 됩니다. 따라서 저장소 모델에서와 마찬가지로 뷰 모델의 경우 가짜 저장소를 만들고 종속 항목 삽입을 적용하여 테스트에 사용합니다.

이 작업에서는 종속 항목 삽입을 적용하여 모델을 확인합니다.

1단계: TasksRepository 인터페이스 만들기

생성자 종속 항목 삽입을 사용하는 첫 번째 단계는 가짜 클래스와 실제 클래스 간에 공유되는 공통 인터페이스를 만드는 것입니다.

실제 작동 방식 TasksRemoteDataSource, TasksLocalDataSource, FakeDataSource를 확인해 보니 모두 동일한 인터페이스(TasksDataSource)를 공유하고 있습니다. 이렇게 하면 TasksDataSource를 가져오는 DefaultTasksRepository의 생성자에서 말할 수 있습니다.

DefaultTasksRepository.kt

class DefaultTasksRepository(
   private val tasksRemoteDataSource: TasksDataSource,
   private val tasksLocalDataSource: TasksDataSource,
   private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {

FakeDataSource 기기를 교환하실 수 있습니다.

이제 데이터 소스와 마찬가지로 DefaultTasksRepository의 인터페이스를 만듭니다. DefaultTasksRepository의 모든 공개 메서드 (공개 API 노출 영역)를 포함해야 합니다.

  1. DefaultTasksRepository을 열고 클래스 이름을 마우스 오른쪽 버튼으로 클릭합니다. 그런 다음 Refactor -> Extract -> Interface를 선택합니다.

  1. 추출하여 파일 구분을 선택합니다.

  1. Extract Interface 창에서 인터페이스 이름을 TasksRepository로 변경합니다.
  2. 양식을 작성할 구성원 섹션에서 두 컴패니언 구성원 비공개 메서드를 제외한 모든 구성원을 확인합니다.


  1. Refactor를 클릭합니다. 새 TasksRepository 인터페이스가 data/source 패키지에 표시됩니다.

이제 DefaultTasksRepositoryTasksRepository를 구현합니다.

  1. 앱을 실행하여 테스트가 아니라 모든 것이 제대로 작동하는지 확인합니다.

2단계: FakeTestRepository 만들기

이제 인터페이스가 있으므로 DefaultTaskRepository 테스트를 두 번 만들 수 있습니다.

  1. 테스트 소스 세트의 데이터/소스에서 Kotlin 파일과 클래스 FakeTestRepository.kt를 만들고 TasksRepository 인터페이스에서 확장합니다.

FakeTestRepository.kt

class FakeTestRepository : TasksRepository  {
}

인터페이스 메서드를 구현해야 한다는 메시지가 표시됩니다.

  1. 오류 위로 마우스를 가져가면 추천 메뉴가 표시되고 구성원 구현을 클릭하여 선택할 수 있습니다.
  1. 모든 방법을 선택하고 OK를 누릅니다.

3단계: FakeTestRepository 메서드 구현

이제 '구현되지 않은' 메서드가 있는 FakeTestRepository 클래스가 있습니다. FakeDataSource를 구현한 방법과 유사하게 FakeTestRepository는 로컬 및 원격 데이터 소스 간의 복잡한 미디에이션을 처리하는 대신 데이터 구조를 기반으로 합니다.

FakeTestRepositoryFakeDataSource 또는 이와 유사한 것을 사용할 필요가 없으며 입력만 고려하여 실제 가짜 출력을 반환하기만 하면 됩니다. LinkedHashMap를 사용하여 작업 목록과 관찰 가능한 작업을 위한 MutableLiveData을 저장합니다.

  1. FakeTestRepository에서 현재 작업의 목록을 나타내는 LinkedHashMap 변수와 식별 가능한 작업의 MutableLiveData를 모두 추가합니다.

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private val observableTasks = MutableLiveData<Result<List<Task>>>()


    // Rest of class
}

다음 메서드를 구현합니다.

  1. getTasks: 이 메서드는 tasksServiceData를 사용하고 tasksServiceData.values.toList()를 사용하여 목록으로 변환한 다음 Success 결과로 반환해야 합니다.
  2. refreshTasks: observableTasks의 값을 getTasks()에서 반환하는 값으로 업데이트합니다.
  3. observeTasks: runBlocking를 사용하여 코루틴을 만들고 refreshTasks를 실행한 다음 observableTasks를 반환합니다.

다음은 이러한 메서드의 코드입니다.

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private val observableTasks = MutableLiveData<Result<List<Task>>>()

    override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
        return Result.Success(tasksServiceData.values.toList())
    }

    override suspend fun refreshTasks() {
        observableTasks.value = getTasks()
    }

    override fun observeTasks(): LiveData<Result<List<Task>>> {
        runBlocking { refreshTasks() }
        return observableTasks
    }

    // Rest of class

}

4단계: addTasks에 테스트하는 메서드 추가

테스트할 때는 이미 Tasks를 저장소에 포함하는 것이 좋습니다. saveTask를 여러 번 호출할 수 있지만, 이를 쉽게 하기 위해 작업을 추가할 수 있는 테스트용 도우미 메서드를 추가합니다.

  1. 작업의 vararg를 가져와 HashMap에 각각 추가하고 작업을 새로고침하는 addTasks 메서드를 추가합니다.

FakeTestRepository.kt

    fun addTasks(vararg tasks: Task) {
        for (task in tasks) {
            tasksServiceData[task.id] = task
        }
        runBlocking { refreshTasks() }
    }

이제 몇 가지 주요 메서드를 사용하여 테스트할 수 있는 가짜 저장소가 있습니다. 이제 테스트에서 이를 사용합니다.

이 작업에서는 ViewModel 내부에서 가짜 클래스를 사용합니다. 생성자 종속 항목 삽입을 사용하면 TasksViewModel 및 생성자에 TasksRepository 변수를 추가하여 생성자 종속 항목 삽입을 통해 두 데이터 소스를 가져올 수 있습니다.

이 프로세스는 뷰 모델을 직접 구성하지 않으므로 뷰 모델과 약간 다릅니다. 예를 들면 다음과 같습니다.

class TasksFragment : Fragment() {

    private val viewModel by viewModels<TasksViewModel>()
    
    // Rest of class...

}


위 코드에서와 같이 viewModel's 속성 위임을 사용하여 뷰 모델을 만듭니다. 뷰 모델의 구성 방식을 변경하려면 ViewModelProvider.Factory를 추가하고 사용해야 합니다. ViewModelProvider.Factory에 익숙하지 않은 경우 여기에서 자세히 알아보세요.

1단계: TasksViewModel에서 ViewModelFactory 만들기 및 사용

먼저 Tasks 화면과 관련된 클래스 및 테스트를 업데이트합니다.

  1. TasksViewModel을(를) 엽니다.
  2. 클래스 내부에서 구성하는 대신 TasksRepository를 사용하도록 TasksViewModel의 생성자를 변경합니다.

TasksViewModel.kt

// REPLACE
class TasksViewModel(application: Application) : AndroidViewModel(application) {

    private val tasksRepository = DefaultTasksRepository.getRepository(application)

    // Rest of class
}

// WITH

class TasksViewModel( private val tasksRepository: TasksRepository ) : ViewModel() { 
    // Rest of class 
}

생성자를 변경했으므로 이제 팩토리를 사용하여 TasksViewModel를 구성해야 합니다. 팩토리 클래스를 TasksViewModel와 동일한 파일에 넣지만 자체 파일에 넣을 수도 있습니다.

  1. TasksViewModel 파일 하단에 있는 클래스 외부에서 일반 TasksRepository를 사용하는 TasksViewModelFactory을 추가합니다.

TasksViewModel.kt

@Suppress("UNCHECKED_CAST")
class TasksViewModelFactory (
    private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
    override fun <T : ViewModel> create(modelClass: Class<T>) =
        (TasksViewModel(tasksRepository) as T)
}


ViewModel의 구성 방식을 변경하는 표준 방법입니다. 팩토리를 만들었으므로 이제 뷰 모델을 구성할 때마다 팩토리를 사용합니다.

  1. TasksFragment를 업데이트하여 팩토리를 사용하세요.

TasksFragment.kt

// REPLACE
private val viewModel by viewModels<TasksViewModel>()

// WITH

private val viewModel by viewModels<TasksViewModel> {
    TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. 코드를 실행하고 모든 부분이 제대로 작동하는지 확인합니다.

2단계: TasksViewModelTest 내에서 FakeTestRepository 사용

이제 뷰 모델 테스트에서 실제 저장소를 사용하는 대신 가짜 저장소를 사용할 수 있습니다.

  1. TasksViewModelTest엽니다.
  2. TasksViewModelTestFakeTestRepository 속성을 추가합니다.

TaskViewModelTest.kt를 참조하세요.

@RunWith(AndroidJUnit4::class)
class TasksViewModelTest {

    // Use a fake repository to be injected into the viewmodel
    private lateinit var tasksRepository: FakeTestRepository
    
    // Rest of class
}
  1. setupViewModel 메서드를 업데이트하여 작업이 세 개 있는 FakeTestRepository를 만든 다음 이 저장소로 tasksViewModel를 구성합니다.

TasksViewModelTest.kt를 참조하세요.

    @Before
    fun setupViewModel() {
        // We initialise the tasks to 3, with one active and two completed
        tasksRepository = FakeTestRepository()
        val task1 = Task("Title1", "Description1")
        val task2 = Task("Title2", "Description2", true)
        val task3 = Task("Title3", "Description3", true)
        tasksRepository.addTasks(task1, task2, task3)

        tasksViewModel = TasksViewModel(tasksRepository)
        
    }
  1. 더 이상 AndroidX 테스트 ApplicationProvider.getApplicationContext 코드를 사용하지 않으므로 @RunWith(AndroidJUnit4::class) 주석을 삭제할 수도 있습니다.
  2. 테스트를 실행하고 모두 작동하는지 확인합니다.

이제 생성자 종속 항목 삽입을 사용하여 종속 항목으로 DefaultTasksRepository를 삭제하고 테스트에서 FakeTestRepository로 대체했습니다.

3단계: TaskDetail 프래그먼트 및 ViewModel도 업데이트

TaskDetailFragmentTaskDetailViewModel을 정확하게 동일하게 변경합니다. 그러면 다음에 TaskDetail 테스트를 작성할 때 사용할 코드가 준비됩니다.

  1. TaskDetailViewModel을(를) 엽니다.
  2. 생성자를 업데이트합니다.

TaskDetailViewModel.kt

// REPLACE
class TaskDetailViewModel(application: Application) : AndroidViewModel(application) {

    private val tasksRepository = DefaultTasksRepository.getRepository(application)

    // Rest of class
}

// WITH

class TaskDetailViewModel(
    private val tasksRepository: TasksRepository
) : ViewModel() { // Rest of class }
  1. TaskDetailViewModel 파일 하단에 있는 클래스 외부에 TaskDetailViewModelFactory를 추가합니다.

TaskDetailViewModel.kt

@Suppress("UNCHECKED_CAST")
class TaskDetailViewModelFactory (
    private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
    override fun <T : ViewModel> create(modelClass: Class<T>) =
        (TaskDetailViewModel(tasksRepository) as T)
}
  1. TasksFragment를 업데이트하여 팩토리를 사용하세요.

TasksFragment.kt

// REPLACE
private val viewModel by viewModels<TaskDetailViewModel>()

// WITH

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. 코드를 실행하고 모든 것이 작동하는지 확인합니다.

이제 TasksFragmentTasksDetailFragment에서 실제 저장소 대신 FakeTestRepository를 사용할 수 있습니다.

다음으로, 프래그먼트 및 뷰 모델 상호작용을 테스트하는 통합 테스트를 작성합니다. 뷰 모델 코드가 적절하게 UI를 업데이트하는지 확인합니다. 이를 위해

  • ServiceLocator 패턴
  • Espresso 및 Mockito 라이브러리

통합 테스트는 여러 클래스의 상호작용을 테스트하여 함께 사용할 때 예상대로 작동하는지 확인합니다. 이러한 테스트는 로컬(test 소스 세트) 또는 계측 테스트(androidTest 소스 세트)로 실행할 수 있습니다.

여기에서는 각 프래그먼트를 사용하고, 프래그먼트의 주요 기능을 테스트하기 위해 프래그먼트 및 뷰 모델의 통합 테스트를 작성합니다.

1단계: Gradle 종속 항목 추가

  1. 다음 Gradle 종속 항목을 추가합니다.

app/build.gradle

    // 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"

이러한 종속 항목은 다음과 같습니다.

  • junit:junit: JUnit. 기본 테스트 문을 작성하는 데 필요합니다.
  • androidx.test:core - 핵심 AndroidX 테스트 라이브러리
  • kotlinx-coroutines-test - 코루틴 테스트 라이브러리
  • androidx.fragment:fragment-testing: 테스트에서 프래그먼트를 만들고 상태를 변경하기 위한 AndroidX 테스트 라이브러리입니다.

이러한 라이브러리를 androidTest 소스 세트에 사용하므로 androidTestImplementation을 사용하여 종속 항목으로 추가합니다.

2단계: TaskDetailFragmentTest 클래스 만들기

TaskDetailFragment는 단일 작업에 대한 정보를 표시합니다.

먼저 다른 프래그먼트에 비해 기본적인 기능이 있으므로 TaskDetailFragment의 프래그먼트 테스트를 작성합니다.

  1. taskdetail.TaskDetailFragment을(를) 엽니다.
  2. 이전에 한 것처럼 TaskDetailFragment 테스트를 생성합니다. 기본 옵션을 수락하고 test 소스 세트가 아닌 androidTest 소스 세트에 배치합니다.

  1. 다음 주석을 TaskDetailFragmentTest 클래스에 추가합니다.

TaskDetailFragmentTest.kt

@MediumTest
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {

}

이 주석의 목적은 다음과 같습니다.

  • @MediumTest: 테스트를 '중간 런타임' 통합 테스트 (@SmallTest 단위 테스트 및 @LargeTest 대규모 엔드 투 엔드 테스트와 비교)로 표시합니다. 이렇게 하면 실행할 테스트 크기를 그룹화하고 선택할 수 있습니다.
  • @RunWith(AndroidJUnit4::class): AndroidX 테스트를 사용하는 모든 클래스에서 사용됩니다.

3단계: 테스트에서 프래그먼트 실행

이 작업에서는 AndroidX 테스팅 라이브러리를 사용하여 TaskDetailFragment를 실행합니다. FragmentScenario은 프래그먼트를 둘러싸고 테스트의 프래그먼트 수명 주기를 직접 제어할 수 있게 하는 AndroidX 테스트의 클래스입니다. 프래그먼트 테스트를 작성하려면 프래그먼트 테스트 (TaskDetailFragment)에 관한 FragmentScenario를 만듭니다.

  1. 이 테스트를 TaskDetailFragmentTest복사합니다.

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi() {
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

위의 코드는 다음과 같습니다.

아직 어설션이 완료되지 않았으므로 테스트는 아직 완료되지 않았습니다. 지금은 테스트를 실행하고 어떤 일이 일어나는지 관찰합니다.

  1. 계측 테스트이므로 에뮬레이터나 기기가 표시되는지 확인합니다.
  2. 테스트를 실행합니다.

몇 가지 결과가 발생합니다.

  • 먼저, 계측 테스트이므로 테스트가 실제 기기 (연결된 경우) 또는 에뮬레이터에서 실행됩니다.
  • 프래그먼트가 실행됩니다.
  • 다른 프래그먼트를 탐색하거나 활동과 관련된 메뉴가 없는지 확인합니다. 프래그먼트 사용합니다.

마지막으로 프래그먼트가 작업 데이터를 성공적으로 로드하지 않으므로 '데이터 없음'이라고 표시됩니다.

테스트는 둘 다 TaskDetailFragment를 로드했고 데이터가 올바르게 로드되었다고 어설션해야 합니다. 데이터가 없는 이유는 무엇인가요? 작업을 만들었지만 저장소에 저장하지 않았기 때문입니다.

    @Test
    fun activeTaskDetails_DisplayedInUi() {
        // This DOES NOT save the task anywhere
        val activeTask = Task("Active Task", "AndroidX Rocks", false)

        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

FakeTestRepository가 있지만 프래그먼트의 실제 저장소를 가짜 저장소로 바꿀 방법이 필요합니다. 다음에는 할 거예요!

이 작업에서는 ServiceLocator를 사용하여 가짜 저장소를 프래그먼트에 제공합니다. 이렇게 하면 프래그먼트를 작성하고 모델 통합 테스트를 볼 수 있습니다.

이전과 마찬가지로 뷰 모델 또는 저장소에 종속 항목을 제공해야 하는 경우 여기에서 생성자 종속 항목 삽입을 사용할 수 없습니다. 생성자 종속 항목 삽입을 사용하려면 클래스를 구성해야 합니다. 프래그먼트 및 활동은 여러분이 구성하지 않으며 일반적으로 생성자에 액세스할 수 없는 클래스의 예입니다.

프래그먼트를 구성하지 않으므로 생성자 종속 항목 삽입을 사용하여 저장소 테스트 double(FakeTestRepository)을 프래그먼트로 교체할 수 없습니다. 대신 Service Locator 패턴을 사용하세요. 서비스 로케이터 패턴은 종속 항목 삽입의 대안입니다. 여기에는 일반 코드와 테스트 코드 모두에 종속 항목을 제공하는 것이 목적인 "Service Locator"라는 싱글톤 클래스를 만듭니다. 일반 앱 코드 (main 소스 세트)에서 이러한 종속 항목은 모두 일반 앱 종속 항목입니다. 테스트의 경우 종속 항목 테스트 버전을 제공하는 Service Locator를 수정합니다.

Service Locator를 사용하지 않음


서비스 로케이터 사용

이 Codelab 앱에서는 다음 작업을 실행합니다.

  1. 저장소를 구성하고 저장할 수 있는 서비스 로케이터 클래스를 만듭니다. 기본적으로 'normal'\'t; 저장소를 구성합니다.
  2. 코드가 리팩터링될 때 서비스 로케이터를 사용하도록 코드를 리팩터링합니다.
  3. 테스트 클래스에서 Service Locator에서 메서드를 호출하여 “quot;normal" 저장소를 테스트 더블로 교체합니다.

1단계: ServiceLocator 만들기

ServiceLocator 클래스를 만들어 보겠습니다. 기본 앱 코드가 나머지 앱 코드와 함께 상주합니다. 기본 애플리케이션 코드가 이 코드를 사용하기 때문입니다.

참고: ServiceLocator는 싱글톤이므로 클래스에 Kotlin object 키워드를 사용합니다.

  1. 기본 소스 세트의 최상위 수준에 ServiceLocator.kt 파일을 만듭니다.
  2. ServiceLocator이라는 object를 정의합니다.
  3. databaserepository 인스턴스 변수를 만들고 둘 다 null로 설정합니다.
  4. 여러 스레드에서 사용할 수 있으므로 저장소에 @Volatile 주석을 답니다. @Volatile에 관한 자세한 내용은 여기를 참고하세요.

코드는 아래와 같습니다.

object ServiceLocator {

    private var database: ToDoDatabase? = null
    @Volatile
    var tasksRepository: TasksRepository? = null

}

지금은 ServiceLocator에서 TasksRepository를 반환하는 방법을 알아야 합니다. 필요한 경우 기존 DefaultTasksRepository을 반환하거나 필요한 경우 새 DefaultTasksRepository를 만들어 반환합니다.

다음 함수를 정의합니다.

  1. provideTasksRepository: 기존 저장소를 제공하거나 새 저장소를 만듭니다. 이 메서드는 여러 스레드가 실행되는 상황에서 실수로 두 개의 저장소 인스턴스를 만들지 않도록 하려면 this에서 synchronized이어야 합니다.
  2. createTasksRepository: 새 저장소를 만드는 코드 createTaskLocalDataSource를 호출하고 새 TasksRemoteDataSource를 만듭니다.
  3. createTaskLocalDataSource: 새 로컬 데이터 소스를 만드는 코드 createDataBase을 호출합니다.
  4. createDataBase: 새 데이터베이스를 만들기 위한 코드입니다.

완성된 코드는 다음과 같습니다.

ServiceLocator.kt

object ServiceLocator {

    private var database: ToDoDatabase? = null
    @Volatile
    var tasksRepository: TasksRepository? = null

    fun provideTasksRepository(context: Context): TasksRepository {
        synchronized(this) {
            return tasksRepository ?: createTasksRepository(context)
        }
    }

    private fun createTasksRepository(context: Context): TasksRepository {
        val newRepo = DefaultTasksRepository(TasksRemoteDataSource, createTaskLocalDataSource(context))
        tasksRepository = newRepo
        return newRepo
    }

    private fun createTaskLocalDataSource(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, "Tasks.db"
        ).build()
        database = result
        return result
    }
}

2단계: 애플리케이션에서 ServiceLocator 사용

저장소가 아닌 기본 애플리케이션 코드 (테스트가 아님)를 변경하여 ServiceLocator 한곳에서 저장소를 만듭니다.

저장소 클래스 인스턴스를 한 개만 만드는 것이 중요합니다. 이를 위해 내 애플리케이션 클래스에서 서비스 로케이터를 사용합니다.

  1. 패키지 계층 구조의 최상위 수준에서 TodoApplication를 열고 저장소의 val를 만든 다음 ServiceLocator.provideTaskRepository를 사용하여 가져온 저장소를 할당합니다.

TodoApplication.kt

class TodoApplication : Application() {

    val taskRepository: TasksRepository
        get() = ServiceLocator.provideTasksRepository(this)

    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) Timber.plant(DebugTree())
    }
}

이제 애플리케이션에서 저장소를 만들었으므로 DefaultTasksRepository에서 이전 getRepository 메서드를 삭제할 수 있습니다.

  1. DefaultTasksRepository를 열고 컴패니언 객체를 삭제합니다.

DefaultTasksRepository.kt

// DELETE THIS COMPANION OBJECT
companion object {
    @Volatile
    private var INSTANCE: DefaultTasksRepository? = null

    fun getRepository(app: Application): DefaultTasksRepository {
        return INSTANCE ?: synchronized(this) {
            val database = Room.databaseBuilder(app,
                ToDoDatabase::class.java, "Tasks.db")
                .build()
            DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
                INSTANCE = it
            }
        }
    }
}

이제 getRepository를 사용한 모든 곳에서 애플리케이션의 taskRepository을 대신 사용하세요. 이렇게 하면 저장소를 직접 만드는 대신 ServiceLocator가 제공한 모든 저장소를 가져올 수 있습니다.

  1. TaskDetailFragement를 열고 클래스 상단에서 getRepository 호출을 찾습니다.
  2. 이 호출을 TodoApplication에서 저장소를 가져오는 호출로 바꿉니다.

TaskDetailFragment.kt

// REPLACE this code
private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}

// WITH this code

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
}
  1. TasksFragment에서도 동일한 작업을 실행합니다.

TasksFragment.kt

// REPLACE this code
    private val viewModel by viewModels<TasksViewModel> {
        TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
    }


// WITH this code

    private val viewModel by viewModels<TasksViewModel> {
        TasksViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
    }
  1. StatisticsViewModelAddEditTaskViewModel의 경우 TodoApplication에서 저장소를 사용하도록 저장소를 가져오는 코드를 업데이트합니다.

TasksFragment.kt

// REPLACE this code
    private val tasksRepository = DefaultTasksRepository.getRepository(application)



// WITH this code

    private val tasksRepository = (application as TodoApplication).taskRepository

  1. 테스트가 아닌 애플리케이션을 실행합니다.

리팩터링만 했으므로 앱은 문제 없이 동일하게 실행되어야 합니다.

3단계: FakeAndroidTestRepository 만들기

테스트 소스 세트에 FakeTestRepository이 이미 있습니다. 기본적으로 testandroidTest 소스 세트 간에 테스트 클래스를 공유할 수 없습니다. 따라서 androidTest 소스 세트에 중복 FakeTestRepository 클래스를 만들어 FakeAndroidTestRepository이라고 해야 합니다.

  1. androidTest 소스 세트를 마우스 오른쪽 버튼으로 클릭하고 data 패키지를 만듭니다. 마우스 오른쪽 버튼으로 클릭하고 source 패키지를 만듭니다.
  2. 이 소스 패키지에 FakeAndroidTestRepository.kt라는 새 클래스를 만듭니다.
  3. 다음 코드를 클래스에 복사합니다.

FakeAndroidTestRepository.kt

import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.runBlocking
import java.util.LinkedHashMap



class FakeAndroidTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private var shouldReturnError = false

    private val observableTasks = MutableLiveData<Result<List<Task>>>()

    fun setReturnError(value: Boolean) {
        shouldReturnError = value
    }

    override suspend fun refreshTasks() {
        observableTasks.value = getTasks()
    }

    override suspend fun refreshTask(taskId: String) {
        refreshTasks()
    }

    override fun observeTasks(): LiveData<Result<List<Task>>> {
        runBlocking { refreshTasks() }
        return observableTasks
    }

    override fun observeTask(taskId: String): LiveData<Result<Task>> {
        runBlocking { refreshTasks() }
        return observableTasks.map { tasks ->
            when (tasks) {
                is Result.Loading -> Result.Loading
                is Error -> Error(tasks.exception)
                is Success -> {
                    val task = tasks.data.firstOrNull() { it.id == taskId }
                        ?: return@map Error(Exception("Not found"))
                    Success(task)
                }
            }
        }
    }

    override suspend fun getTask(taskId: String, forceUpdate: Boolean): Result<Task> {
        if (shouldReturnError) {
            return Error(Exception("Test exception"))
        }
        tasksServiceData[taskId]?.let {
            return Success(it)
        }
        return Error(Exception("Could not find task"))
    }

    override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
        if (shouldReturnError) {
            return Error(Exception("Test exception"))
        }
        return Success(tasksServiceData.values.toList())
    }

    override suspend fun saveTask(task: Task) {
        tasksServiceData[task.id] = task
    }

    override suspend fun completeTask(task: Task) {
        val completedTask = Task(task.title, task.description, true, task.id)
        tasksServiceData[task.id] = completedTask
    }

    override suspend fun completeTask(taskId: String) {
        // Not required for the remote data source.
        throw NotImplementedError()
    }

    override suspend fun activateTask(task: Task) {
        val activeTask = Task(task.title, task.description, false, task.id)
        tasksServiceData[task.id] = activeTask
    }

    override suspend fun activateTask(taskId: String) {
        throw NotImplementedError()
    }

    override suspend fun clearCompletedTasks() {
        tasksServiceData = tasksServiceData.filterValues {
            !it.isCompleted
        } as LinkedHashMap<String, Task>
    }

    override suspend fun deleteTask(taskId: String) {
        tasksServiceData.remove(taskId)
        refreshTasks()
    }

    override suspend fun deleteAllTasks() {
        tasksServiceData.clear()
        refreshTasks()
    }

   
    fun addTasks(vararg tasks: Task) {
        for (task in tasks) {
            tasksServiceData[task.id] = task
        }
        runBlocking { refreshTasks() }
    }
}

4단계: 테스트를 위한 ServiceLocator 준비

알겠습니다. 테스트할 때 ServiceLocator을 사용하여 테스트를 두 배로 전환할 수 있습니다. 이렇게 하려면 ServiceLocator 코드에 코드를 추가해야 합니다.

  1. ServiceLocator.kt을(를) 엽니다.
  2. tasksRepository의 setter를 @VisibleForTesting로 표시합니다. 이 주석을 사용하면 setter가 공개된 이유를 테스트할 수 있습니다.

ServiceLocator.kt

    @Volatile
    var tasksRepository: TasksRepository? = null
        @VisibleForTesting set

테스트만 실행하든 테스트 그룹에서 실행하든 테스트는 정확히 동일하게 실행되어야 합니다. 즉, 상호 간에 종속된 동작이 없어야 합니다 (즉, 테스트 간의 객체 공유 피하기).

ServiceLocator는 싱글톤이므로 실수로 테스트 간에 공유될 가능성이 있습니다. 이를 방지하려면 테스트 간에 ServiceLocator 상태를 올바르게 재설정하는 메서드를 만듭니다.

  1. Any 값이 있는 lock라는 인스턴스 변수를 추가합니다.

ServiceLocator.kt

private val lock = Any()
  1. 데이터베이스를 지우고 저장소와 데이터베이스를 모두 null로 설정하는 resetRepository라는 테스트별 메서드를 추가합니다.

ServiceLocator.kt

    @VisibleForTesting
    fun resetRepository() {
        synchronized(lock) {
            runBlocking {
                TasksRemoteDataSource.deleteAllTasks()
            }
            // Clear all data to avoid test pollution.
            database?.apply {
                clearAllTables()
                close()
            }
            database = null
            tasksRepository = null
        }
    }

5단계: ServiceLocator 사용

이 단계에서는 ServiceLocator를 사용합니다.

  1. TaskDetailFragmentTest을(를) 엽니다.
  2. lateinit TasksRepository 변수를 선언합니다.
  3. 설정과 해체 메서드를 추가하여 각 테스트 전에 FakeAndroidTestRepository를 설정하고 각 테스트 후에 삭제합니다.

TaskDetailFragmentTest.kt

    private lateinit var repository: TasksRepository

    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }
  1. runBlockingTest에서 activeTaskDetails_DisplayedInUi() 함수 본문을 래핑합니다.
  2. 프래그먼트를 실행하기 전에 저장소에 activeTask를 저장합니다.
repository.saveTask(activeTask)

최종 테스트는 아래의 코드와 같습니다.

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi()  = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }
  1. 전체 클래스에 @ExperimentalCoroutinesApi 주석을 답니다.

완료되면 코드는 다음과 같이 표시됩니다.

TaskDetailFragmentTest.kt

@MediumTest
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {

    private lateinit var repository: TasksRepository

    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }


    @Test
    fun activeTaskDetails_DisplayedInUi()  = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

}
  1. activeTaskDetails_DisplayedInUi() 테스트를 실행합니다.

이전과 마찬가지로 프래그먼트를 확인해야 합니다. 이번에는 저장소를 올바르게 설정했기 때문에 이제 작업 정보가 표시됩니다.


이 단계에서는 Espresso UI 테스트 라이브러리를 사용하여 첫 번째 통합 테스트를 완료합니다. UI의 어설션으로 테스트를 추가할 수 있도록 코드를 구조화했습니다. 이를 위해 Espresso 테스트 라이브러리를 사용합니다.

Espresso는 다음과 같은 이점을 제공합니다.

  • 버튼 클릭, 막대 슬라이드, 화면 아래로 스크롤 등 뷰와 상호작용
  • 특정 뷰가 화면에 있는지 또는 특정 상태 (예: 특정 텍스트를 포함하거나 체크박스가 선택되어 있는지)에 있는지 어설션합니다.

1단계: Gradle 종속 항목 참고사항

Espresso 종속 항목은 Android 프로젝트에 기본적으로 포함되어 있으므로 이미 기본 Espresso 종속 항목을 갖습니다.

app/build.gradle

dependencies {

  // ALREADY in your code
    androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
   
 // Other dependencies
}

androidx.test.espresso:espresso-core: 이 핵심 Espresso 종속 항목은 새 Android 프로젝트를 만들 때 기본적으로 포함됩니다. 뷰와 대부분의 작업에서 실행할 수 있는 기본 테스트 코드가 포함되어 있습니다.

2단계: 애니메이션 끄기

Espresso 테스트는 실제 기기에서 실행되므로 특성상 계측 테스트입니다. 발생하는 문제 중 하나는 애니메이션입니다. 애니메이션이 지연되고 뷰가 화면에 있는지 테스트하려고 하지만 여전히 애니메이션 처리 중이면 Espresso가 실수로 테스트에 실패할 수 있습니다. 이렇게 하면 Espresso 테스트가 불안정할 수 있습니다.

Espresso UI 테스트의 경우 애니메이션을 사용 중지하는 것이 좋습니다 (테스트도 더 빠르게 실행됨).

  1. 테스트 기기에서 설정 및 개발자 옵션으로 이동합니다.
  2. 창 애니메이션 배율, 전환 애니메이션 배율, 애니메이터 길이 배율 등 세 가지 설정을 사용 중지합니다.

3단계: Espresso 테스트 보기

Espresso 테스트를 작성하기 전에 몇 가지 Espresso 코드를 살펴보세요.

onView(withId(R.id.task_detail_complete_checkbox)).perform(click()).check(matches(isChecked()))

이 문으로 ID가 task_detail_complete_checkbox인 체크박스 뷰를 찾아 클릭한 다음 확인되었다고 어설션합니다.

Espresso 문은 대부분 네 부분으로 구성됩니다.

1. Static Espresso 메서드

onView

onView는 Espresso 문을 시작하는 정적 Espresso 메서드의 예입니다. onView은 가장 일반적인 옵션 중 하나이지만 onData와 같은 다른 옵션도 있습니다.

2. ViewMatcher

withId(R.id.task_detail_title_text)

withId는 ID로 뷰를 가져오는 ViewMatcher의 예입니다. 문서에서 찾을 수 있는 다른 뷰 매처도 있습니다.

3. ViewAction

perform(click())

ViewAction를 사용하는 perform 메서드 ViewAction는 뷰에 실행할 수 있는 작업입니다. 예를 들어 여기서는 뷰를 클릭합니다.

4. ViewAssertion

check(matches(isChecked()))

ViewAssertion를 취하는 check ViewAssertion 검사 또는 뷰에 관한 어설션 사용할 가장 일반적인 ViewAssertionmatches 어설션입니다. 어설션을 완료하려면 다른 ViewMatcher(이 경우에는 isChecked)를 사용합니다.

Espresso 문에서 항상 performcheck을 모두 호출하지는 않습니다. 단순히 check를 사용하여 어설션을 만드는 문과 perform를 사용하여 ViewAction를 실행하는 문을 만들 수 있습니다.

  1. TaskDetailFragmentTest.kt을(를) 엽니다.
  2. activeTaskDetails_DisplayedInUi 테스트를 업데이트합니다.

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        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("Active 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")))
        // and make sure the "active" checkbox is shown unchecked
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(not(isChecked())))
    }

필요한 경우 import 문은 다음과 같습니다.

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.hamcrest.core.IsNot.not
  1. // THEN 주석 다음에 오는 모든 내용은 Espresso를 사용합니다. 테스트 구조 및 withId 사용을 살펴보고 세부정보 페이지가 어떻게 표시되는지에 관한 어설션을 만듭니다.
  2. 테스트를 실행하고 통과하는지 확인합니다.

4단계: 선택사항, 자체 Espresso 테스트 작성

이제 테스트를 직접 작성합니다.

  1. completedTaskDetails_DisplayedInUi라는 새 테스트를 만들고 이 스켈레톤 코드를 복사합니다.

TaskDetailFragmentTest.kt

    @Test
    fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add completed task to the DB
       
        // WHEN - Details fragment launched to display task
        
        // THEN - Task details are displayed on the screen
        // make sure that the title/description are both shown and correct
}
  1. 이전 테스트를 살펴보면 이 테스트를 완료할 수 있습니다.
  2. 실행하고 테스트를 통과했는지 확인합니다.

완성된 completedTaskDetails_DisplayedInUi는 이 코드와 같이 표시됩니다.

TaskDetailFragmentTest.kt

    @Test
    fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add completed task to the DB
        val completedTask = Task("Completed Task", "AndroidX Rocks", true)
        repository.saveTask(completedTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(completedTask.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("Completed 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")))
        // and make sure the "active" checkbox is shown unchecked
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isChecked()))
    }

이 마지막 단계에서는 mock이라는 다른 유형의 테스트 더블을 사용하여 Navigation 구성요소를 테스트하는 방법과 테스트 라이브러리 Mockito를 사용하는 방법을 알아봅니다.

이 Codelab에서는 가짜라는 테스트 더블을 사용했습니다. 가짜는 여러 유형의 테스트 중 하나입니다. 탐색 구성요소를 테스트하려면 어떤 테스트 더블을 사용해야 하나요?

탐색이 어떻게 이루어지는지 생각해 보세요. TasksFragment에서 한 작업을 눌러 할 일 세부정보 화면으로 이동한다고 가정해 보겠습니다.

눌렸을 때 작업 세부정보 화면으로 이동하는 TasksFragment의 코드입니다.

TasksFragment.kt

private fun openTaskDetails(taskId: String) {
    val action = TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment(taskId)
    findNavController().navigate(action)
}


탐색은 navigate 메서드를 호출했기 때문에 발생합니다. 어설션 문을 작성해야 하는 경우 TaskDetailFragment로 이동했는지 테스트하는 간단한 방법이 없습니다. 탐색은 TaskDetailFragment를 초기화하는 것 외에 명확한 출력 또는 상태 변경을 야기하지 않는 복잡한 작업입니다.

어설션할 수 있는 작업은 navigate 메서드가 올바른 작업 매개변수로 호출되었다는 점입니다. 이는 모의 테스트와 정확히 동일한데, 특정 메서드가 호출되었는지 확인합니다.

Mockito는 테스트 더블을 만들기 위한 프레임워크입니다. 모의 단어는 API와 이름에서 사용되지만 모의 음성을 제조하는 것만으로는 아닙니다. 스터브와 스파이도 만들 수 있습니다.

Mockito를 사용하여 탐색 메서드가 올바르게 호출되었다고 어설션할 수 있는 모의 NavigationController을 만듭니다.

1단계: Gradle 종속 항목 추가

  1. Gradle 종속 항목을 추가합니다.

app/build.gradle

    // Dependencies for Android instrumented unit tests
    androidTestImplementation "org.mockito:mockito-core:$mockitoVersion"

    androidTestImplementation "com.linkedin.dexmaker:dexmaker-mockito:$dexMakerVersion" 

    androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"



  • org.mockito:mockito-core: Mockito 종속 항목입니다.
  • dexmaker-mockito: 이 라이브러리는 Android 프로젝트에서 Mockito를 사용하는 데 필요합니다. Mockito는 런타임에 클래스를 생성해야 합니다. Android에서는 dex 바이트 코드를 사용하여 이 작업이 수행되므로, 이 라이브러리를 사용하면 Android에서 런타임 중에 Mockito에서 객체를 생성할 수 있습니다.
  • androidx.test.espresso:espresso-contrib: 이 라이브러리는 DatePicker, RecyclerView와 같은 고급 뷰의 테스트 코드가 포함된 외부 참여 (즉, 이름)로 구성됩니다. 또한 CountingIdlingResource에서 설명하는 접근성 검사와 클래스도 포함되어 있습니다.

2단계: TasksFragmentTest 만들기

  1. TasksFragment를 엽니다.
  2. TasksFragment 클래스 이름을 마우스 오른쪽 버튼으로 클릭하고 GenerateTest를 차례로 선택합니다. androidTest 소스 세트에 테스트를 만듭니다.
  3. 다음 코드를 TasksFragmentTest에 복사합니다.

TasksFragmentTest.kt

@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()
    }

}

이 코드는 개발자가 작성한 TaskDetailFragmentTest 코드와 유사합니다. FakeAndroidTestRepository를 설정하고 분해합니다. 탐색 테스트를 추가하여 작업 목록에서 작업을 클릭할 때 올바른 TaskDetailFragment로 이동하는지 테스트합니다.

  1. 테스트 clickTask_navigateToDetailFragmentOne추가합니다.

TasksFragmentTest.kt

    @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)
        
    }
  1. Mockito의 mock 함수를 사용하여 모의를 만듭니다.

TasksFragmentTest.kt

 val navController = mock(NavController::class.java)

Mockito에서 모의 테스트하려면 모의 처리하려는 수업을 전달합니다.

다음으로 NavController를 프래그먼트와 연결해야 합니다. onFragment를 사용하면 프래그먼트 자체에서 메서드를 호출할 수 있습니다.

  1. 새로운 모의 프래그먼트를 NavController로 만듭니다.
scenario.onFragment {
    Navigation.setViewNavController(it.view!!, navController)
}
  1. 코드를 추가하여 'TITLE1"' 텍스트가 있는 RecyclerView 항목을 클릭합니다.
// WHEN - Click on the first list item
        onView(withId(R.id.tasks_list))
            .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
                hasDescendant(withText("TITLE1")), click()))

RecyclerViewActionsespresso-contrib 라이브러리의 일부이며, 이를 통해 RecyclerView에서 Espresso 작업을 실행할 수 있습니다.

  1. navigate이 올바른 인수로 호출되었는지 확인합니다.
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
    TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")

Mockito의 verify 메서드를 사용하면 모의 모델이 됩니다. 매개변수(ID: "id1")를 사용하여 특정 메서드(navigate)를 호출한 모의 navController를 확인할 수 있습니다.

전체 테스트는 다음과 같습니다.

@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.view!!, 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")
    )
}
  1. 테스트를 실행합니다.

요약하면 탐색을 테스트하는 방법은 다음과 같습니다.

  1. Mockito를 사용하여 NavController 모의 객체를 만듭니다.
  2. NavController를 모의 테스트한 프래그먼트에 연결합니다.
  3. 올바른 작업 및 매개변수를 통해 탐색이 호출되었는지 확인합니다.

3단계: 선택사항, clickAddTaskButton_navigationToAddEditFragment 작성

탐색 테스트를 직접 작성할 수 있는지 확인하려면 이 작업을 시도해 보세요.

  1. + FAB를 클릭하면 AddEditTaskFragment로 이동하는지 확인하는 테스트 clickAddTaskButton_navigateToAddEditFragment를 작성합니다.

정답은 아래와 같습니다.

TasksFragmentTest.kt

    @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.view!!, navController)
        }

        // WHEN - Click on the "+" button
        onView(withId(R.id.add_task_fab)).perform(click())

        // THEN - Verify that we navigate to the add screen
        verify(navController).navigate(
            TasksFragmentDirections.actionTasksFragmentToAddEditTaskFragment(
                null, getApplicationContext<Context>().getString(R.string.add_task)
            )
        )
    }

여기를 클릭하여 시작한 코드와 최종 코드 간의 차이를 확인하세요.

완료된 Codelab의 코드를 다운로드하려면 아래의 git 명령어를 사용하면 됩니다.

$ git clone https://github.com/googlecodelabs/android-testing.git
$ cd android-testing
$ git checkout end_codelab_2


또는 저장소를 ZIP 파일로 다운로드하여 압축을 풀고 Android 스튜디오에서 열 수 있습니다.

ZIP 파일 다운로드

이 Codelab에서는 수동 종속 항목 삽입, 서비스 로케이터를 설정하는 방법과 Android Kotlin 앱에서 가짜 및 모의 처리를 사용하는 방법을 설명했습니다. 특히 다음 항목이 중요합니다.

  • 테스트하려는 항목과 테스트 전략에 따라 앱에 구현할 테스트의 종류가 결정됩니다. 단위 테스트는 중점적이며 빠르게 이루어집니다. 통합 테스트는 프로그램의 각 부분 간의 상호작용을 확인합니다. 엔드 투 엔드 테스트는 기능을 확인하고, 충실도가 가장 높으며, 계측이 잦으며, 실행하는 데 시간이 더 오래 걸릴 수 있습니다.
  • 앱의 아키텍처는 테스트 어려움에 영향을 줍니다.
  • TDD 또는 테스트 기반 개발은 테스트를 먼저 작성한 후 테스트를 통과하는 기능을 만드는 전략입니다.
  • 테스트용으로 앱의 일부를 격리하려면 테스트 더블을 사용하면 됩니다. 테스트 더블은 테스트용으로 특별히 만들어진 클래스의 버전입니다. 예를 들어 데이터베이스 또는 인터넷에서 데이터를 가져오는 것처럼 위장합니다.
  • 종속 항목 삽입을 사용하여 실제 클래스를 저장소 또는 네트워킹 계층과 같은 테스트 클래스로 바꿉니다.
  • 테스트(androidTest)를 사용하여 UI 구성요소를 실행합니다.
  • 생성자 종속 항목 삽입을 사용할 수 없는 경우(예: 프래그먼트 실행) 서비스 로케이터를 사용하는 경우가 많습니다. 서비스 로케이터 패턴은 종속 항목 삽입의 대안입니다. 여기에는 일반 코드와 테스트 코드 모두에 종속 항목을 제공하는 것이 목적인 "Service Locator"라는 싱글톤 클래스를 만듭니다.

Udacity 과정:

Android 개발자 문서:

동영상:

기타:

이 과정의 다른 Codelab에 관한 링크는 Kotlin Codelab의 고급 Android 방문 페이지를 참고하세요.