Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Привет, меня зовут Николай. Моя должность в Delivery Club — QA Automation Engineer в мобильной платформенной команде. Эта статья будет о том, как мы подменяем и храним ответы бэкенда при UI-автоматизации тестирования курьерского Android-приложения.
Возможно, вам нужен труднодостижимый ответ сервера в тесте, или ваши тестовые окружения часто бывают нестабильны, или вы зависите от сторонней системы? И если пока что вы не можете повлиять на это, то у вас может возникнуть желание написать функциональный тест с подмененным бэкендом. Проекты, с которыми я сталкивался, зачастую просто использовали JSON-файлы в качестве ответов, но у этого подхода есть свои недостатки, и мы рассмотрим их. Также предложу несколько альтернативных способов хранения ответов бэкенда, опишу их достоинства и недостатки. Общие моменты, связанные с написанием UI-тестов, мы почти не будем рассматривать.
Немного теории
Известные способы подмены бэкенда в Android-тестах
Подключаем необходимые зависимости
Стабы из JSON-ресурсов
Стабы из Raw Strings
Стабы из моделей
Стабы из Key Value
Стабы в произвольном стиле
Сравнение способов хранения стабов
Выводы
Немного теории
Разные источники приводят разные варианты классификации тестовых дублеров. Для этой статьи подойдет самое простое деление на стабы и моки.
Test doubles (тестовые дублеры) — фиктивные объекты, заменяющие реальные в тестируемой системе. Тестовый дублер может выступать сразу в нескольких ролях.
Stub (стаб) — ничего не проверяющая и зачастую упрощенная замена какого-либо объекта.
Mock (мок) — замена объекта, способная отслеживать собственное состояние, в частности, количество обращений к ней, параметры вызова и прочее.
В коде я решил остановиться на термине Mock, чтобы не усложнять восприятие более важных деталей при работе с MockWebServer (об инструменте расскажу далее). При этом будем иметь в виду, что на самом деле мы будем создавать Stub для наших автотестов.
Инструментальные тесты — автотесты, которые выполняются на эмуляторе или на устройстве. Считается, что в инструментальных тестах использовать тестовые дублеры следует не для всего, при этом сетевую составляющую можно подменить без существенного ухудшения качества тестирования. Подчеркну, что автотесты c использованием тестовых дублеров не являются полноценной заменой e2e-тестированию.
Известные способы подмены бэкенда в Android-тестах
Мне известны следующие способы создания сетевых заглушек в Android-тестах:
Варианты для MockWebServer, которые далее мы разберем подробнее:
Хранение JSON в Assets.
Хранение JSON в Kotlin-классах как Raw strings.
Хранение тела ответа в Map из Key Value, сериализуемой в JSON.
Хранение тела ответа в DTO модели, сериализуемой в JSON.
Локальный стаб-сервер, который может работать в режиме Record and Playback (например, WireMock).
Внешний стаб-сервер в инфраструктуре компании. При необходимости можно использовать кодогенерацию из Swagger (Open API). Стаб-сервер может выступать как прокси, в котором часть ответов на запрос подменяется, а часть приходит с реального бэкенда.
Моки интерфейсов HTTP-клиента (например, Retrofit). Это продвинутый уровень и зачастую может потребоваться участие разработчика.
Прочие библиотеки, работающие схоже с MockWebServer и WireMock.
Подключаем необходимые зависимости
Зависимости от библиотек
Сетевым клиентом в нашем примере будет Retrofit, а работать с JSON будем посредством kotlinx-serialization. В качестве замены сервера будет использован MockWebServer от создателей OkHttp/Retrofit. Эта библиотека имеет ряд возможностей, среди которых нам понадобится подмена ответов. Для удобного тестирования пользовательского интерфейса будем использовать Kaspresso.
Первым делом добавим библиотеку в build.gradle и скорректируем AndroidManifest, чтобы получить возможность отправлять запросы по незащищенному HTTP.
build.gradle
dependencies {
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:x.x.x'
}
network_security_config.xml для debug сборки
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config
cleartextTrafficPermitted="true"
tools:ignore="InsecureBaseConfiguration"
>
</base-config>
</network-security-config>
AndroidManifest.xml
<application
android:networkSecurityConfig="@xml/network_security_config"
</application>
DI-составляющая
DI-реализация сильно зависит от конкретного проекта. Она позволит подменять реальное сетевое взаимодействие фиктивным для нужд тестирования. Также для решения задачи помимо основного Application нужно будет создать класс Test Application. Ниже привожу упрощенный вариант, на который вы можете опираться при внедрении. Полный вариант можно изучить в репозитории.
Создаем разные реализации интерфейса baseUrlProvider:
Retrofit.Builder().baseUrl(baseUrlProvider.getUrl())
где для Application адрес будет реальным:
override fun getUrl() = "https://api.github.com"
а для Test Application адрес будет из локальной сети:
override fun getUrl() = "http:/localhost:8080"
Порт в нашем случае можно указать явно, потому что тест использует окружение с изолированной сетью.
Далее создаем наследника AndroidJUnitRunner и указываем в нем недавно созданный Test Application. А на наследника AndroidJUnitRunner в свою очередь ссылаемся в build.gradle:
@Suppress("unused")
class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?
): Application {
return super.newApplication(cl, TestApplication::class.java.name, context)
}
}
build.gradle
android {
defaultConfig {
testInstrumentationRunner "com.primechord.stubwebserver.application.CustomTestRunner"
}
}
Стабы из JSON-ресурсов
Мы будем использовать пример ответа endpoint’а, который содержит много полей, и сопоставим с endpoint'ами из реальных production-приложений. Например, он может быть таким: https://api.github.com/users/primechord/repos. Многочисленность полей поможет подсветить достоинства и недостатки рассматриваемых подходов.
Утилитный код
Dispatcher — механизм MockWebServer, позволяющий создать правила обработки запросов.
Создадим свой Dispatcher. Если будет соответствие ключу из Map, то мы будем отдавать фиктивный ответ. В MockWebServer ответ выражается через класс MockResponse. Если MockWebServer увидит запрос для Path, которого нет среди ключей, то будем отдавать код 404. В текущей реализации можно использовать ключи, которые соответствуют окончанию endpoint'а (endsWith).
fun createDispatcher(
mockResponsesMap: Map<String, MockResponse?>
) = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
mockResponsesMap.forEach {
if (request.path?.endsWith(it.key) == true) {
return mockResponsesMap[it.key] ?: failNotFoundResponseMock()
}
}
return failNotFoundResponseMock()
}
}
Привожу пример расширяемого набора стабов, который будет использован по умолчанию, чтобы начать тест с нужного экрана приложения без сетевых ошибок в значимых endpoint'ах.
class MockUtils {
companion object {
const val SOME_REQUEST_PATH = "/api/v1/hello/world"
const val SOME_RESPONSE_FILE = "hello/world_pretty.json"
}
val baseRequestResponseMap: HashMap<String, MockResponse?> = hashMapOf(
SOME_REQUEST_PATH to successResponse(SOME_RESPONSE_FILE),
)
}
Также прикладываю вспомогательные функции для установки тела ответа:
fun failNotFoundResponseMock(): MockResponse =
MockResponse()
.setResponseCode(HttpURLConnection.HTTP_NOT_FOUND)
fun successResponse(fileName: String): MockResponse =
MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(jsonUtil.readJSONFromAsset(fileName))
fun successResponse(body: () -> String): MockResponse =
MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(body.invoke())
Ну и, конечно же, привожу базовый класс тестов:
abstract class BaseTest(
protected val server: MockWebServer = MockWebServer(),
builder: Kaspresso.Builder = Kaspresso.Builder.simple {
beforeEachTest {
server.start(8080)
}
afterEachTest {
server.shutdown()
}
},
) : TestCase(builder)
Как выглядят тесты и их стабы
Assets без обертки
Приведенный ниже вариант с анонимным классом прост, хотя вы можете написать сколь угодно сложное условие в блоке when:
@Test
fun pureDispatcher() {
server.dispatcher = object : Dispatcher() {
val mockUtils = MockUtils()
override fun dispatch(request: RecordedRequest): MockResponse {
return when {
request.path?.contains("/repos") == true -> {
mockUtils.successResponse {
mockUtils.jsonUtil.readJSONFromAsset("repos.json")
}
}
else -> mockUtils.failNotFoundResponseMock()
}
}
}
// kaspresso code
}
А вот более лаконичный вариант с использованием утилитного кода, который можно использовать повторно в других тестах класса:
private fun MockWebServer.mockRepos(reposResponseAsset: String) {
val mockUtils = MockUtils()
val requestResponseMap = mockUtils.baseRequestResponseMap
requestResponseMap["/repos"] = mockUtils.successResponse(reposResponseAsset)
dispatcher = mockUtils.createDispatcher(requestResponseMap)
}
@Test
fun withExtension() {
server.mockRepos("repos.json")
// kaspresso code
}
Обертка для уровня тестов
Теперь добавим функцию расширения MockWebServer, которую далее будем использовать на уровне теста:
fun MockWebServer.mockResponses(vararg pairs: Pair<String, MockContainer>) {
val mockUtils = MockUtils()
val requestResponseMap = mockUtils.baseRequestResponseMap
for (pair in pairs) {
requestResponseMap[pair.first] = pair.second.getResponse()
}
dispatcher = mockUtils.createDispatcher(requestResponseMap)
}
sealed interface MockContainer {
fun getResponse(): MockResponse
}
class AnyContainer(private val mockResponse: MockResponse) : MockContainer {
override fun getResponse() = mockResponse
}
class AssetContainer(private val fileName: String) : MockContainer {
override fun getResponse() = mockUtils.successResponse(fileName)
}
class RawStringContainer(private val body: String) : MockContainer {
override fun getResponse() = mockUtils.successResponse { body }
}
class ModelContainerClass<T>(private val model: T, private val serializer: KSerializer<T>) : MockContainer {
override fun getResponse() = mockUtils.successResponse { json.encodeToString(serializer, model) }
}
class KeyValueContainer(private val params: Any) : MockContainer {
override fun getResponse() = mockUtils.successResponse { params.toJsonElement().toString() }
}
@Suppress("TestFunctionName")
inline fun <reified T> ModelContainer(model: T): ModelContainerClass<T> =
ModelContainerClass(model, Json.serializersModule.serializer())
Примечательно, что повторный вызов mockResponses снова установит Dispatcher, а это иногда может пригодиться при тестировании разных состояний в тесте. Вам нужно будет лишь указать измененный стаб при повторном вызове.
Виды контейнеров, которые будут использоваться в обертке
ModelContainer — для модели, сериализуемой в строку тела ответа.
KeyValueContainer — для Key-Value-объекта, сериализуемого в строку тела ответа.
RawStringContainer — для тела ответа в виде уже готовой строки.
AssetContainer — для тела ответа, которое хранится в Assets.
AnyContainer — контейнер, который позволит создать произвольный MockResponse. Может быть полезен при тестировании негативных сценариев.
Assets уже с оберткой
Еще более лаконичный вариант в сравнении с предыдущими:
@Test
fun withAsset() {
server.mockResponses(
"/repos" to AssetContainer("repos.json"),
)
// kaspresso code
}
Недостатки:
Создание дополнительных тестовых случаев будет происходить с помощью полного копирования файла ресурса.
При Code Review JSON-файлы нередко игнорируются.
Проблематично написать документирующий комментарий.
Стабы из Raw Strings
Вы также можете хранить JSON в виде строки, которая обрамляется тройными кавычками. Например, это может выглядеть так: """{ }""". Далее такая строка сериализуется в JSON.
В этом варианте вам будут доступны возможность языка String templates и возможность среды разработки Language injections.
В тестах нашего курьерского приложения способ не был опробован, но есть рабочий пример в приложенном репозитории и, пожалуй, я приведу его для полноты картины.
Как выглядят тесты и их стабы
Достоинства:
Есть проверка синтаксиса Raw strings посредством IDE Language injections.
Доступны фича String templates, которая поможет работать с датами, а также «старая добрая» String concatenation.
Можно оставлять комментарии.
Недостатки:
IDE может медленно работать с файлами, которые подлежат компиляции и состоят из тысяч строк кода.
Стабы из моделей
Как выглядят тесты и их стабы
Рассмотрим упрощенный пример модели из приложения, которая будет использоваться в тестах:
@Serializable
data class RepoItem(
val owner: Owner,
@SerialName("private")
val privateField: Boolean,
// …
)
Далее будут приведены примеры использования моделей как из приложения, так и сгенерированных для тестов. Существенное различие в том, что модели для тестов будут со значениями по умолчанию. Плагин для генерации — json-to-kotlin-class. Доступность файлов между модулями регулируется на уровне Gradle.
Если вы используете модели из приложения, то не дублируете аналогичный код в тестах. Если вы используете сгенерированные для тестов модели, то меньше завязываетесь на production-код в тестах. Опираясь на перечисленные ниже достоинства и недостатки, выбирайте предпочтительный для вас способ работы с моделями.
С моделями из приложения
@Test
fun withProdModels() {
server.mockResponses(
"/repos" to ModelContainer(
createReposItemsFromProductionModels("defaultLogin")
),
)
// kaspresso code
}
private fun createReposItemsFromProductionModels(
ownerLogin: String
): List<ProdRepoItem> {
return listOf(
ProdRepoItem(
owner = ProdOwner(
login = ownerLogin,
avatar_url = "",
// …
),
// …
)
)
}
Со сгенерированными моделями
@Test
fun withGenModels() {
server.mockResponses( // намерение создать произвольное количество стабов
"/repos" to ModelContainer( // создается пара из Path и тела ответа
listOf( // массив объектов в теле ответа
GenRepoItem( // первый объект в теле ответа
owner = GenOwner( // вложенный в него дочерний объект
// указываем значение, отличающееся от значения по умолчанию
// для того, чтобы в дальнейшем его протестировать
login = "expected text"
)
)
)
)
)
// kaspresso code
}
Достоинства:
Модель проверяется компилятором. Вам также не нужно донастраивать JSON как строку.
Data class содержит значения по умолчанию. Раскрытие значимых данных теста в месте использования без необходимости указания значения каждого поля ответа. Такой код уже сложнее проигнорировать при Code Review.
Появляется решение для ситуаций, когда вам нужны динамические данные в полях, например, даты. Гибкость в заполнении полей позволяет удобнее тестировать негативные сценарии.
Стабы из объектов удобно документировать, и они сами могут выступать своего рода документацией.
Недостатки (но не для всех):
Если необходимая для стаба модель находится не в common-модуле и пока не планируется делать ее общей, то вам придется скопировать ее в модуль с тестами. Актуально для многомодульных проектов. Если модель отягощена избыточными для тестов подробностями, то вы также можете легко генерировать Data class при помощи IDE-плагина. Останется разово указать все значения по умолчанию и внести мелкие правки для типов данных, которые плагин не всегда удачно выводит, что свойственно инструментам кодогенерации.
Неподготовленному человеку проще взять JSON в Charles/Fiddler, положить в Assets и не думать о проблемах долгосрочной поддержки.
Ответы с большой вложенностью будут иметь много круглых скобок и, соответственно, сдвигаться линтером вправо, что в некоторых случаях может затруднять чтение кода.
Стабы из Key Value
Вы также можете хранить JSON в структуре, которая будет состоять из Collection, Map и JsonPrimitive, и далее сериализовать ее в JSON.
В тестах нашего курьерского приложения способ не был опробован, но есть рабочий пример в приложенном репозитории и, пожалуй, я также упомяну его для полноты картины.
С JsonElement из kotlinx.serialization можно работать по-разному. Я остановился на следующем варианте.
Как выглядят тесты и их стабы
@Test
fun withKeyValue() {
server.mockResponses(
"/repos" to KeyValueContainer(
createResponseBodyForRepos(login = "expected text")
)
)
// kaspresso code
}
private fun createResponseBodyForRepos(login: String): List<Map<String, Any>> {
return listOf(
mapOf(
"allow_forking" to false,
"archive_url" to "",
// …
)
)
}
Достоинства:
Можно повторно использовать написанные объекты.
Можно работать с датами.
Можно оставлять комментарии.
Написание стабов в предложенном стиле напоминает DSL.
Недостатки:
Времязатратно создавать и отлаживать.
Стабы в произвольном стиле
Также предлагаю рассмотреть способ создания стабов, позволяющий делать произвольные MockResponse. Это пригодится для тестирования негативных сценариев, в которых вам может понадобиться больше гибкости. Способ останется вне итогового сравнения.
@Test
fun withError() {
server.mockResponses(
"/repos" to AnyContainer(
MockResponse()
.setResponseCode(500)
)
)
// kaspresso code
}
Сравнение способов хранения стабов
Сравниваем подходы в соответствии с описанными выше достоинствами и недостатками:
Ниже я сравнил подходы по скорости, изолированности и простоте настройки для теста. Данные для сравнения не собирались, в таблице отражено мнение автора.
Предлагаемая обертка была применена для курьерского приложения Delivery Club. Она сохраняет гибкость и позволяют задавать стабы как объекты, файлы ресурсов, raw strings и произвольные MockResponse. По моему мнению, использование моделей для стабов является предпочтительным и наиболее выразительным вариантом, но, в зависимости от ситуации, вы можете выбрать и другие способы.
Желающие могут ознакомиться с репозиторием, в котором есть простое одноэкранное приложение и тесты для него, демонстрирующие вышеперечисленные подходы. Благодарю за внимание.
Полезные материалы:
Как начать писать автотесты и не сойти с ума
Быстрый старт: гайд по автоматизированному тестированию для Android-разработчика. JVM
Мок-сервер для автоматизации мобильного тестирования
https://github.com/JorgeCastilloPrz/hiroaki
https://github.com/andrzejchm/RESTMock