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

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

image

Чтобы не трогать код реального проекта (под 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.

Комментарии (0)