Полное руководство по тестовым дублерам в Android — Часть 1: Теория

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Моки, стабы, фейки, пустышки и шпионы в Android: от теории к (хорошей) практике

Независимо от технологий и продуктов, с которыми вы работаете, знание того, как использовать тестовые дублеры (test doubles), имеет основополагающее значение для любой стратегии автоматизированного тестирования. В частности, при работе с неинструментальными тестами в Android использование такого рода ресурсов становится еще более важным. По сути, концепция тестовых дублеров довольно проста, но большое количество доступных именований, определений и инструментов неминуемо вызывает путаницу в сообществе разработчиков. Вы наверняка уже слышали что-то вроде этого:

  • «Нам просто нужно мокнуть эту зависимость, и все будет работать нормально»

  • «Избегайте моков!»

  • «Моки или стабы?»

  • «Предпочитаю мокам фейки»

Можете мне не верить, но приведенные выше высказывания могут быть истолкованы совершенно по-разному, если мы не знаем точных определений. Если вы никогда не слышали о тестовых дублерах или хотите углубиться в эту тему, то эта статья для вас!

Что такое тестовые дублеры?

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

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

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

Концепция тестовых дублеров была привнесена Джерардом Месарошем (Gerard Meszaros) в книге Шаблоны тестирования xUnit: рефакторинг кода тестов и дополнена многими другими работами в области разработки и тестирования программного обеспечения. В технической литературе мы можем выделить 5 категорий тестовых дублеров: пустышки (dummies), стабы (stubs), фейки (fakes), моки (mocks) и шпионы (spies). Каждая из них имеет свое конкретное предназначение.

Пустышка (Dummy)

Пустышка — простейший тестовый дублер. Его единственная цель — быть переданным в качестве аргумента, не имея какого-либо особого значения для теста.

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

@Test
fun `Update registered note count when registering a new note in empty repository`() {
    val dummyNote = //Dummy
    
    noteRepository.registerNote(dummyNote) //Just filling the parameter, the double's content is not relevant for the test
    val allNotes = noteRepository.getNotes()

    assertEquals(expected = 1, actual = allNotes.size)
}

Простой пример использования пустышки в тесте

В приведенном выше примере dummyNote имеет одну единственную цель - быть переданным в качестве параметра. Его внутренние значения для теста не очень важны. Ниже мы можем найти несколько примеров переменных, которые также можно считать пустышками:

//----- Literal dummy -----
val dummyPrice = 10.0 

//----- Generated dummy -----
val dummyCustomer = CustomerTestBuilder.build() 

//----- Alternative empty implementation -----
val dummyNote = DummyNote() 

class DummyNote(): Note //No implementation  
class RealNote(): Note //Real implementation 

Другие типы путышек, созданных без каких-либо инструментов

Пустышки обычно замещают объекты данных, которые сложно настроить. Они также помогают сделать тестовый код компактным, чистым и свободным от внешних инструментов из-за их простоты. Используйте другого тестового дублера или инструмент только в том случае, если это действительно необходимо.

Стаб (Stub)

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

Стабы обычно генерируются добавляемыми в ваши проекты инструментами, такими как Mockito или Mockk, но их также можно создавать вручную. Они предотвращают выполнение медленных или недетерминированных вызовов во время выполнения теста.

@Test
fun `Retrieve notes count from server when requested`() {
    val notesApiStub = //Stub
    val note = //Dummy
    val noteRepository = NoteRepository(notesApiStub) //System under test

    //Stub configuration. Hard-coded value returned will be a list with 2 entries.
    //This method is going to be called by noteRepository.getNoteCount()
    every { notesApiStub.fetchAllNotes() } returns listOf(note, note)

    val allNotes = noteRepository.getNotes()

    assertEquals(expected = 2, actual = allNotes.size)
}

Простой пример использования стаба в тесте

Для конфигурации стаба в приведенном выше примере мы используем MockK. Вы можете наблюдать в строке 9, как выполняется эта конфигурация — мы устанавливаем только то, что зависимость notesApiStub должна ответить при вызове fetchAllNotes():

every { notesApiStub.fetchAllNotes() } returns listOf(note, note)

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

interface NoteApi {
    suspend fun uploadNote(note: Note): Result
    suspend fun fetchAllNotes(): List<Note>
}

class RealNoteApi : NoteApi {
    override suspend fun uploadNote(note: Note): Result {
        //Real implementation
    }

    override suspend fun fetchAllNotes(): List<Note> {
        //Real implementation
    }
}

class StubNoteApi(
  val notes: List<Note> = listOf(), 
  val result: Result = Result.Success
) : NoteApi {
    override suspend fun uploadNote(note: Note): Result {
        return result
    }

    override suspend fun fetchAllNotes(): List<Note> {
        return notes
    }
}

Реализованный вручную стаб с фиксированными ответами

Независимо от того, как создается стаб, предварительно настроенный ответ будет возвращен немедленно, без обращения к реальному бэкенду. Используйте стабы, когда вам нужны быстрые, детерминированные, предварительно настроенные ответы для вашего теста.

Фейк (Fake)

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

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

@Test
fun `Retrieve all notes when requested`() {
    val noteApiFake = FakeNoteApi() //Fake double implementing the same interface as the original
    val noteRepository = NoteRepository(noteApiFake) //System under test 
    val note = //Dummy
    noteApiFake.uploadNote(note) //Configuring the fake
    noteApiFake.uploadNote(note) //Configuring the fake
    
    //Fake with real and lightweight implementation is going to be used under the hoods
    val allNotes = noteRepository.getNotes()

    assertEquals(expected = 2, actual = allNotes.size)
}

Простой пример использования фейка в тесте

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

Фейки также могут быть реализованы вручную, как показано в примере ниже:

interface NoteApi {
    suspend fun uploadNote(note: Note): Result
    suspend fun fetchAllNotes(): List<Note>
}

class RealNoteApi: NoteApi {
    override suspend fun uploadNote(note: Note): Result {
        //Real impl
    }

    override suspend fun fetchAllNotes(): List<Note> {
        //Real impl
    }
}

class FakeNoteApi: NoteApi {
    private val notes = mutableListOf<Note>()
    
    override suspend fun uploadNote(note: Note): Result {
        notes.add(note)
        
        return Result.Success
    }

    override suspend fun fetchAllNotes(): List<Note> {
        return notes
    }
}

Реализованный вручную фейк, немного более умная реализация по сравнению со стабом

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

val database = Room.inMemoryDatabaseBuilder(
    context, 
    MainDatabase::class.java
).build()

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

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

Мок (Mock)

The Mock — это дублер, предназначенный для проверки конкретных взаимодействий с зависимостями во время выполнения теста. Другими словами, моки заменяют зависимости, которые необходимо наблюдать при тестировании системы.

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

@Test
fun `Track analytics event when creating new note`() {
    val analyticsWrapperMock = //Mock
    val noteAnalytics = NoteAnalytics(analyticsWrapperMock) //System under test

    noteAnalytics.trackNewNoteEvent(NoteType.Supermarket)

    //Verifies that specific call has happened
    verify(exactly = 1) { analyticsWrapperMock.logEvent("NewNote", "SuperMarket") }
}

Простой пример использования мока в тесте

Для создания мока в приведенном выше примере мы используем MockK. В нем нам нужно убедиться, что NoteAnalytics вызывает метод AnalyticsWrapper.logEvent(String, String) с определенными параметрами при завершении вызова NoteAnalytics.trackNewNoteEvent(Enum).

verify(exactly = 1) { 
    analyticsWrapperMock.logEvent("NewNote", "SuperMarket") 
}

Задача мока — наблюдать и проверять взаимодействие с зависимостью, а цель стаба/фейка — имитировать поведение зависимости и возвращать предопределенные значения.

Используйте моки, когда вам нужно проверить определенные взаимодействия с зависимостями, особенно если тестируемое поведение не имеет конкретного возвращаемого значения для проверки утверждения (метод, который возвращает Void или Unit). Избегайте моков зависимостей, которые имеют определенные возвращаемые значения.

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

Шпион (Spy)

На мой взгляд, шпион — самый неоднозначный тестовый дублер из всех, так как его определение разнится от автора к автору. Резюмируя первоначальное определение Джерарда Месароша и перефразируя его своими словами:

Мы можем смело утверждать, что у шпионов те же задачи, что и у моков, т.е. наблюдение и проверка взаимодействий с зависимостями во время выполнения теста. Разница в том, что шпионы используют функциональную реализацию для работы и могут записывать более сложные состояния, которые можно использовать для последующей проверки или утверждения (assertion).

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

@Test
fun `Track analytics event when creating new note`() {
    val analyticsWrapperSpy = //Spy
    val noteAnalytics = NoteAnalytics(analyticsWrapperSpy) //System under test

    //AnalyticsWrapperSpy records the interaction with NoteAnalytics under the hoods
    noteAnalytics.trackCreateNewNoteEvent(NoteType.Supermarket)

    //Based on the its internal implementation, the spy returns the state of the dependency
    val numberOfEvents = analyticsWrapperSpy.getNewNoteEventsRegistered()
    assertEquals(expected = 1, actual = numberOfEvents)
}

Простой пример использования шпиона в тесте

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

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

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

Пустышки, стабы, фейки, моки и шпионы: заключение

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

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

  • Те, что не имитируют поведение и не наблюдает за взаимодействиями: пустышки.

  • Те, что имитируют поведение: стабы и фейки.

  • Те, что наблюдают за взаимодействиями: моки и шпионы.

  • Те, что не располагают функциональной реализации под капотом: пустышки, стабы и моки.

  • Те, что имеют функциональную реализацию под капотом: фейки и шпионы.

Сводка по 5 тестовым дублерам
Сводка по 5 тестовым дублерам

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

Почему люди называют все тестовые дублеры моками?

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

Например, MockWebServer, MockK и Mockito. Независимо от того, является ли тестовый дублер фейком, стабом или моком, обычно мы слышим имя “мок”. Одна из причин этого заключается в том, что некоторые дублеры могут выполнять несколько ролей, одновременно являясь и моками, и стабами. Учитывая эти случаи, стало предпочтительнее называть дублеров, которые являются более общими или имеют несколько ролей, моками, а не создавать для них другую номенклатуру.

Другая причина такого обобщения связана с наличием противоречивых определений, которые часто появляются в книгах и статьях в интернете:

«Классификация моков, фейков и стабов в литературе крайне противоречива.»

— Взято из Википедии, Mock Object

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

Роль тестовых дублеров в методологиях/школах тестирования

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

  • Школа общительного (sociable) тестирования. Также известна как классическая школа, детройтский или чикагский стиль.

  • Или школа одиночного (solitary) тестирования. Также известна как мокистский или лондонский стиль.

Сравнение двух школ тестирования. Источник: https://martinfowler.com/bliki/UnitTest.html
Сравнение двух школ тестирования. Источник: https://martinfowler.com/bliki/UnitTest.html

Возможно, вы создаете тесты по принципу одной из этих школ и даже не осознаете этого. Основное различие между ними заключается в определении модуля ("unit"), из которого следует определение модульного теста. Ниже изложено краткое описание каждой из этих школ тестирования.

Для классической школы тестирования (Детройтский стиль) модуль представлена ​​​поведением, независимо от того, состоит ли это поведение из более чем одного класса или нет. В этом случае модульный тест может быть выполнен с несколькими реальными классами, если они представляют одно поведение. Тест выполняется быстро и может быть распараллелен (без удержания состояния). Здесь рекомендуется сократить количество тестовых дублеров и просто использовать их для замены зависимостей, которые имеют состояния с общим доступом, работают слишком медленно или находятся вне вашего контроля, например база данных, бэкенд или сторонние инструменты.

Для одиночной школы тестирования (Лондонский стиль) модуль представлен классом. В этом случае все зависимости тестируемого класса следует заменить дублерами, даже если настоящие быстро и легко настраиваются. Этот стиль тестирования предназначен для достижения более высокого уровня изоляции и контроля.

Заключение

Вот и все. Мы разобрались с теорией использования тестовых дублей. Во второй части этой серии узнаем немного больше о специфике Android.


Перевод материала подготовлен в преддверии старта специализации Android Developer.

Источник: https://habr.com/ru/company/otus/blog/673152/


Интересные статьи

Интересные статьи

Всем добрый денек! Надеюсь после первых трех статей, эта вам покажется не менее полезной.Сегодня я постараюсь простым языком объяснить MVC паттерн.И конечно же покажу все...
Материал статьи взят с моего дзен-канала. Создаем тональный генератор В предыдущей статье мы выполнили установку библиотеки медиастримера, инструментов разработки и проверили их функционировани...
Привет, Хабр. Во второй части были рассмотрены 5 способов использования Raspberry Pi. И как оказывается, этот микрокомпьютер способен ещё на большее. Сейчас мы рассмотрим еще 5, надеюсь, не ме...
В нашем сегодняшнем материале мы расскажем о том, как DPI-системы помогают интернет-провайдерам экономить, защищать данные клиентов, и поговорим о способах подключения.
Зачем же нужно делать резервные копии? Ведь оборудование весьма и весьма надежное, к тому же есть «облака», которые по надежности лучше физических серверов: при правильной настройке «облачный» ...