Мок-сервер для автоматизации мобильного тестирования

    Работая над последним проектом, столкнулся с тестированием мобильного приложения, связанного на уровне бизнес-логики с различными сторонними сервисами. Тестирование этих сервисов не входило в мою задачу, однако проблемы с их 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.
    Maxilect
    79,00
    Умные решения для вашего бизнеса
    Поделиться публикацией

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

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое