В первой части этой серии были определены критерии эффективной стратегии тестирования и описаны в общих чертах различные типы тестов, реализуемые в Android-проектах. Во второй части сосредоточимся на модульном (unit) тестировании: выясним, что это такое, как и когда его применять.

Определение модульного теста

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

@Test
fun `calculateStops returns correct number of stops for a given line ID`() {

val repository = LinesRepository()
val route = BusRoute(repository, "Line123")

val numberOfStops = route.calculateStops()

assertEquals(3, numberOfStops)
}

Как видите, у нас есть класс с именем «BusRoute» («Автобусный маршрут»), который вычисляет количество остановок для определенного идентификатора автобусной линии. В тесте проверяется, правильно ли работает функция и возвращает ли ожидаемое количество остановок для данной автобусной линии.

Две школы модульного тестирования

Сообщество разработчиков программного обеспечения в целом согласно с ключевыми характеристиками модульного тестирования. Расходятся лишь мнения о наилучшем подходе к изоляции. Когда речь идет о тестировании небольшого участка кода, что именно имеется в виду? Это всего лишь одна функция или целый класс? Может ли это быть компонент, включающий в себя другие классы в качестве взаимодействующих объектов? Нужно ли тестировать и эти зависимости или допустимо заменить их моками (mocks) или стабами (stubs)? Рассмотрим эти вопросы более подробно.

Классический подход (Детройтская школа)

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

При таком подходе изоляция не означает полного отделения тестируемого кода от остальной части системы, включая взаимодействующие объекты и зависимости. Нужно изолировать только эту конкретную функциональность от любой другой логики, не связанной с тестируемым объектом (в дальнейшем — SUT). Зависимости должны быть такими же, как и в производственном коде. Однако, чтобы тесты работали быстро, надежно и безошибочно, нельзя добавлять реальные экземпляры «внепроцессных» зависимостей, таких как базы данных, сетевые запросы и т. д.

В приведенном выше примере, когда инстанцируем SUT, BusRoute, передаем реальный экземпляр (не мок) LinesRepository.

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

Тестирование с помощью моков (Лондонская школа)

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

@Test
fun `calculateStops returns correct number of stops for a given line ID using a mocked service`() {

val busStopService = mock(BusStopService::class.java)
`when`(busStopService.getStops("Line123")).thenReturn(listOf("Station A", "Station B", "Station C"))

val route = BusRoute(busStopService)
val numberOfStops = route.calculateStops(lineId)

assertEquals(3, numberOfStops)
}

В этом примере SUT — класс BusRoute, а зависимости были смоделированы так, чтобы обеспечить ожидаемое поведение. Это очень важно, поскольку внимание на 100% сосредоточено на функциональности этого класса и ни на чем другом. Если вносится регрессия и тест проваливается, можно не сомневаться, что ошибка находится в этом элементе и только здесь. Такая детализация обеспечивает эффективный и быстрый процесс поиска проблем в коде.

Что должен включать модульный тест?

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

Проверка поведения, а не реализации

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

Единственная ответственность

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

Пограничные случаи

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

Анатомия модульного теста

Шаблон AAA (arrange/act/assert — настройка/выполнение/проверка) получил широкое признание в индустрии программного обеспечения, поскольку обеспечивает методичный и понятный подход к написанию и сопровождению тестов. Идея заключается в разделении теста на три части:

  1. Настройка (arrange) начальных условий, необходимых для теста, таких как инициализация объектов и конфигурация имитационного поведения.
  1. Выполнение (act) функциональности, которую нужно проверить.
  1. Проверка (assert) результата работы тестируемой функциональности и подтверждение, что он соответствует ожидаемому результату.

Вот как это выглядит на практике:

@Test
fun `calculateStops returns correct number of stops for a given line ID`() {

// Arrange
val repository = LinesRepository() // Настройка реального или имитационного репозитория
val route = BusRoute(repository, "Line123")// Создание экземпляра BusRoute с заданным идентификатором линии

// Act
val numberOfStops = route.calculateStops() // Вызов тестируемого метода

// Assert
assertEquals(3, numberOfStops) // Убедитесь, что результат соответствует ожидаемому количеству остановок
}

Тестовые дублеры

В Лондонской школе модульного тестирования часто называют все объекты, используемые для замены зависимостей SUT, «моками». Однако на самом деле существует несколько типов тестовых дублеров, каждый из которых имеет определенное назначение в модульном тестировании.

Пустышка (Dummy)

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

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

Пример: передача пустышки Context при тестировании метода, который не взаимодействует с платформой Android.

Библиотека/фреймворк: для пустышек не требуется специальная библиотека, поскольку они могут быть просто заполнителями или null-объектами.

@Test
fun `calculateStops returns correct number of stops using a dummy Context`() {
// Arrange
val dummyContext = mock(Context::class.java)
val route = BusRoute(dummyContext, "Line123")

// Act
val numberOfStops = route.calculateStops()

// Assert
assertEquals(3, numberOfStops)
}

Фейк (fake) 

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

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

Пример: реализация SharedPreferences в памяти или фейковая сетевая служба, которая возвращает предопределенные ответы.

Библиотека/фреймворк:

  • Room: используйте конфигурацию базы данных в памяти, чтобы имитировать уровень сохраняемости;
  • Faker: используйте библиотеки, такие как MockWebServer, чтобы имитировать сетевые ответы.
class FakeBusStopService : BusStopService {
override fun getStops(lineId: String): List<String> {
return listOf("Station A", "Station B", "Station C")
}
}

@Test
fun `calculateStops returns correct number of stops using a FakeBusStopService`() {
// Arrange
val fakeService = FakeBusStopService()
val route = BusRoute(fakeService, "Line123")

// Act
val numberOfStops = route.calculateStops()

// Assert
assertEquals(3, numberOfStops)
}

Стаб (stub)

Стаб — тестовый дублер, который предоставляет конкретные, заранее определенные ответы на вызовы методов. Это позволяет SUT выполнять свою логику, не полагаясь на полную реализацию зависимости.

Когда использовать: в случаях, когда нужно управлять выводом зависимости для тестирования определенных сценариев с SUT.

Пример: отключение метода в Repository для возврата определенного значения при тестировании ViewModel.

Библиотека/фреймворк:

  • Mockito: используйте конструкцию when().thenReturn() для создания стабов;
  • Kotlin: используйте MockK для более идиоматичного создания стабов в Kotlin.
@Test
fun `calculateStops returns correct number of stops using a stubbed BusStopService`() {
// Arrange
val busStopService = mock(BusStopService::class.java)
`when`(busStopService.getStops("Line123")).thenReturn(listOf("Station A", "Station B", "Station C"))
val route = BusRoute(busStopService, "Line123")

// Act
val numberOfStops = route.calculateStops()

// Assert
assertEquals(3, numberOfStops)
}

Мок (mock)

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

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

Пример: проверка того, что метод для сохранения данных в Repository вызывается при отправке формы.

Библиотека/фреймворк:

  • Mockito: наиболее широко используемая библиотека для создания моков и проверки взаимодействий;
  • MockK: мощная альтернатива для Kotlin-проектов.
@Test
fun `calculateStops calls getStops on BusStopService`() {
// Arrange
val busStopService = mock(BusStopService::class.java)
val route = BusRoute(busStopService, "Line123")

// Act
route.calculateStops()

// Assert
verify(busStopService).getStops("Line123")
}

Шпион (spy)

Шпион служит оберткой для реального объекта, позволяя вызывать его реальные методы, одновременно отслеживая и проверяя взаимодействие с ним. Это полезно при частичном имитировании объекта с сохранением части его реального поведения.

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

Пример: отслеживание ViewModel для обеспечения вызова определенных методов жизненного цикла при одновременном выполнении реальной логики ViewModel.

Библиотека/фреймворк:

  • Mockito: используйте spy(), чтобы создать обертку-шпиона вокруг реального объекта;
  • Mock: обеспечивает поддержку шпионов, что позволяет создавать более гибкие сценарии тестирования в Kotlin.

Надежная стратегия модульного тестирования

Эффективный модульный тест — не только гарантирует проверку правильной работы кода; он также служит долгосрочной порукой стабильности и надежности сопровождения приложения. Выделим его ключевые характеристики.

Защита от регрессий и рефакторинга

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

Удобство сопровождения

Модульные тесты должны быть простыми в обслуживании, понимании и (при необходимости) модификации. Необходимо избегать слишком сложной части теста «Arrange» («Настройка») или проблем с предоставлением зависимостей, которые могут затруднить работу с тестом в долгосрочной перспективе.

Быстрая обратная связь

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

Модульное тестирование в Android

Теперь, освоив основы модульного тестирования, поговорим о том, какие части и компоненты Android-приложения следует тестировать.

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

Бизнес-логика

  • ViewModel. В этом компоненте содержится большая часть UI-логики и во многих случаях бизнес-логика (в зависимости от архитектуры и уровня разделения задач). Здесь необходимо тестировать различные состояния модели после определенного взаимодействия с пользователем, ответы на данные из Repository и т. д.
class BusRouteViewModelTest {

    private lateinit var viewModel: BusRouteViewModel
    private val repository = mock(LinesRepository::class.java)

    @Before
    fun setUp() {
        viewModel = BusRouteViewModel(repository)
    }

    @Test
    fun `fetchStops sets stops LiveData correctly`() {
        // Arrange
        val expectedStops = listOf("Station A", "Station B", "Station C")
        `when`(repository.getStops("Line123")).thenReturn(expectedStops)

        // Act
        viewModel.fetchStops("Line123")

        // Assert
        assertEquals(expectedStops, viewModel.stops.getOrAwaitValue())
    }
}
  • Use Case (Interactor): этот компонент, используемый в реализации чистой архитектуры, содержит бизнес-логику, которая должна пройти модульное тестирование.

Утилитные классы, хелперы, мапперы

Необходимо протестировать любую функцию, класс, хелпер (helper), маппер (mapper) и т. д., которые выполняют вычисления, форматирование данных и подобную логику.

//  Возьмем для примера этот утилитный класс:
object BusRouteUtils {
fun formatStops(stops: List<String>): String {
return stops.joinToString(separator = " -> ")
}
}

// Это может быть тест-кейсом:
class BusRouteUtilsTest {
@Test
fun `formatStops returns correctly formatted string`() {
// Arrange
val stops = listOf("Station A", "Station B", "Station C")
val expectedFormat = "Station A -> Station B -> Station C"

// Act
val result = BusRouteUtils.formatStops(stops)

// Assert
assertEquals(expectedFormat, result)
}
}

Уровень данных

  • Репозитории: проверьте, правильно ли Repository извлекает, хранит, кэширует и обрабатывает данные. Особенно важно, если этот компонент действительно обладает функциями репозитория, такими как извлечение данных из разных источников, сопоставление и тому подобное.
  • Мапперы данных: модели данных, преобразованные, скажем, в модели предметной области, — также идеальные кандидаты для модульного тестирования.
class BusRouteRepositoryTest {

private lateinit var repository: BusRouteRepository
private val localDataSource = mock(BusStopDataSource::class.java)
private val remoteDataSource = mock(BusStopDataSource::class.java)

@Before
fun setUp() {
repository = BusRouteRepository(localDataSource, remoteDataSource)
}

@Test
fun `getStops returns local data when available`() {
// Arrange
val expectedStops = listOf("Station A", "Station B")
`when`(localDataSource.getStops("Line123")).thenReturn(expectedStops)

// Act
val result = repository.getStops("Line123")

// Assert
assertEquals(expectedStops, result)
verify(remoteDataSource, never()).getStops(anyString())
}

@Test
fun `getStops fetches from remote and saves locally when local data is empty`() {
// Arrange
val expectedStops = listOf("Station A", "Station B")
`when`(localDataSource.getStops("Line123")).thenReturn(emptyList())
`when`(remoteDataSource.getStops("Line123")).thenReturn(expectedStops)

// Act
val result = repository.getStops("Line123")

// Assert
assertEquals(expectedStops, result)
verify(localDataSource).saveStops("Line123", expectedStops)
}
}

Заключение

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

Хотя в этой статье представлен всесторонний обзор модульного тестирования, такие темы, как разработка на основе тестирования (test-driven development, TDD) и интеграция тестов в непрерывную интеграцию (continuous integration, CI), являются важными аспектами, которые будут рассмотрены в будущих статьях. Для тех, кто заинтересован в углубленном изучении модульного тестирования, настоятельно рекомендую книгу Владимира Хорикова «Модульное тестирование: принципы, практики, шаблоны», которая послужила ценным справочным материалом для данной статьи.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи David Guerrero: An effective testing strategy for Android (Part 2) – Unit Testing

Предыдущая статьяТоп-25 полезных советов для React-разработчиков. Часть 2