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

Эволюция тестового окружения
За время работы автоматизатором в разных компаниях я наблюдаю типичную эволюцию развития тестового окружения. Практически всегда она начинается с одного девелоперского стенда, дальше появляется стенд для тестирования релизных сборок, потом возникает запрос на увеличение количества тестовых серверов для разных смежных команд, у них появляются свои и так далее. И через какое-то время со всей этой сетью становится довольно сложно работать.
В какой-то момент выясняется, что тестовый стенд смежной команды очень сильно влияет на стабильность ваших тестовых прогонов и возникает желание максимально изолироваться от влияния сторонних сервисов. Более того, для автоматизации некоторых тестов бывает необходимо подготовить соответствующие ответы этих сервисов, а это не всегда осуществимо. Хороший пример, когда нужно проверить, как приложение реагирует на 500 ошибку внешнего сервиса, но ты не имеешь возможности заставить сторонний сервер вернуть тебе такую. И в итоге автоматизаторы и разработчики используют подмену (мокирование) сторонних запросов для получения нужной изоляции и гибкости тестирования и разработки.

Что делать с существующими тестами?
Если в компании тестовое окружение развивалось по вышеописанному сценарию, то и тестовый фреймворк с большим количеством тестов уже скорее всего написан и сильно зависит от текущей тестовой инфраструктуры и ее связанности с внешними сервисами.
В тот момент, когда команда решает изолировать свою тестовую инфраструктуру, и возникает вопрос как подружить тесты с мок-сервисами. Я бы выделил два варианта в организации связи тестов и моков:
-
Когда используется какой-нибудь инструмент мокирования запросов, умеющий отвечать на запрос по какому-то специальному правилу, заданному извне. Таких инструментов уже достаточно много, и mockserver, о котором речь пойдет ниже, один из них.
-
Когда есть самописный сервис, почти полностью имитирующий внешний в нужных нам точках взаимодействия.
Оба эти варианта имеют право на жизнь, могут сочетаться в одном проекте и у обоих есть как достоинства, так и недостатки. Давайте рассмотрим их по отдельности.
Тест сам подготавливает все необходимые данные в моках перед выполнением
Для этого потребуется развернуть какой-то инструмент мокирования в тестовом окружении и перенаправить тестируемые сервисы на него.
Далее в тестовом фреймворке нужно будет создать все необходимые генераторы эмулируемых данных, написать клиента к этому сервису и расставить методы, которые создают необходимые ответы в сервисе моков, по имеющимся тестам и их шагам.
Например, нам нужно написать тест на создание пользователя, в процессе которого подгружаются данные из сторонней информационной адресной системы или микросервиса. И этот сторонний сервис нам и надо эмулировать. Итоговый результат в псевдо-коде может выглядеть так:
# Генерируем пользователя со случайными данными User = DataGenerator.generateNewUser() # Подготовка предстоящего ответа в сервисе мокирования данных # для будущего запроса тестируемой системы MockClient.userAddressHandler.setAddressExpectationOfExternalSystem(User) # Вызов процедуры создания пользователя, в процессе которой тестируемая система # подгрузит наши данные из сервиса мокирования Response = ServiceClient.createUser(User) Assert Response.message == "User created"
Данный вариант позволит описать в каждом тесте всю логику поведения не только тестируемой системы, но и сторонних внешних сервисов, так как мы явно видим и задаем данные, которые эти сервисы должны возвращать. Это улучшает наглядность теста и добавляет гибкости создания предусловий. Но есть и минусы. Для того, чтобы эмулировать что-то посложнее, чем пример выше, нужно будет предварительно создавать ожидания сразу в нескольких сервисах и правильно синхронизировать данные между ними. Это добавляет сложности в написании тестовых предусловий и приводит к разрастанию самого теста. Например:
User = DataGenerator.generateNewUser() # Подготовка предстоящих ответов в сервисе мокирования данных # для будущих запросов тестируемой системы MockClient.userAddressHandler.setAddressExpectation(User.id) MockClient.userPaymentsHandler.setPaymentsExpectation(User.id) MockClient.userBankDataHandler.setBankScoresExpectation(User.id, User.account) MockClient.userCryptoWalletHandler.setRandomCryptoWalletExpectation(User.id) Response = ServiceClient.createUserWithPaymentsHistory(User) Assert Response.message == "User created"
Еще один минус заключается в том, что если соседняя команда тоже хотела бы использовать ваши наработки по мокированию сервисов, но использует другой язык разработки, то она не сможет легко и просто подключить вашу библиотеку для работы с генератором ожиданий и придется изобретать что-то свое.
«Умные» моки. Эмулируем заменяемый сервис в нужных нам точках
В этом случае нам надо повторить логику эмулируемой системы во вспомогательном, зачастую самописном, сервисе. Это потребует дополнительных затрат времени разработчиков по созданию такого сервиса и дальнейшей поддержке, но при этом никаких особых изменений в тестовом фреймворке не потребуется. Плюс еще и в том, что подобный сервис мокирования может быть использован смежными командами.
Неудобство в том, что для добавления какой-то особой логики, необходимой тестам, придется делать изменения в самом коде сервиса, пересобирать, разворачивать и так далее.
Сочетаем сразу два варианта
В нашей команде возникло желание избавиться от зоопарка существующих заглушек и добавить гибкости нашим тестам. Другими словами, хотелось собрать все плюсы вышеперечисленных вариантов в одном инструменте, который будет легче поддерживать команде, хорошо держит нагрузку и позволяет всем участникам легко и просто мокировать необходимые запросы. И mockserver решает все эти задачи.
Когда тестировщику нужно создать необходимый ответ на запрос (expectation), он использует удобное API mockserver’a:
curl -v -X PUT "http://localhost:1080/mockserver/expectation" -d '{ "httpRequest" : { "path" : "/service/path/" }, "httpResponse" : { "body" : "response_body", } }'
В примере выше создаётся правило “верни мне ответ 200 c телом «response_body» на GET запрос по пути /service/path”. То есть в нужный момент выполнения теста, перед каким-то шагом, я могу создать такое ожидание и тестируемая система получит нужный мне ответ. Очень удобно при тестировании особых наборов данных, кодов ошибок и так далее, то есть всего того, что сложно или невозможно эмулировать на реальном сервисе.
Более того, у mockserver есть интересная возможность создания динамических callback-ов, которые позволяют выполнить необходимый нам код во время выдачи ответа на запрос. Другими словами, я могу не только сгенерировать какой-то ответ динамически, например, создав случайный идентификатор, но и осуществить запросы в базу, другой сервис и так далее, то есть выполнить любую логику. Я даже могу создать новое ожидание в mockserver! А ведь это, по сути, позволяет нам создать “умный” мок на готовой платформе, описав минимумом кода только нужную часть эмулируемой логики.
Например, у нас есть задача по созданию сервиса, эмулирующего взаимодействие с системой управления пользователями. Концепт решения может выглядеть так:
-
Каждый тест атомарен и создает нового пользователя запросом
POST /api/v1/userв систему, с передачей параметров пользователя в payload-е запроса. Вдобавок к пользователю будет создаваться account и привязываться к нему. -
Реакцией на такой запрос POST будет выполнение необходимого нам кода — обработка переданных параметров пользователя, генерация уникального ID пользователя и создание нужных ответов в mockserver для будущих запросов тестируемой системы.
-
Подготовка ожиданий (нужного ответа) на будущие GET запросы по адресам
/api/v1/users/{user_uid}/api/v1/users/{user_uid}/accounts/{account_id}
Давайте более подробно на примере разберем, как это можно сделать.
Пример реализации на Kotlin
В момент старта mockserver’а мы можем указать ему подключить собранные JAR файлы нашего проекта в свой classpath, что и позволит нам использовать наш код. Ниже пример того, как создать такой класс UserHandleExpectation, наш будущий «умный» мок, пошагово:
Добавляем зависимости в pom.xm
pom.xml
<dependency> <groupId>org.mock-server</groupId> <artifactId>mockserver-client-java-no-dependencies</artifactId> <version>RELEASE</version> </dependency> <dependency> <groupId>org.mock-server</groupId> <artifactId>mockserver-netty-no-dependencies</artifactId> <version>RELEASE</version> </dependency> <dependency> <groupId>com.google.code.gson</groupId> <artifactId>gson</artifactId> <version>2.9.0</version> </dependency>
В настройках билда проекта артефакты настраиваем так, чтобы все зависимости собирались в отдельные JAR файлы, и сам скомпилированный код классов тоже был в JAR архиве. Это важно, так как иначе вы будете получать ошибку ClassNotFoundException при попытке использования своего ожидания.
Создаем package userApi и внутри класс-файл UserHandleExpectation.kt
UserHandleExpectation.kt
package userApi import com.google.gson.Gson import org.mockserver.client.MockServerClient import userApi.dto.* import org.mockserver.matchers.TimeToLive import org.mockserver.matchers.Times import org.mockserver.mock.action.ExpectationResponseCallback import org.mockserver.model.* import org.mockserver.model.JsonBody.json import utils.getIsoCurrentDate import java.util.* import java.util.concurrent.TimeUnit import kotlin.math.absoluteValue const val TTL_SEC: Long = 600 class UserHandleExpectation : ExpectationResponseCallback { override fun handle(httpRequest: HttpRequest): HttpResponse { val gson = Gson() val mockServerClient = MockServerClient("localhost", 1080) // Convert POST payload to data structure val userPayload: UserPOSTRequestDTO? = gson.fromJson(httpRequest.bodyAsJsonOrXmlString, UserPOSTRequestDTO::class.java) // Create expectation for GET /users/{userId} val userUUID = UUID.randomUUID().toString() val userGETResponse = UserGETResponseDTO( userUid = userUUID, name = userPayload?.name, surname = userPayload?.surname, currency = userPayload?.currency, region = userPayload?.region, serverCode = userPayload?.server_code, createdDate = getIsoCurrentDate() ) mockServerClient.`when`( HttpRequest.request() .withMethod("GET") .withPath("/api/v1/users/${userUUID}"), Times.unlimited(), TimeToLive.exactly(TimeUnit.SECONDS, TTL_SEC) ).respond( HttpResponse.response() .withContentType(MediaType.APPLICATION_JSON) .withBody(json(gson.toJson(userGETResponse, UserGETResponseDTO::class.java))) ) // Create expectation for GET /users/{userId}/accounts/{accountId} val accountId = userUUID.hashCode().absoluteValue val accountGETResponse = AccountGETResponseDTO( id = accountId, userUid = userUUID, currency = userPayload?.currency, status = "ACTIVE", expired = false ) mockServerClient.`when`( HttpRequest.request() .withMethod("GET") .withPath("/api/v1/users/${userUUID}/accounts/${accountId}"), Times.unlimited(), TimeToLive.exactly(TimeUnit.SECONDS, TTL_SEC) ).respond( HttpResponse.response() .withContentType(MediaType.APPLICATION_JSON) .withBody(json(gson.toJson(accountGETResponse, AccountGETResponseDTO::class.java))) ) // Prepare response for current callback val userPOSTCallbackResponse = UserPOSTResponseDTO( userId = userUUID, name = userPayload?.name, surname = userPayload?.surname, account = accountId, serverCode = userPayload?.server_code, region = userPayload?.region ) return HttpResponse.response() .withStatusCode(HttpStatusCode.CREATED_201.code()) .withContentType(MediaType.APPLICATION_JSON) .withBody(json(gson.toJson(userPOSTCallbackResponse, UserPOSTResponseDTO::class.java))) } }
Сопутствующий код для работы примера вы найдете по ссылке smart mock example
Запуск
Собираем проект и запускаем mockserver с помощью docker следующей командой:
docker run -d --rm -v <путь до вашей папки c JAR файлами>:/libs -p 1080:1080 --name mock mockserver/mockserver -serverPort 1080
Здесь монтируемая папка /libs должна содержать все скомпилированные JAR файлы проекта.
Для того, чтобы получить точку входа, когда наш мок отреагирует, а это в нашем случае запрос POST /api/v1/users, я должен создать ожидание в mockserver. Выполняем команду:
curl -v -X PUT "http://localhost:1080/mockserver/expectation" -d '{ "httpRequest": { "path": "/api/v1/users", "method": "POST" }, "httpResponseClassCallback": { "callbackClass": "userApi.UserHandleExpectation" } }'
Перейдя на дашборд mockserver по адресу http://localhost:1080/mockserver/dashboard, мы видим следующую картину:

Посылаем POST запрос для генерации UUID пользователя и его аккаунта, а также создания ответов для будущих GET запросов:
curl -v "http://localhost:1080/api/v1/users" -d '{ "name": "John", "surname": "Doe", "currency": "USD", "region": "CA", "server_code": "USA" }'
Ответ:
{ "userId": "2182884c-89f1-4a74-b180-c73848f8d8ad", "name": "John", "surname": "Doe", "account": 1492317915, "serverCode": "USA", "region": "CA" }
И на дашборде отображаются наши новые ожидания:

Таким образом, развивая эту концепцию, можно создать «умный» мок на базе mockserver, который будет сам готовить необходимые ответы на все необходимые эндпоинты на основе ваших входных данных.
Проблемы и тюнинг настроек
После реализации такого мока мы решили протестировать его под нагрузкой, чтобы проверить, как быстро он обрабатывает динамические callback-и и создает ожидания. Оказалось, что в ряде случаев, когда нам необходимо создать несколько ожиданий за обработку одного callback-а, mockserver иногда подвисает и отдает 404 ответ на запрос после тайм аута в 20 секунд.
Первым делом поигрались с настройками. Привожу их ниже:
mockserver.logLevel=INFO mockserver.maxExpectations=12000 mockserver.watchInitializationJson=false mockserver.maxLogEntries=100 mockserver.outputMemoryUsageCsv=false mockserver.maxWebSocketExpectations=2000 mockserver.disableSystemOut=false mockserver.nioEventLoopThreadCount=100 mockserver.clientNioEventLoopThreadCount=100 mockserver.matchersFailFast=true mockserver.alwaysCloseSocketConnections=true mockserver.webSocketClientEventLoopThreadCount=100 mockserver.actionHandlerThreadCount=100
Также не пожалейте mockserver памяти и CPU. Памяти лучше выделить около 1 гигабайта и несколько процессорных ядер, если вы планируете запускать тесты больше чем в 16 потоков и активно использовать динамические callback-и
Из всех настроек лучше всего помогает mockserver.matchersFailFast=true Эта настройка на первом же несовпадении дает ответ, что ожидание не подошло. В нашем случае, когда мы сравниваем только по path, это некритично.
Тюнинг настроек улучшил ситуацию, но не избавил от проблем полностью. После безуспешных поисков решения мы перешли на использование http клиента к mockserver’у вместо использования нативного Java клиента как в примере выше. Плюс ко всему, mockserver только через JSON REST API поддерживает создание пакета ожиданий за один запрос, а это оказалось удобно и полезно для снижения нагрузки.
Добавляем okktp библиотеку в pom.xml проекта
<dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.10.0</version> </dependency>
Создаем отдельный класс HTTP клиента
MockClient.kt
package client import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.mockserver.mock.Expectation import java.net.URL import java.util.concurrent.TimeUnit class MockClient { private val mockBaseURL = "http://localhost:1080/mockserver/expectation" private fun getHttpClient(): OkHttpClient { val builder = OkHttpClient.Builder() builder.connectTimeout(30, TimeUnit.SECONDS) builder.readTimeout(30, TimeUnit.SECONDS) builder.writeTimeout(30, TimeUnit.SECONDS) return builder.build() } fun setExpectations(expectation: List<Expectation>) { val url = URL(mockBaseURL) val client = getHttpClient() val mediaType = "application/json; charset=utf-8".toMediaType() val body = expectation.toString().toRequestBody(mediaType) val request = Request.Builder().url(url).put(body).build() client.newCall(request).execute().close() } }
И с учетом этого клиента создание ожиданий в коде класса callback теперь выглядит так:
UserHandleExpectation.kt
package userApi import client.MockClient import com.google.gson.Gson import userApi.dto.* import org.mockserver.matchers.TimeToLive import org.mockserver.matchers.Times import org.mockserver.mock.action.ExpectationResponseCallback import org.mockserver.mock.Expectation import org.mockserver.model.* import org.mockserver.model.JsonBody.json import utils.getIsoCurrentDate import java.util.* import java.util.concurrent.TimeUnit import kotlin.math.absoluteValue const val TTL_SEC: Long = 600 class UserHandleExpectation : ExpectationResponseCallback { override fun handle(httpRequest: HttpRequest): HttpResponse { val gson = Gson() val mockServerClient = MockClient() // Convert POST payload to data structure val userPayload: UserPOSTRequestDTO? = gson.fromJson(httpRequest.bodyAsJsonOrXmlString, UserPOSTRequestDTO::class.java) // Create expectation for GET /users/{userId} val userUUID = UUID.randomUUID().toString() val userGETResponse = UserGETResponseDTO( userUid = userUUID, name = userPayload?.name, surname = userPayload?.surname, currency = userPayload?.currency, region = userPayload?.region, serverCode = userPayload?.server_code, createdDate = getIsoCurrentDate() ) val userExpectation = Expectation.`when`( HttpRequest.request() .withMethod("GET") .withPath("/api/v1/users/${userUUID}"), Times.unlimited(), TimeToLive.exactly(TimeUnit.SECONDS, TTL_SEC) ).thenRespond( HttpResponse.response() .withContentType(MediaType.APPLICATION_JSON) .withBody(json(gson.toJson(userGETResponse, UserGETResponseDTO::class.java))) ) // Create expectation for GET /users/{userId}/accounts/{accountId} val accountId = userUUID.hashCode().absoluteValue val accountGETResponse = AccountGETResponseDTO( id = accountId, userUid = userUUID, currency = userPayload?.currency, status = "ACTIVE", expired = false ) val userAccountsExpectation = Expectation.`when`( HttpRequest.request() .withMethod("GET") .withPath("/api/v1/users/${userUUID}/accounts/${accountId}"), Times.unlimited(), TimeToLive.exactly(TimeUnit.SECONDS, TTL_SEC) ).thenRespond( HttpResponse.response() .withContentType(MediaType.APPLICATION_JSON) .withBody(json(gson.toJson(accountGETResponse, AccountGETResponseDTO::class.java))) ) // Prepare response for current callback val userPOSTCallbackResponse = UserPOSTResponseDTO( userId = userUUID, name = userPayload?.name, surname = userPayload?.surname, account = accountId, serverCode = userPayload?.server_code, region = userPayload?.region ) // Store expectations in Mockserver by one request mockServerClient.setExpectations( listOf<Expectation>( userExpectation, userAccountsExpectation ) ) return HttpResponse.response() .withStatusCode(HttpStatusCode.CREATED_201.code()) .withContentType(MediaType.APPLICATION_JSON) .withBody(json(gson.toJson(userPOSTCallbackResponse, UserPOSTResponseDTO::class.java))) } }
После всех действий мы получаем удобный инструмент, который сам на своей стороне занимается созданием необходимых ответов на наши последующие вызовы, и бонусом — имеем дополнительную гибкость при создании предусловий в нашем тестовом фреймворке. Ведь теперь мы можем в нужных тестах просто изменить уже подготовленное моком ожидание напрямую. Например, мок подготовил нам серию ответов, но на каком-то шаге теста мы хотим получить особое значение параметра. Тогда мы запрашиваем из mockserver это ожидание, изменяем в нем этот параметр, обновляем ожидание и тест получает нужные нам данные. В итоге, нет нужды в каждом тесте прописывать создание типичных предусловий, этим занимается сам мок.
Вывод
Благодаря возможностям mockserver мы смогли реализовать на его платформе удобный и универсальный инструмент мокирования запросов, который дает возможность гибко управлять данными во время выполнения тестов, и в то же время позволяет вынести логику сторонних сервисов отдельно. Все возможности mockserver, способы матчинга запросов, их верификацию и многое другое вы сможете найти в подробной документации. Код из примеров выше вы сможете найти по ссылке.
ссылка на оригинал статьи https://habr.com/ru/company/exness/blog/705652/
Добавить комментарий