Введение в тестовые двойники и внедрение зависимостей

Эта кодовая лаборатория является частью курса Advanced Android in Kotlin. Вы получите максимальную отдачу от этого курса, если будете последовательно работать с лабораториями кода, но это не обязательно. Все кодовые лаборатории курса перечислены на целевой странице Advanced Android in Kotlin codelabs .

Введение

Эта вторая тестовая лаборатория полностью посвящена двойникам тестов: когда их использовать в Android и как их реализовать с помощью внедрения зависимостей, шаблона Service Locator и библиотек. При этом вы научитесь писать:

  • Модульные тесты репозитория
  • Фрагменты и интеграционные тесты модели представления
  • Фрагментные навигационные тесты

Что вы уже должны знать

Вы должны быть знакомы с:

Что вы узнаете

  • Как спланировать стратегию тестирования
  • Как создавать и использовать тестовые двойники, а именно фейки и моки
  • Как использовать ручную инъекцию зависимостей на Android для модульных и интеграционных тестов
  • Как применить шаблон Service Locator
  • Как тестировать репозитории, фрагменты, модели представления и компонент навигации

Вы будете использовать следующие библиотеки и концепции кода:

Что ты будешь делать

  • Напишите модульные тесты для репозитория, используя тестовый дубль и внедрение зависимостей.
  • Напишите модульные тесты для модели представления, используя двойной тест и внедрение зависимостей.
  • Напишите интеграционные тесты для фрагментов и их моделей представления, используя среду тестирования пользовательского интерфейса Espresso.
  • Напишите навигационные тесты, используя Mockito и Espresso.

В этой серии лабораторных работ вы будете работать с приложением TO-DO Notes. Приложение позволяет записывать задачи для выполнения и отображает их в списке. Затем вы можете пометить их как выполненные или нет, отфильтровать их или удалить.

Это приложение написано на Kotlin, имеет несколько экранов, использует компоненты Jetpack и следует архитектуре из Руководства по архитектуре приложения . Научившись тестировать это приложение, вы сможете тестировать приложения, использующие те же библиотеки и архитектуру.

Скачать код

Для начала скачайте код:

Скачать ZIP

Кроме того, вы можете клонировать репозиторий Github для кода:

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

Найдите минутку, чтобы ознакомиться с кодом, следуя приведенным ниже инструкциям.

Шаг 1. Запустите пример приложения

После загрузки приложения TO-DO откройте его в Android Studio и запустите. Он должен скомпилироваться. Изучите приложение, выполнив следующие действия:

  • Создайте новую задачу с плавающей кнопкой плюс. Сначала введите название, затем введите дополнительную информацию о задаче. Сохраните его с зеленой галочкой FAB.
  • В списке задач щелкните название задачи, которую вы только что выполнили, и посмотрите на экран сведений об этой задаче, чтобы увидеть остальную часть описания.
  • В списке или на экране сведений установите флажок для этой задачи, чтобы установить для нее статус Завершено .
  • Вернитесь к экрану задач, откройте меню фильтров и отфильтруйте задачи по статусу « Активно » и « Выполнено ».
  • Откройте панель навигации и щелкните Статистика .
  • Вернитесь к обзорному экрану и в меню панели навигации выберите « Очистить завершено », чтобы удалить все задачи со статусом « Завершено ».

Шаг 2. Изучите пример кода приложения

Приложение TO-DO основано на популярном образце архитектуры и тестирования Architecture Blueprints (с использованием версии образца с реактивной архитектурой ). Приложение следует архитектуре из Руководства по архитектуре приложения . Он использует ViewModels с фрагментами, репозиторий и комнату. Если вы знакомы с любым из приведенных ниже примеров, это приложение имеет аналогичную архитектуру:

Более важно, чтобы вы понимали общую архитектуру приложения, чем глубоко понимали логику на любом уровне.

Вот краткое описание пакетов, которые вы найдете:

Пакет: com.example.android.architecture.blueprints.todoapp

.addedittask

Экран добавления или редактирования задачи: код слоя пользовательского интерфейса для добавления или редактирования задачи.

.data

Уровень данных: это относится к уровню данных задач. Он содержит базу данных, сеть и код репозитория.

.statistics

Экран статистики: код слоя пользовательского интерфейса для экрана статистики.

.taskdetail

Экран сведений о задаче: код слоя пользовательского интерфейса для отдельной задачи.

.tasks

Экран задач: код слоя пользовательского интерфейса для списка всех задач.

.util

Вспомогательные классы: общие классы, используемые в различных частях приложения, например, для макета обновления смахивания, используемого на нескольких экранах.

Слой данных (.data)

Это приложение включает в себя смоделированный сетевой уровень в удаленном пакете и уровень базы данных в локальном пакете. Для простоты в этом проекте сетевой уровень моделируется только с помощью HashMap с задержкой, а не с реальными сетевыми запросами.

DefaultTasksRepository координирует или является посредником между сетевым уровнем и уровнем базы данных и возвращает данные на уровень пользовательского интерфейса.

Уровень пользовательского интерфейса (.addedittask, .statistics, .taskdetail, .tasks)

Каждый из пакетов слоя пользовательского интерфейса содержит фрагмент и модель представления, а также любые другие классы, необходимые для пользовательского интерфейса (например, адаптер для списка задач). TaskActivity — это действие, содержащее все фрагменты.

Навигация

Навигация для приложения управляется компонентом навигации . Он определен в файле nav_graph.xml . Навигация запускается в моделях представления с помощью класса Event ; модели представления также определяют, какие аргументы передавать. Фрагменты наблюдают за Event и выполняют реальную навигацию между экранами.

В этой лабораторной работе вы узнаете, как тестировать репозитории, модели просмотра и фрагменты, используя двойные тесты и внедрение зависимостей. Прежде чем вы углубитесь в то, что это такое, важно понять причины, по которым вы будете писать эти тесты.

В этом разделе рассматриваются некоторые общие рекомендации по тестированию, применимые к Android.

Пирамида тестирования

При размышлении о стратегии тестирования есть три связанных аспекта тестирования:

  • Область действия — какую часть кода затрагивает тест? Тесты могут выполняться для одного метода, для всего приложения или где-то посередине.
  • Скорость — насколько быстро выполняется тест? Скорость тестирования может варьироваться от миллисекунд до нескольких минут.
  • Верность — насколько этот тест «реалистичен»? Например, если часть кода, который вы тестируете, должна сделать сетевой запрос, действительно ли тестовый код делает этот сетевой запрос или он имитирует результат? Если тест действительно разговаривает с сетью, это означает, что он имеет более высокую точность. Компромисс заключается в том, что тест может занять больше времени, может привести к ошибкам, если сеть не работает, или может быть дорогостоящим в использовании.

Между этими аспектами существуют неотъемлемые компромиссы. Например, скорость и точность являются компромиссом: чем быстрее тест, тем меньше точность, и наоборот. Один из распространенных способов разделить автоматизированные тесты на следующие три категории:

  • Модульные тесты — это узкоспециализированные тесты, которые выполняются в одном классе, обычно в одном методе этого класса. Если модульный тест не пройден, вы можете точно знать, где в вашем коде проблема. У них низкая точность, поскольку в реальном мире ваше приложение включает в себя гораздо больше, чем выполнение одного метода или класса. Они достаточно быстры, чтобы запускаться каждый раз, когда вы меняете свой код. Чаще всего это будут локально запущенные тесты (в наборе исходных текстов test ). Пример: тестирование отдельных методов в моделях представлений и репозиториях.
  • Интеграционные тесты — проверяют взаимодействие нескольких классов, чтобы убедиться, что они ведут себя должным образом при совместном использовании. Один из способов структурировать интеграционные тесты — протестировать одну функцию, например возможность сохранения задачи. Они тестируют больший объем кода, чем модульные тесты, но все же оптимизированы для быстрой работы, а не для полной точности. Их можно запускать либо локально, либо как инструментальные тесты, в зависимости от ситуации. Пример: тестирование всей функциональности одной пары фрагмент и модель представления.
  • Сквозные тесты (E2e) — проверьте комбинацию функций, работающих вместе. Они тестируют большие части приложения, точно имитируют реальное использование и поэтому обычно работают медленно. Они имеют высочайшую точность и говорят вам, что ваше приложение действительно работает в целом. По большому счету, эти тесты будут инструментальными тестами (в наборе исходников androidTest )
    Пример: запуск всего приложения и совместное тестирование нескольких функций.

Предлагаемая пропорция этих тестов часто представлена ​​​​пирамидой, при этом подавляющее большинство тестов являются модульными тестами.

Архитектура и тестирование

Ваша способность тестировать приложение на всех уровнях пирамиды тестирования неразрывно связана с архитектурой вашего приложения . Например, очень плохо спроектированное приложение может поместить всю свою логику в один метод. Возможно, вы сможете написать сквозной тест для этого, поскольку эти тесты, как правило, тестируют большие части приложения, но как насчет написания модульных или интеграционных тестов? Когда весь код находится в одном месте, сложно протестировать только код, относящийся к одному модулю или функции.

Лучшим подходом было бы разбить логику приложения на несколько методов и классов, что позволило бы тестировать каждую часть отдельно. Архитектура — это способ разделить и организовать ваш код, что упрощает модульное и интеграционное тестирование. Приложение TO-DO, которое вы будете тестировать, следует определенной архитектуре:



В этом уроке вы увидите, как тестировать части вышеуказанной архитектуры в надлежащей изоляции:

  1. Сначала вы проведете модульное тестирование репозитория .
  2. Затем вы будете использовать тестовый двойник в модели представления, который необходим для модульного и интеграционного тестирования модели представления.
  3. Далее вы научитесь писать интеграционные тесты для фрагментов и их моделей представления .
  4. Наконец, вы научитесь писать интеграционные тесты , включающие компонент навигации .

Сквозное тестирование будет рассмотрено в следующем уроке.

Когда вы пишете модульный тест для части класса (метода или небольшого набора методов), ваша цель — протестировать только код этого класса .

Тестирование только кода в определенном классе или классах может оказаться сложной задачей. Давайте посмотрим на пример. Откройте класс data.source.DefaultTaskRepository в main исходном наборе. Это репозиторий для приложения и класс, для которого вы будете писать модульные тесты в следующий раз.

Ваша цель — протестировать только код этого класса. Тем не менее, DefaultTaskRepository зависит от других классов, таких как LocalTaskDataSource и RemoteTaskDataSource , для работы. Другими словами, LocalTaskDataSource и RemoteTaskDataSource являются зависимостями от DefaultTaskRepository .

Таким образом, каждый метод в 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 Test, чтобы получить смоделированную среду Android.
  • Некоторые части кода, такие как сетевой код, могут выполняться очень долго, а иногда даже давать сбои, что приводит к длительным и ненадежным тестам.
  • Ваши тесты могут утратить способность диагностировать, какой код виноват в сбое теста. Ваши тесты могут начать тестирование кода, не относящегося к репозиторию, поэтому, например, ваши предполагаемые модульные тесты «репозитория» могут завершиться неудачно из-за проблемы в каком-то зависимом коде, таком как код базы данных.

Тестовые двойники

Решение этой проблемы заключается в том, что при тестировании репозитория не используйте реальный сетевой код или код базы данных , а вместо этого используйте тестовый дубликат. Тестовый двойник — это версия класса, созданная специально для тестирования. Он предназначен для замены реальной версии класса в тестах. Это похоже на то, как дублер — это актер, который специализируется на трюках, и заменяет настоящего актера для опасных действий.

Вот некоторые типы тестовых двойников:

Фальшивый

Тестовый двойник, у которого есть «рабочая» реализация класса, но он реализован таким образом, что он хорош для тестов, но не подходит для производства.

Насмехаться

Тестовый двойник, который отслеживает, какой из его методов был вызван. Затем он проходит или не проходит тест в зависимости от того, правильно ли были вызваны его методы.

Заглушка

Тестовый двойник, который не включает логику и возвращает только то, что вы запрограммировали для возврата. Например, StubTaskRepository можно запрограммировать на возврат определенных комбинаций задач из getTasks .

Дурачок

Тестовый двойник, который передается, но не используется, например, если вам просто нужно предоставить его в качестве параметра. Если бы у вас был NoOpTaskRepository , он просто реализовал бы TaskRepository без кода ни в одном из методов.

Шпион

Тестовый двойник, который также отслеживает некоторую дополнительную информацию; например, если вы создали SpyTaskRepository , он может отслеживать количество вызовов метода addTask .

Для получения дополнительной информации о тестовых двойниках ознакомьтесь с разделом «Тестирование в туалете: знайте свои тестовые двойники» .

Наиболее распространенными тестовыми двойниками, используемыми в Android, являются Fakes и Mocks .

В этой задаче вы создадите дубль теста FakeDataSource для модульного теста DefaultTasksRepository , отделенного от реальных источников данных.

Шаг 1: Создайте класс FakeDataSource

На этом шаге вы создадите класс FakeDataSouce , который будет тестовым двойником LocalDataSource и RemoteDataSource .

  1. В тестовом исходном наборе щелкните правой кнопкой мыши и выберите New -> Package .

  1. Создайте пакет данных с исходным пакетом внутри.
  2. Создайте новый класс с именем FakeDataSource в пакете данных/источника .

Шаг 2: Реализуйте интерфейс TasksDataSource

Чтобы иметь возможность использовать ваш новый класс FakeDataSource в качестве тестового двойника, он должен иметь возможность заменить другие источники данных. Этими источниками данных являются TasksLocalDataSource и TasksRemoteDataSource .

  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 Studio будет жаловаться, что вы не реализовали необходимые методы для TasksDataSource .

  1. Воспользуйтесь меню быстрого исправления и выберите « Реализовать элементы» .


  1. Выберите все методы и нажмите OK .

Шаг 3: Реализуйте метод getTasks в FakeDataSource

FakeDataSource — это особый тип тестового двойника, называемый подделкой . Подделка — это тестовый двойник, у которого есть «рабочая» реализация класса, но реализованная так, что она хороша для тестов, но не подходит для продакшена. «Рабочая» реализация означает, что класс будет выдавать реалистичные выходные данные с учетом входных данных.

Например, ваш поддельный источник данных не будет подключаться к сети или сохранять что-либо в базе данных — вместо этого он просто будет использовать список в памяти. Это будет «работать так, как вы могли бы ожидать», поскольку методы получения или сохранения задач будут возвращать ожидаемые результаты, но вы никогда не сможете использовать эту реализацию в рабочей среде, поскольку она не сохраняется на сервере или в базе данных.

FakeDataSource

  • позволяет тестировать код в DefaultTasksRepository , не полагаясь на реальную базу данных или сеть.
  • обеспечивает «достаточно реальную» реализацию тестов.
  1. Изменить конструктор FakeDataSource , чтобы создать переменную с именем tasks , которая является var MutableList<Task>? со значением по умолчанию пустого изменяемого списка.
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }


Это список задач, которые «подделывают» базу данных или ответ сервера. На данный момент цель — протестировать метод getTasks репозитория . Это вызывает getTasks , deleteAllTasks и saveTask данных.

Напишите поддельную версию этих методов:

  1. Напишите getTasks : если tasks не равно null , вернуть результат Success . Если tasks null , вернуть результат 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 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 , но непонятно, как вы его используете в тестах. Он должен заменить TasksRemoteDataSource и TasksLocalDataSource , но только в тестах. И TasksRemoteDataSource , и TasksLocalDataSource являются зависимостями от DefaultTasksRepository , что означает, что DefaultTasksRepositories требует или «зависит» от этих классов для запуска.

Прямо сейчас зависимости создаются внутри метода init DefaultTasksRepository .

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
}

Поскольку вы создаете и назначаете taskLocalDataSource и tasksRemoteDataSource внутри DefaultTasksRepository , они, по сути, жестко закодированы. Нет никакого способа поменять местами вашего тестового двойника.

Вместо этого вы хотите предоставить эти источники данных классу, а не жестко кодировать их. Предоставление зависимостей известно как внедрение зависимостей . Существуют разные способы предоставления зависимостей и, следовательно, разные типы внедрения зависимостей.

Внедрение зависимостей в конструктор позволяет вам поменять местами тестовый двойник, передав его в конструктор.

Без инъекции

Инъекция

Шаг 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 и выберите Generate , а затем Test.
  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. Создайте три переменные, две переменные-члены FakeDataSource (по одной для каждого источника данных вашего репозитория) и переменную для DefaultTasksRepository , которую вы будете тестировать.

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. Создайте свои поддельные источники данных, используя списки remoteTasks и localTasks .
  3. Создайте свой tasksRepository , используя два поддельных источника данных, которые вы только что создали, и Dispatchers.Unconfined .

Окончательный метод должен выглядеть как код ниже.

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 репозитория. Убедитесь, что когда вы вызываете getTasks со значением true (что означает, что он должен перезагружаться из удаленного источника данных), он возвращает данные из удаленного источника данных (в отличие от локального источника данных).

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

Ожидается ошибка сопрограммы, потому что getTasks является функцией suspend , и вам нужно запустить сопрограмму, чтобы вызвать ее. Для этого вам нужна область действия сопрограммы. Чтобы устранить эту ошибку, вам нужно будет добавить некоторые зависимости gradle для обработки запуска сопрограмм в ваших тестах.

  1. Добавьте необходимые зависимости для тестирования сопрограмм в исходный набор тестов с помощью testImplementation .

приложение/build.gradle

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

Не забудьте синхронизировать!

kotlinx-coroutines-test — это тестовая библиотека сопрограмм, специально предназначенная для тестирования сопрограмм. Для запуска тестов используйте функцию runBlockingTest . Это функция, предоставляемая тестовой библиотекой сопрограмм. Он принимает блок кода, а затем запускает этот блок кода в специальном контексте сопрограммы, который запускается синхронно и немедленно, что означает, что действия будут происходить в детерминированном порядке. По сути, это заставляет ваши сопрограммы работать как не сопрограммы, поэтому он предназначен для тестирования кода.

Используйте runBlockingTest в своих тестовых классах, когда вы вызываете функцию suspend . Вы узнаете больше о том, как работает 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 и убедитесь, что он работает и ошибка исчезла!

Вы только что видели, как проводить модульное тестирование репозитория. На следующих шагах вы снова будете использовать внедрение зависимостей и создадите еще один тестовый двойник — на этот раз, чтобы показать, как писать модульные и интеграционные тесты для ваших моделей представления.

Модульные тесты должны тестировать только интересующий вас класс или метод. Это известно как изолированное тестирование, когда вы четко изолируете свой «модуль» и тестируете только тот код, который является частью этого модуля.

Таким образом, TasksViewModelTest должен тестировать только код TasksViewModel — он не должен тестировать в базе данных, сети или классах репозитория. Поэтому для ваших моделей представления, как вы только что сделали для своего репозитория, вы создадите поддельный репозиторий и примените внедрение зависимостей, чтобы использовать его в своих тестах.

В этой задаче вы применяете внедрение зависимостей для просмотра моделей.

Шаг 1. Создайте интерфейс TasksRepository

Первым шагом к использованию внедрения зависимостей конструктора является создание общего интерфейса между поддельным и реальным классами.

Как это выглядит на практике? Посмотрите на TasksRemoteDataSource , TasksLocalDataSource и FakeDataSource и обратите внимание, что все они используют один и тот же интерфейс: TasksDataSource . Это позволяет вам сказать в конструкторе DefaultTasksRepository , что вы принимаете TasksDataSource .

DefaultTasksRepository.kt

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

Это то, что позволяет нам подменять ваш FakeDataSource !

Затем создайте интерфейс для DefaultTasksRepository , как вы сделали для источников данных. Он должен включать все общедоступные методы (открытая поверхность API) DefaultTasksRepository .

  1. Откройте DefaultTasksRepository и щелкните правой кнопкой мыши имя класса. Затем выберите Refactor -> Extract -> Interface .

  1. Выберите Извлечь в отдельный файл.

  1. В окне Extract Interface измените имя интерфейса на TasksRepository .
  2. В разделе Члены для формирования интерфейса отметьте все члены, кроме двух членов-компаньонов и закрытых методов.


  1. Щелкните Рефакторинг . Новый интерфейс TasksRepository должен появиться в пакете data/source .

А DefaultTasksRepository теперь реализует TasksRepository .

  1. Запустите свое приложение (не тесты), чтобы убедиться, что все работает.

Шаг 2. Создайте FakeTestRepository

Теперь, когда у вас есть интерфейс, вы можете создать тестовый двойник DefaultTaskRepository .

  1. В тестовом исходном наборе в data/source создайте файл Kotlin и класс FakeTestRepository.kt и расширьте его из интерфейса TasksRepository .

FakeTestRepository.kt

class FakeTestRepository : TasksRepository  {
}

Вам будет сказано, что вам нужно реализовать методы интерфейса.

  1. Наведите указатель мыши на ошибку, пока не появится меню предложений, затем нажмите и выберите « Реализовать элементы » .
  1. Выберите все методы и нажмите OK .

Шаг 3. Реализуйте методы FakeTestRepository

Теперь у вас есть класс FakeTestRepository с «нереализованными» методами. Подобно тому, как вы реализовали FakeDataSource , FakeTestRepository будет поддерживаться структурой данных, вместо того, чтобы иметь дело со сложным посредником между локальными и удаленными источниками данных.

Обратите внимание, что вашему FakeTestRepository не нужно использовать FakeDataSource или что-то в этом роде; ему просто нужно вернуть реалистичные поддельные выходные данные с учетом входных данных. Вы будете использовать 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. наблюдатьTasks — runBlocking observeTasks запускает 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. Добавьте метод addTasks , который принимает vararg задач, добавляет каждую в HashMap и затем обновляет задачи.

FakeTestRepository.kt

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

На данный момент у вас есть поддельный репозиторий для тестирования с несколькими реализованными ключевыми методами. Затем используйте это в своих тестах!

В этой задаче вы используете поддельный класс внутри ViewModel . Используйте внедрение зависимостей конструктора, чтобы получить два источника данных через внедрение зависимостей конструктора, добавив переменную TasksRepository в конструктор TasksViewModel .

Этот процесс немного отличается с моделями представлений, потому что вы не создаете их напрямую. Например:

class TasksFragment : Fragment() {

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

}


Как и в приведенном выше коде, вы используете делегат свойства viewModel's который создает модель представления. Чтобы изменить способ построения модели представления, вам нужно добавить и использовать ViewModelProvider.Factory . Если вы не знакомы с ViewModelProvider.Factory , вы можете узнать о нем больше здесь .

Шаг 1. Создайте и используйте ViewModelFactory в TasksViewModel

Вы начинаете с обновления классов и тестов, связанных с экраном « Tasks ».

  1. Откройте TasksViewModel .
  2. Измените конструктор TasksViewModel , чтобы он принимал TasksRepository а не создавал его внутри класса.

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 , вне класса, добавьте TasksViewModelFactory , который принимает обычный TasksRepository .

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. Используйте FakeTestRepository внутри TasksViewModelTest

Теперь вместо использования реального репозитория в тестах модели представления вы можете использовать поддельный репозиторий.

  1. Откройте TasksViewModelTest .
  2. Добавьте свойство FakeTestRepository в TasksViewModelTest .

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. Update the setupViewModel method to make a FakeTestRepository with three tasks, and then construct the tasksViewModel with this repository.

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. Because you are no longer using the AndroidX Test ApplicationProvider.getApplicationContext code, you can also remove the @RunWith(AndroidJUnit4::class) annotation.
  2. Run your tests, make sure they all still work!

By using constructor dependency injection, you've now removed the DefaultTasksRepository as a dependency and replaced it with your FakeTestRepository in the tests.

Step 3. Also Update TaskDetail Fragment and ViewModel

Make the exact same changes for the TaskDetailFragment and TaskDetailViewModel . This will prepare the code for when you write TaskDetail tests next.

  1. Open TaskDetailViewModel .
  2. Update the constructor:

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. At the bottom of the TaskDetailViewModel file, outside the class, add a 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. Update TasksFragment to use the factory.

TasksFragment.kt

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

// WITH

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. Run your code and make sure everything is working.

You are now able to use a FakeTestRepository instead of the real repository in TasksFragment and TasksDetailFragment .

Next you'll write integration tests to test your fragment and view-model interactions. You'll find out if your view model code appropriately updates your UI. To do this you use

  • the ServiceLocator pattern
  • the Espresso and Mockito libraries

Integration tests test the interaction of several classes to make sure they behave as expected when used together. These tests can be run either locally ( test source set) or as instrumentation tests ( androidTest source set).

In your case you'll be taking each fragment and writing integration tests for the fragment and view model to test the main features of the fragment.

Step 1. Add Gradle Dependencies

  1. Add the following gradle dependencies.

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"

These dependencies include:

  • junit:junit —JUnit, which is necessary for writing basic test statements.
  • androidx.test:core —Core AndroidX test library
  • kotlinx-coroutines-test —The coroutines testing library
  • androidx.fragment:fragment-testing —AndroidX test library for creating fragments in tests and changing their state.

Since you'll be using these libraries in your androidTest source set, use androidTestImplementation to add them as dependencies.

Step 2. Make a TaskDetailFragmentTest class

The TaskDetailFragment shows information about a single task.

You'll start by writing a fragment test for the TaskDetailFragment since it has fairly basic functionality compared to the other fragments.

  1. Open taskdetail.TaskDetailFragment .
  2. Generate a test for TaskDetailFragment , as you've done before. Accept the default choices and put it in the androidTest source set (NOT the test source set).

  1. Add the following annotations to the TaskDetailFragmentTest class.

TaskDetailFragmentTest.kt

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

}

The purpose of these annotation is:

Step 3. Launch a fragment from a test

In this task, you're going to launch TaskDetailFragment using the AndroidX Testing library . FragmentScenario is a class from AndroidX Test that wraps around a fragment and gives you direct control over the fragment's lifecycle for testing. To write tests for fragments, you create a FragmentScenario for the fragment you're testing ( TaskDetailFragment ).

  1. Copy this test into 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)

    }

This code above:

This is not a finished test yet, because it's not asserting anything. For now, run the test and observe what happens.

  1. This is an instrumented test, so make sure the emulator or your device is visible.
  2. Run the test.

A few things should happen.

  • First, because this is an instrumented test, the test will run on either your physical device (if connected) or an emulator.
  • It should launch the fragment.
  • Notice how it doesn't navigate through any other fragment or have any menus associated with the activity - it is just the fragment.

Finally, look closely and notice that the fragment says "No data" as it doesn't successfully load up the task data.

Your test both needs to load up the TaskDetailFragment (which you've done) and assert the data was loaded correctly. Why is there no data? This is because you created a task, but you didn't save it to the repository.

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

    }

You have this FakeTestRepository , but you need some way to replace your real repository with your fake one for your fragment . You'll do this next!

In this task, you'll provide your fake repository to your fragment using a ServiceLocator . This will allow you to write your fragment and view model integration tests.

You can't use constructor dependency injection here, as you did before, when you needed to provide a dependency to the view model or repository. Constructor dependency injection requires that you construct the class. Fragments and activities are examples of classes that you don't construct and generally don't have access to the constructor of.

Since you don't construct the fragment, you can't use constructor dependency injection to swap the repository test double ( FakeTestRepository ) to the fragment. Instead, use the Service Locator pattern. The Service Locator pattern is an alternative to Dependency Injection. It involves creating a singleton class called the "Service Locator", whose purpose is to provide dependencies, both for the regular and test code. In the regular app code (the main source set), all of these dependencies are the regular app dependencies. For the tests, you modify the Service Locator to provide test double versions of the dependencies.

Not using Service Locator


Using a Service Locator

For this codelab app, do the following:

  1. Create a Service Locator class that is able to construct and store a repository. By default it constructs a "normal" repository.
  2. Refactor your code so that when you need a repository, use the Service Locator.
  3. In your testing class, call a method on the Service Locator which swaps out the "normal" repository with your test double.

Step 1. Create the ServiceLocator

Let's make a ServiceLocator class. It'll live in the main source set with the rest of the app code because it's used by the main application code.

Note: The ServiceLocator is a singleton, so use the Kotlin object keyword for the class.

  1. Create the file ServiceLocator.kt in the top level of the main source set.
  2. Define an object called ServiceLocator .
  3. Create database and repository instance variables and set both to null .
  4. Annotate the repository with @Volatile because it could get used by multiple threads ( @Volatile is explained in detail here ).

Your code should look as a shown below.

object ServiceLocator {

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

}

Right now the only thing your ServiceLocator needs to do is know how to return a TasksRepository . It'll return a pre-existing DefaultTasksRepository or make and return a new DefaultTasksRepository , if needed.

Define the following functions:

  1. provideTasksRepository —Either provides an already existing repository or creates a new one. This method should be synchronized on this to avoid, in situations with multiple threads running, ever accidentally creating two repository instances.
  2. createTasksRepository —Code for creating a new repository. Will call createTaskLocalDataSource and create a new TasksRemoteDataSource .
  3. createTaskLocalDataSource —Code for creating a new local data source. Will call createDataBase .
  4. createDataBase —Code for creating a new database.

The completed code is below.

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
    }
}

Step 2. Use ServiceLocator in Application

You're going to make a change to your main application code (not your tests) so that you create the repository in one place, your ServiceLocator .

It's important that you only ever make one instance of the repository class. To ensure this, you'll use the Service locator in my Application class.

  1. At the top level of your package hierarchy, open TodoApplication and create a val for your repository and assign it a repository that is obtained using 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())
    }
}

Now that you have created a repository in the application, you can remove the old getRepository method in DefaultTasksRepository .

  1. Open DefaultTasksRepository and delete the companion object.

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
            }
        }
    }
}

Now everywhere you were using getRepository , use the application's taskRepository instead. This ensures that instead of making the repository directly, you are getting whatever repository the ServiceLocator provided.

  1. Open TaskDetailFragement and find the call to getRepository at the top of the class.
  2. Replace this call with a call that gets the repository from 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. Do the same for 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. For StatisticsViewModel and AddEditTaskViewModel , update the code that acquires the repository to use the repository from the TodoApplication .

TasksFragment.kt

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



// WITH this code

    private val tasksRepository = (application as TodoApplication).taskRepository

  1. Run your application (not the test)!

Since you only refactored, the app should run the same without issue.

Step 3. Create FakeAndroidTestRepository

You already have a FakeTestRepository in the test source set. You cannot share test classes between the test and androidTest source sets by default. So, you need to make a duplicate FakeTestRepository class in the androidTest source set, and call it FakeAndroidTestRepository .

  1. Right-click the androidTest source set and make a data package. Right-click again and make a source package.
  2. Make a new class in this source package called FakeAndroidTestRepository.kt .
  3. Copy the following code to that class.

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

Step 4. Prepare your ServiceLocator for Tests

Okay, time to use the ServiceLocator to swap in test doubles when testing. To do that, you need to add some code to your ServiceLocator code.

  1. Open ServiceLocator.kt .
  2. Mark the setter for tasksRepository as @VisibleForTesting . This annotation is a way to express that the reason the setter is public is because of testing.

ServiceLocator.kt

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

Whether you run your test alone or in a group of tests, your tests should run exactly the same. What this means is that your tests should have no behavior that is dependent on one another (which means avoiding sharing objects between tests).

Since the ServiceLocator is a singleton, it has the possibility of being accidentally shared between tests. To help avoid this, create a method that properly resets the ServiceLocator state between tests.

  1. Add an instance variable called lock with the Any value.

ServiceLocator.kt

private val lock = Any()
  1. Add a testing-specific method called resetRepository which clears out the database and sets both the repository and database to null.

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
        }
    }

Step 5. Use your ServiceLocator

In this step, you use the ServiceLocator .

  1. Open TaskDetailFragmentTest .
  2. Declare a lateinit TasksRepository variable.
  3. Add a setup and a tear down method to set up a FakeAndroidTestRepository before each test and clean it up after each test.

TaskDetailFragmentTest.kt

    private lateinit var repository: TasksRepository

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

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }
  1. Wrap the function body of activeTaskDetails_DisplayedInUi() in runBlockingTest .
  2. Save activeTask in the repository before launching the fragment.
repository.saveTask(activeTask)

The final test looks like this code below.

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. Annotate the whole class with @ExperimentalCoroutinesApi .

When finished, the code will look like this.

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. Run the activeTaskDetails_DisplayedInUi() test.

Much like before, you should see the fragment, except this time, because you properly set up the repository, it now shows the task information.


In this step, you'll use the Espresso UI testing library to complete your first integration test. You have structured your code so you can add tests with assertions for your UI. To do that, you'll use the Espresso testing library .

Espresso helps you:

  • Interact with views, like clicking buttons, sliding a bar, or scrolling down a screen.
  • Assert that certain views are on screen or are in a certain state (such as containing particular text, or that a checkbox is checked, etc.).

Step 1. Note Gradle Dependency

You'll already have the main Espresso dependency since it is included in Android projects by default.

app/build.gradle

dependencies {

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

androidx.test.espresso:espresso-core —This core Espresso dependency is included by default when you make a new Android project. It contains the basic testing code for most views and actions on them.

Step 2. Turn off animations

Espresso tests run on a real device and thus are instrumentation tests by nature. One issue that arises is animations: If an animation lags and you try to test if a view is on screen, but it's still animating, Espresso can accidentally fail a test. This can make Espresso tests flaky.

For Espresso UI testing, it's best practice to turn animations off (also your test will run faster!):

  1. On your testing device, go to Settings > Developer options .
  2. Disable these three settings: Window animation scale , Transition animation scale , and Animator duration scale .

Step 3. Look at an Espresso test

Before you write an Espresso test, take a look at some Espresso code.

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

What this statement does is find the checkbox view with the id task_detail_complete_checkbox , clicks it, then asserts that it is checked.

The majority of Espresso statements are made up of four parts:

1. Static Espresso method

onView

onView is an example of a static Espresso method that starts an Espresso statement. onView is one of the most common ones, but there are other options, such as onData .

2. ViewMatcher

withId(R.id.task_detail_title_text)

withId is an example of a ViewMatcher which gets a view by its ID. There are other view matchers which you can look up in the documentation .

3. ViewAction

perform(click())

The perform method which takes a ViewAction . A ViewAction is something that can be done to the view, for example here, it's clicking the view.

4. ViewAssertion

check(matches(isChecked()))

check which takes a ViewAssertion . ViewAssertion s check or asserts something about the view. The most common ViewAssertion you'll use is the matches assertion. To finish the assertion, use another ViewMatcher , in this case isChecked .

Note that you don't always call both perform and check in an Espresso statement. You can have statements that just make an assertion using check or just do a ViewAction using perform .

  1. Open TaskDetailFragmentTest.kt .
  2. Update the activeTaskDetails_DisplayedInUi test.

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

Here are the import statements, if needed:

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. Everything after the // THEN comment uses Espresso. Examine the test structure and the use of withId and check to make assertions about how the detail page should look.
  2. Run the test and confirm it passes.

Step 4. Optional, Write your own Espresso Test

Now write a test yourself.

  1. Create a new test called completedTaskDetails_DisplayedInUi and copy this skeleton code.

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. Looking at the previous test, complete this test.
  2. Run and confirm the test passes.

The finished completedTaskDetails_DisplayedInUi should look like this code.

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

In this last step you'll learn how to test the Navigation component , using a different type of test double called a mock, and the testing library Mockito .

In this codelab you've used a test double called a fake. Fakes are one of many types of test doubles. Which test double should you use for testing the Navigation component ?

Think about how navigation happens. Imagine pressing one of the tasks in the TasksFragment to navigate to a task detail screen.

Here's code in TasksFragment that navigates to a task detail screen when it is pressed.

TasksFragment.kt

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


The navigation occurs because of a call to the navigate method. If you needed to write an assert statement, there isn't a straightforward way to test whether you've navigated to TaskDetailFragment . Navigating is a complicated action that doesn't result in a clear output or state change, beyond initializing TaskDetailFragment .

What you can assert is that the navigate method was called with the correct action parameter. This is exactly what a mock test double does—it checks whether specific methods were called.

Mockito is a framework for making test doubles. While the word mock is used in the API and name, it is not for just making mocks. It can also make stubs and spies.

You will be using Mockito to make a mock NavigationController which can assert that the navigate method was called correctly.

Step 1. Add Gradle Dependencies

  1. Add the gradle dependencies.

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 —This is the Mockito dependency.
  • dexmaker-mockito —This library is required to use Mockito in an Android project. Mockito needs to generate classes at runtime. On Android, this is done using dex byte code, and so this library enables Mockito to generate objects during runtime on Android.
  • androidx.test.espresso:espresso-contrib —This library is made up of external contributions (hence the name) which contain testing code for more advanced views, such as DatePicker and RecyclerView . It also contains Accessibility checks and class called CountingIdlingResource that is covered later.

Step 2. Create TasksFragmentTest

  1. Open TasksFragment .
  2. Right-click on the TasksFragment class name and select Generate then Test . Create a test in the androidTest source set.
  3. Copy this code to the 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()
    }

}

This code looks similar to the TaskDetailFragmentTest code you wrote. It sets up and tears down a FakeAndroidTestRepository . Add a navigation test to test that when you click on a task in the task list, it takes you to the correct TaskDetailFragment .

  1. Add the test 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. Use Mockito's mock function to create a mock.

TasksFragmentTest.kt

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

To mock in Mockito, pass in the class you want to mock.

Next, you need to associate your NavController with the fragment. onFragment lets you call methods on the fragment itself.

  1. Make your new mock the fragment's NavController .
scenario.onFragment {
    Navigation.setViewNavController(it.view!!, navController)
}
  1. Add the code to click on the item in the RecyclerView that has the text "TITLE1".
// WHEN - Click on the first list item
        onView(withId(R.id.tasks_list))
            .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
                hasDescendant(withText("TITLE1")), click()))

RecyclerViewActions is part of the espresso-contrib library and lets you perform Espresso actions on a RecyclerView .

  1. Verify that navigate was called, with the correct argument.
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
    TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")

Mockito's verify method is what makes this a mock—you're able to confirm the mocked navController called a specific method ( navigate ) with a parameter ( actionTasksFragmentToTaskDetailFragment with the ID of "id1").

The complete test looks like this:

@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. Run your test!

In summary, to test navigation you can:

  1. Use Mockito to create a NavController mock.
  2. Attach that mocked NavController to the fragment.
  3. Verify that navigate was called with the correct action and parameter(s).

Step 3. Optional, write clickAddTaskButton_navigateToAddEditFragment

To see if you can write a navigation test yourself, try this task.

  1. Write the test clickAddTaskButton_navigateToAddEditFragment which checks that if you click on the + FAB, you navigate to the AddEditTaskFragment .

The answer is below.

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

Click here to see a diff between the code you started and the final code.

To download the code for the finished codelab, you can use the git command below:

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


Alternatively you can download the repository as a Zip file, unzip it, and open it in Android Studio.

Download Zip

This codelab covered how to set up manual dependency injection, a service locator, and how to use fakes and mocks in your Android Kotlin apps. In particular:

  • What you want to test and your testing strategy determine the kinds of test you are going to implement for your app. Unit tests are focused and fast. Integration tests verify interaction between parts of your program. End-to-end tests verify features, have the highest fidelity, are often instrumented, and may take longer to run.
  • The architecture of your app influences how hard it is to test.
  • TDD or Test Driven Development is a strategy where you write the tests first, then create the feature to pass the tests.
  • To isolate parts of your app for testing, you can use test doubles. A test double is a version of a class crafted specifically for testing. For example, you fake getting data from a database or the internet.
  • Use dependency injection to replace a real class with a testing class, for example, a repository or a networking layer.
  • Use i nstrumented testing ( androidTest ) to launch UI components.
  • When you can't use constructor dependency injection, for example to launch a fragment, you can often use a service locator. The Service Locator pattern is an alternative to Dependency Injection. It involves creating a singleton class called the "Service Locator", whose purpose is to provide dependencies, both for the regular and test code.

Udacity course:

Android developer documentation:

Videos:

Other:

For links to other codelabs in this course, see the Advanced Android in Kotlin codelabs landing page.