Работая над последним проектом, столкнулся с тестированием мобильного приложения, связанного на уровне бизнес-логики с различными сторонними сервисами. Тестирование этих сервисов не входило в мою задачу, однако проблемы с их API блокировали работу по самому приложению – тесты падали не из-за проблем внутри, а из-за неработоспособности API, даже не доходя до проверки нужной функциональности.
Традиционно для тестирования таких приложений используются стенды. Но они не всегда работают нормально, и это мешает работе. В качестве альтернативного решения я использовал моки. Об этом тернистом пути и хочу рассказать сегодня.
Чтобы не трогать код реального проекта (под NDA), для наглядности дальнейшего изложения я создал простой REST-клиент под Android, позволяющий отправлять на некий адрес HTTP-запросы (GET/POST) с необходимыми мне параметрами. Его-то мы и будем тестировать.
Код приложения-клиента, диспатчеров и тестов можно скачать с GitLab.
Какие существуют варианты?
Подходов к мокированию в моем случае существовало два:
- развернуть мок-сервер в облаке или на удаленной машине (если речь идет о конфиденциальных разработках, которые нельзя выносить в облако);
- запускать мок-сервер локально – прямо на телефоне, на котором тестируется мобильное приложение.
Первый вариант несильно отличается от тестового стенда. Действительно, можно выделить под мок-сервер рабочее место в сети, но его необходимо будет поддерживать, как и любой тестовый стенд. Вот тут-то и придется столкнуться с основными подводными камнями этого подхода. Удаленное рабочее место умерло, перестало отвечать, что-то поменялось – надо следить, менять конфигурацию, т.е. делать все то же самое, что и при поддержке обычного тестового стенда. Ситуацию для себя мы никак не исправляем, и на это точно уйдет больше времени, чем на любые локальные манипуляции. Так что конкретно в моем проекте было удобнее поднимать мок-сервер локально.
Выбор мок-сервера
Разных инструментов существует много. Я пытался работать с несколькими и почти в каждом столкнулся с определенными проблемами:
- Mock-server, wiremock – два мок-сервера, которые я так и не смог нормально запустить на Android. Поскольку все эксперименты происходили в рамках живого проекта, время на выбор было ограничено. Поковырявшись с ними пару дней, я оставил попытки.
- Restmock – это обертка над okhttpmockwebserver, подробнее о котором речь пойдет далее. Выглядела она неплохо, запустилась, но разработчик этой обертки спрятал “под капотом” возможность задания IP-адреса и порта мок-сервера, а для меня это было критично. Restmock стартовал на каком-то случайном порту. Ковыряясь в коде, я увидел, что при инициализации сервера разработчик использовал метод, который задавал порт случайным образом, если не получал его на вход. В принципе, можно было наследоваться от этого метода, но проблема была в приватном конструкторе. В итоге от обертки я отказался.
- Okhttpmockwebserver – попробовав разные инструменты, я остановился на мок-сервере, который нормально собрался и запустился локально на устройстве.
Разбираем принцип работы
Текущая версия okhttpmockwebserver позволяет реализовать несколько сценариев работы:
- Очередь ответов. Ответы мок-сервера складываются в очередь, работающую по принципу FIFO. Неважно, к какому API и по какому пути я буду обращаться, мок-сервер по очереди будет выкидывать сообщения, заложенные в эту очередь.
- Диспатчер позволяет создать правила, определяющие, какой ответ отдавать. Допустим, запрос пришел по URL, содержащему некий путь, например /get-login/. По этому /get-login/ мок-сервер и отдает единичный, заранее заданный ответ.
- Request Verifier. Опираясь на предыдущий сценарий, я могу проверять запросы, которые отправляет приложение (что в заданных условиях запрос с определенными параметрами действительно уходит). При этом ответ неважен, поскольку он определяется тем, как работает API. Этот сценарий и реализует Request verifier.
Рассмотрим каждый из сценариев подробнее.
Очередь ответов
Простейшая реализация мок-сервера – очередь ответов. До теста я определяю адрес и порт, где будет развернут мок-сервер, а также тот факт, что он будет работать по принципу очереди из сообщений – FIFO (first in first out).
Далее запускаю мок-сервер.
class QueueTest: BaseTest() {
@Rule
@JvmField
var mActivityRule: ActivityTestRule<MainActivity> = ActivityTestRule(MainActivity::class.java)
@Before
fun initMockServer() {
val mockServer = MockWebServer()
val ip = InetAddress.getByName("127.0.0.1")
val port = 8080
mockServer.enqueue(MockResponse().setBody("1st message"))
mockServer.enqueue(MockResponse().setBody("2nd message"))
mockServer.enqueue(MockResponse().setBody("3rd message"))
mockServer.start(ip, port)
}
@Test
fun queueTest() {
sendGetRequest("http://localhost:8080/getMessage")
assertResponseMessage("1st message")
returnFromResponseActivity()
sendPostRequest("http://localhost:8080/getMessage")
assertResponseMessage("2nd message")
returnFromResponseActivity()
sendGetRequest("http://localhost:8080/getMessage")
assertResponseMessage("3rd message")
returnFromResponseActivity()
}
}
Тесты написаны с помощью фреймворка Espresso, предназначенного для исполнения действий в мобильных приложениях. В этом примере я выбираю типы запросов и отправляю их по очереди.
После запуска теста мок-сервер дает ему ответы в соответствии с прописанной очередью, и тест проходит без ошибок.
Реализация диспатчера
Диспатчер – это набор правил, по которым работает мок-сервер. Для удобства изложения я создал три разных диспатчера: SimpleDispatcher, OtherParamsDispatcher и ListingDispatcher.
SimpleDispatcher
Для реализации диспатчера okhttpmockwebserver предоставляет класс Dispatcher()
. От него можно наследоваться, переопределив функцию dispatch
по-своему.
class SimpleDispatcher: Dispatcher() {
@Override
override fun dispatch(request: RecordedRequest): MockResponse {
if (request.method == "GET"){
return MockResponse().setResponseCode(200).setBody("""{ "message": "It was a GET request" }""")
} else if (request.method == "POST") {
return MockResponse().setResponseCode(200).setBody("""{ "message": "It was a POST request" }""")
}
return MockResponse().setResponseCode(200)
}
}
Логика в этом примере простая: если приходит GET, я возвращаю сообщение, что это GET request. Если POST, возвращаю сообщение о POST request. В иных ситуациях возвращаю пустой запрос.
В тесте появляется dispatcher
– объект класса SimpleDispatcher
, который я описал выше. Далее, как и в предыдущем примере, запускается мок-сервер, только на этот раз указывается своего рода правило работы с этим мок-сервером – тот самый диспатчер.
Исходники тестов с SimpleDispatcher
можно найти в репозитории.
OtherParamsDispatcher
Переопределяя функцию dispatch
, я могу оттолкнуться от других параметров запроса для отправки ответов:
class OtherParamsDispatcher: Dispatcher() {
@Override
override fun dispatch(request: RecordedRequest): MockResponse {
return when {
request.path.contains("?queryKey=value") -> MockResponse().setResponseCode(200).setBody("""{ "message": "It was a GET request with query parameter queryKey equals value" }""")
request.body.toString().contains(""bodyKey":"value"") -> MockResponse().setResponseCode(200).setBody("""{ "message": "It was a POST request with body parameter bodyKey equals value" }""")
request.headers.toString().contains("header: value") -> MockResponse().setResponseCode(200).setBody("""{ "message": "It was some request with header equals value" }""")
else -> MockResponse().setResponseCode(200).setBody("""{ Wrong response }""")
}
}
}
В данном случае я демонстрирую несколько вариантов условий.
Во-первых, в API можно передавать параметры в адресной строке. Поэтому я могу поставить условие на вхождение в path какой-либо связки, например “?queryKey=value”
.
Во-вторых, данный класс позволяет залезть внутрь тела (body) запросов POST или PUT. Например, можно использовать contains
, предварительно выполнив toString()
. В моем примере условие срабатывает, когда приходит POST-запрос, содержащий “bodyKey”:”value”
. Аналогично я могу валидировать header запроса (header : value
).
За примерами тестов рекомендую обратиться к репозиторию.
ListingDispatcher
При необходимости можно реализовать и более сложную логику – ListingDispatcher. Тем же способом я переопределяю функцию dispatch
. Однако теперь прямо в классе задаю дефолтный набор стабов (stubsList
) – моков на разные случаи жизни.
class ListingDispatcher: Dispatcher() {
private var stubsList: ArrayList<RequestClass> = defaultRequests()
@Override
override fun dispatch(request: RecordedRequest): MockResponse =
try {
stubsList.first { it.matcher(request.path, request.body.toString()) }.response()
} catch (e: NoSuchElementException) {
Log.e("Unexisting request path =", request.path)
MockResponse().setResponseCode(404)
}
private fun defaultRequests(): ArrayList<RequestClass> {
val allStubs = ArrayList<RequestClass>()
allStubs.add(RequestClass("/get", "queryParam=value", "", """{ "message" : "Request url starts with /get url and contains queryParam=value" }"""))
allStubs.add(RequestClass("/post", "queryParam=value", "", """{ "message" : "Request url starts with /post url and contains queryParam=value" }"""))
allStubs.add(RequestClass("/post", "", ""bodyParam":"value"", """{ "message" : "Request url starts with /post url and body contains bodyParam:value" }"""))
return allStubs
}
fun replaceMockStub(stub: RequestClass) {
val valuesToRemove = ArrayList<RequestClass>()
stubsList.forEach {
if (it.path.contains(stub.path)&&it.query.contains(stub.query)&&it.body.contains(stub.body)) valuesToRemove.add(it)
}
stubsList.removeAll(valuesToRemove)
stubsList.add(stub)
}
fun addMockStub(stub: RequestClass) {
stubsList.add(stub)
}
}
Для этого я создал открытый класс RequestClass
, все поля которого по умолчанию пустые. Для данного класса я задаю функцию response
, которая создает объект MockResponse
(возвращающую ответ 200 или некий иной responseText
), и функцию matcher
, возвращающую true
или false
.
open class RequestClass(val path:String = "", val query: String = "", val body:String = "", val responseText: String = "") {
open fun response(code: Int = 200): MockResponse =
MockResponse()
.setResponseCode(code)
.setBody(responseText)
open fun matcher(apiCall: String, apiBody: String): Boolean = apiCall.startsWith(path)&&apiCall.contains(query)&&apiBody.contains(body)
}
В результате я могу строить более сложные сочетания условий для стабов. Мне эта конструкция показалась более гибкой, хотя принцип в ее основе очень простой.
Но более всего в этом подходе мне понравилось, что я могу подставлять какие-то стабы прямо на ходу, если появилась необходимость что-то поменять в ответе мок-сервера на одном тесте. При тестировании больших проектов такая задача возникает довольно часто, например при проверке каких-то специфических сценариев.
Замену можно осуществить следующим образом:
fun replaceMockStub(stub: RequestClass) {
val valuesToRemove = ArrayList<RequestClass>()
stubsList.forEach {
if (it.path.contains(stub.path)&&it.query.contains(stub.query)&&it.body.contains(stub.body)) valuesToRemove.add(it)
}
stubsList.removeAll(valuesToRemove)
stubsList.add(stub)
}
При такой реализации диспатчера тесты остаются простыми. Я также стартую мок-сервер, только выбираю ListingDispatcher
.
class ListingDispatcherTest: BaseTest() {
@Rule
@JvmField
var mActivityRule: ActivityTestRule<MainActivity> = ActivityTestRule(MainActivity::class.java)
private val dispatcher = ListingDispatcher()
@Before
fun initMockServer() {
val mockServer = MockWebServer()
val ip = InetAddress.getByName("127.0.0.1")
val port = 8080
mockServer.setDispatcher(dispatcher)
mockServer.start(ip, port)
}
.
.
.
}
Ради эксперимента я заменил стаб на POST:
@Test
fun postReplacedStubTest() {
val params: HashMap<String, String> = hashMapOf("bodyParam" to "value")
replacePostStub()
sendPostRequest("http://localhost:8080/post", params = params)
assertResponseMessage("""{ "message" : "Post request stub has been replaced" }""")
}
Для этого вызвал функцию replacePostStub
от обычного dispatcher
и добавил новый response
.
private fun replacePostStub() {
dispatcher.replaceMockStub(RequestClass("/post", "", ""bodyParam":"value"", """{ "message" : "Post request stub has been replaced" }"""))
}
В тесте выше я проверяю, что стаб был заменен.
Затем я добавил новый стаб, которого не было в дефолтных.
@Test
fun getNewStubTest() {
addSomeStub()
sendGetRequest("http://localhost:8080/some_specific_url")
assertResponseMessage("""{ "message" : "U have got specific message" }""")
}
private fun addSomeStub() {
dispatcher.addMockStub(RequestClass("/some_specific_url", "", "", """{ "message" : "U have got specific message" }"""))
}
Request Verifier
Последний кейс – Request verifier – обеспечивает не мокирование, а проверку отправляемых приложением запросов. Для этого я точно так же стартую мок-сервер, реализовав диспатчер, чтобы приложение возвращало хоть что-то.
При отправке запроса из теста тот приходит в мок-сервер. Через него я могу получить доступ к параметрам запроса, используя takeRequest()
.
@Test
fun requestVerifierTest() {
val params: HashMap<String, String> = hashMapOf("bodyKey" to "value")
val headers: HashMap<String, String> = hashMapOf("header" to "value")
sendPostRequest("http://localhost:8080/post", headers = headers, params = params)
val request = mockServer.takeRequest()
assertEquals("POST", request.method)
assertEquals("value", request.getHeader("header"))
assertTrue(request.body.toString().contains(""bodyKey":"value""))
assertTrue(request.path.startsWith("/post"))
}
Выше я показал проверку на простом примере. Точно такой же подход можно использовать для сложных JSON, в том числе для проверки всей структуры запроса (можно сравнивать на уровне JSON или распарсить JSON на объекты и проверить равенство на уровне объектов).
Итоги
В целом инструмент (okhttpmockwebserver) мне понравился, и я использую его на большом проекте. Безусловно, есть некоторые мелочи, которые я хотел бы изменить.
Например, мне не нравится, что приходится стучаться по локальному адресу (localhost:8080 в нашем примере) в конфигах своего приложения; возможно, я еще найду способ все настроить так, чтобы мок-сервер отвечал при попытке отправить запрос на любой адрес.
Также мне не хватает возможности переадресации запросов – когда мок-сервер отправляет запрос дальше, если у него нет для него подходящего стаба. В данном мок-сервере такого подхода нет. Впрочем, до их внедрения и не дошло, поскольку на данный момент в “боевом” проекте не стоит такой задачи.
Автор статьи: Руслан Абдулин
P.S. Мы публикуем наши статьи на нескольких площадках Рунета. Подписывайтесь на наши страницы в VK, FB или Telegram-канал, чтобы узнавать обо всех наших публикациях и других новостях компании Maxilect.
Автор: Maxilect_pr