Ранее мы с вами познакомились с 2 группами тестирования, расположенными в основании и на вершине пирамиды тестирования. Пришло время познакомиться с единственной, не рассмотренной на данный момент и наиболее интересной по моему мнению частью – интеграционным тестированием.
Перед тем как приступить к рассмотрению самих интеграционных тестов, предлагаю сделать краткое резюме описанного ранее материала и обратить внимание на плюсы и минусы юнит и системных тестов. Для этого проанализируем наиболее типовую на данный момент архитектуру мобильного приложения и попробуем разработать тестовую стратегию, на основе описанных ранее видов тестирования.
В ряде случаев некоторые слои или сущности могут не быть представлены отдельными классами или объектами. Однако их роль тем или иным образом все равно будет представлена в приложении (например в случае Redux-подобных архитектур, слои и сущности могут называться немного иначе, но даже такие архитектуры можно замапить
на приведенную схему). Действия пользователя поступают в слой представления, где они обрабатываются и трансформируются в ограниченный набор предусмотренных команд, которые формируют бизнес логику приложения. Команды при необходимости запрашивают данные у data-слоя, который может скрывать под собой сетевое взаимодействие, базу и другие источники данных.
Проследим за потоком данных и его трансформациями на конкретном примере. Предположим, мы работаем над экраном регистрации, на котором пользователь вводит свои данные. По нажатию на кнопку Зарегистрироваться
приложение валидирует введенную информацию, отправляет запрос на сервер, в случае успеха перенаправляет пользователя на экран профиля, в случае ошибки – отображает ее описание.
Часто бывает, хотя к счастью случается все реже, что команды, особенно те которые только начинают внедрять практику написания тестов, ставят обязательные проверки, чтобы общее тестовое покрытие после каждого изменения (пуллреквеста
или диффа
) не уменьшилось, и весь новый код был покрыт тестами на какое-то определенное минимальное значение, скажем 80%. В таком случае, для нашего примера нам придется написать модульные тесты для слоя представления – презентера/вьюмодели/и т.п., слоя бизнес-логики – юзкейса/интерактора, слоя данных – репозитория, возможно для источника данных, для мапперов между всеми этими слоями и, возможно, в некоторых случаях, для парсеров.
Допустим, команда работала над этой новой функциональностью, но ближе к финальному сроку условия несколько изменились. Скажем, при регистрации пользователь вводит свой номер телефона, и, при успешном выполнении сервер возвращает модель зарегистрированного пользователя. Предположим, что изначально разработчики согласовали контракт, по которому номер телефона передавался как строка и возвращался в таком же формате. Но ближе к запуску исследователи и продукт пришли к выводу, что нам требуется знать к какой стране относится номер телефона и, для достижения этого, UI скорректировали так, что пользователь выбирает код страны из выпадающего списка и далее по-старому – абонентский номер в виде строки. Таким образом меняется API-контракт. И соответственно все тесты, которые были для этого написаны. Таким образом, если разработчик не предусмотрел такую возможность (что вполне нормально – не следует усложнять реализацию, когда это необязательно), пытаясь предусмотреть все возможные варианты, придется не только изменить код приложения, но также и переписать большое количество unit-тестов. Что на ранних этапах проекта, когда требования могут меняться очень динамично, может стать большой проблемой. (Выдуманная ситуация представлена лишь для примера и несколько гиперболизирована).
Попробуем решить эту проблему с помощью системных тестов – напишем e2e тест, который будет имитировать поведение пользователя. В этом случае проблема с изменением требований выглядит не так плохо, достаточно будет лишь изменить пару инструкций в page object экрана и позаботиться о том, чтобы сервер, используемый при тестировании возвращал ответ, соответствующий новому контракту. Однако, как было описано ранее – мы столкнемся со всеми проблемами UI тестов: запуск тестов будет требовать больше ресурсов, занимать больше времени и сигнал, получаемый при их запуске может быть не так стабилен. Выработанный нами подход демонстрирует основные качества юнит и системных тестов:
Модульные тесты
Плюсы:
Позволяют удостовериться, что компоненты приложения работают корректно и предотвращают регрессию;
Быстрое выполнение;
Позволяют выявить ошибки на раннем этапе разработки;
Улучшают качество кода.
Минусы:
Дают ложное чувство защищенности (тестируют изолированные компоненты, но всегда есть шанс, что интеграция будет сломана, и система будет работать неправильно);
Завязаны на детали реализации;
Дают не полное тестовое покрытие;
Дополнительные затраты времени и усилий, дополнительные расходы на поддержание.
Системные тесты:
Плюсы:
Позволяют удостовериться, что основная функциональность приложения работает корректно;
Позволяют выявить ошибки в работе элементов пользовательского интерфейса;
Повышают тестовое покрытие и позволяют сэкономить время;
Минусы:
Ресурсоемкие и затратные по времени;
Хрупкие;
Ограничены.
Как видим, оба рассмотренных вида тестирования имеют ряд плюсов и минусов, которые практически полностью друг друга компенсируют. В идеале, хотелось бы такие тесты, которые можно было бы прогонять также быстро и с наименьшими затратами как и unit-тестов, и в то же время охватить как можно большую часть проекта, как это происходит при системном тестировании.
Попробуем найти это самое решение. Для этого можно пойти 2 путями: взяв за основу модульные тесты и постепенно увеличивать их рамки, либо наоборот – начать с системных тестов и решать их проблемы за счет наработок юнит тестов.
Постараемся решить проблемы системных тестов, так как они могут быть особенно полезны на начальном этапе жизни проекта, когда требования могут довольно быстро и сильно меняться. Давайте проанализируем их и постараемся найти источник проблем.
Наличие эмулятора/физического устройства. E2E тесты – это в большинстве своем инструментальные тесты, требующие запуск эмулятора или физического устройства. Если архитектура выдержана правильно и код грамотно разбит по слоям, операционная система (в нашем случае Android), в основном нужна для отображения пользовательского интерфейса. Данный этап можно упростить, если тестировать не состояние view на экране, а валидировать корректность UIState. Context может также потребоваться в слое данных. Эту проблему можно постараться решить, например используя Robolectric.
Наличие сетевого взаимодействия. Большинство современных мобильных приложений взаимодействуют с удаленными серверами посредством сетевого взаимодействия. Что многократно расширяет их функциональность. Однако наличие сети довольно сильно влияет на быстроту выполнения тестов и зачастую требует наличие дополнительного тестового сервера. Который нуждается в обслуживании и настройке.
По моему опыту, устранение этих 2 факторов может помочь избежать практически все проблемы UI тестов. Так мы можем тестировать нашу систему как черный ящик. Подавать на вход UI event’ы и ответы сервера и валидировать корректность UI State и запросы к серверу.
Таким образом мы пришли к одному из видов интеграционного тестирования для мобильных приложений.
В классическом понимании, интеграционное тестирование направлено на тестирование целостности потоков данных и производится над системой, компоненты которой полностью или хотя бы основная функциональность которых протестирована модульными тестами. Оно позволяет решить проблемы, которые могут быть вызваны несогласованностью разработчиков, параллельно работающих над разными частями одной системы, некорректными внешними модулями и зависимостями.
С развитием сферы программного обеспечения и изменения подходов к разработке, интеграционное тестирование также претерпевает ряд изменений, и сейчас можно выделить 2 подкатегории интеграционного тестирования:
узкоспециализированное интеграционное тестирование – классическое интеграционное тестирование, которое затрагивает только ту часть кода модуля, которая взаимодействует с внешними компонентами и зависимостями, подмененными дубликатами (моками).
расширенное интеграционное тестирование – тестирование, которое затрагивает большое количество полноценных реализаций модулей и тестирует код всей системы, а не только интеграционные его части (примерно то, к чему мы пришли на схеме 2).
Узконаправленное интеграционное тестирование довольно близко к модульному тестированию. Остановимся подробнее на расширенном интеграционном тестировании и его применении в мобильной разработке.
То, какие части включать в интеграционное тестирование может варьироваться от проекта к проекту и от команды к команде (ограничиться только Domain-объектами или Data-моделями, предоставлять моки в виде объектов, строк или файлов и т.п.). На своих проектах я остановился на использовании подготовленных json объектов. Поддерживать валидность этих самых json’ов тоже непростая и интересная задача, которая заслуживает отдельной статьи. Один из возможных вариантов решения – контрактные тесты.
Привожу упрощенный пример из того самого предложения про погоду из предыдущих статей. Примеры все также можно найти на GitHub.
Упрощенная реализация Api класса, который считывает api ответы из файлов.
class FileBasedApiClient : OpenMeteoApi {
private val gson = Gson()
private var filePath: String? = null
fun setupFilePath(path: String) {
filePath = path
}
override suspend fun getWeatherForecast(
lat: Double,
lng: Double,
timezone: String,
data: List<String>,
): Result<ForecastApiResponse> {
filePath?.let {
val response: ForecastApiResponse = gson.fromJson(
FileReader(filePath),
ForecastApiResponse::class.java
)
return Result.success(response)
}
throw IllegalStateException("FileBasedApiClient is not setup")
}
}
Тест:
@Test
fun `test success forecast`() = testScope.runTest {
// Arrange
openMeteoApi.setupFilePath(TestConst.simpleForecastResponse)
var uiState: ForecastUiState? = null
// Act
val observeUiStateJob = launch(UnconfinedTestDispatcher(testScheduler)) {
delegate.uiState.collect {
uiState = it
}
}
delegate.fetchData(TestConst.testLocation1.id)
advanceUntilIdle()
// Assert
Assertions.assertThat(uiState).isNotNull
Assertions.assertThat(uiState).isInstanceOf(ForecastSuccessState::class.java)
with(uiState as ForecastSuccessState) {
Assertions.assertThat(locationName).isEqualTo(TestConst.testLocation1.name)
Assertions.assertThat(currentTemperature).isInstanceOf(StringWrapper.StringResourceWrapper::class.java)
val temp = currentTemperature as StringWrapper.StringResourceWrapper
Assertions.assertThat(temp.args).isEqualTo(listOf(14, "°C"))
}
observeUiStateJob.cancel()
}
При желании можно написать намного более fancy реализации фреймворка для настройки теста и окружения, например сделать библиотеку, которая будет по параметрам запроса выдавать подготовленный ответ, написать все на KMM и переиспользовать в iOS проекте, добавить возможность тестирования с разными конфигурациями feature toggle, написать переиспользуемые page object для экранов и много чего еще. Правда следует учитывать, что это тоже код, который тоже может быть источником ошибок :)
Несмотря на описанные преимущества, данный вид тестирования имеет ряд недостатков:
Сложность реализации. Настройка окружения может быть довольно непростой и порог входа для написания таких тестов значительно выше чем для модульных тестов;
Ограниченные рамки тестирования. Хоть они и покрывают большую часть приложения, все есть части которые приходится убирать из скоупа;
Невозможность использования подхода TDD;
Сложность отладки в случае регрессии.
Как видите нет серебряной пули, и при выработке тестовой стратегии лучше использовать комбинацию всех описанных видов тестирования. Должна ли тестовая пирамида быть именно пирамидой? Все сугубо индивидуально, очень сильно зависит от проекта, стадии его развития и переиспользования его компонентов другими приложеняими и проектами.
Rusrst
UI тесты не просто медленные, они БЕЗУМНО медленные. Ну их...
mock webserver вроде ж и с unit тестами работает, если есть желание тестировать состояния
TheSecond Автор
Да, как указывал в статье, тоже предпочитаю интеграционные тесты UI тестам.
По поводу мокирования сервера - есть много способов. Мы искали наиболее универсальный подход, который можно было бы переиспользовать для других платформ - например iOS (KMM в этом очень сильно помог) и к тому же который было бы довольно просто обновлять и следить за тем, чтобы он соответствовал поведению настоящего сервера, и без больших дополнительных затрат позволял прогонять тесты при изменении сервера. Как упомянул - этого можно достичь с помощью контрактных тестов. О которых также планирую написать статью