В первой части этой серии были определены критерии эффективной стратегии тестирования и описаны в общих чертах различные типы тестов, реализуемые в 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 — настройка/выполнение/проверка) получил широкое признание в индустрии программного обеспечения, поскольку обеспечивает методичный и понятный подход к написанию и сопровождению тестов. Идея заключается в разделении теста на три части:
- Настройка (arrange) начальных условий, необходимых для теста, таких как инициализация объектов и конфигурация имитационного поведения.
- Выполнение (act) функциональности, которую нужно проверить.
- Проверка (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), являются важными аспектами, которые будут рассмотрены в будущих статьях. Для тех, кто заинтересован в углубленном изучении модульного тестирования, настоятельно рекомендую книгу Владимира Хорикова «Модульное тестирование: принципы, практики, шаблоны», которая послужила ценным справочным материалом для данной статьи.
Читайте также:
- Освоение широковещательных приемников в Android
- Адаптируем Android-приложение к большим размерам экрана с помощью классов window-size
- Как предотвратить утечки памяти в Android-приложении
Читайте нас в Telegram, VK и Дзен
Перевод статьи David Guerrero: An effective testing strategy for Android (Part 2) – Unit Testing