Иногда ваше тестовое окружение не готово к изоляции от внешних сервисов. Более того, тестовый фреймворк не готов тоже. Но наступает момент, когда вы понимаете, что это нужно изменить и размышляете, как наиболее плавно перейти на моки. В этой статье я хочу рассказать о нескольких вариантах организации мокирования веб-сервисов в подобном случае, о том, как связать это с автоматизацией тестирования и о нашем опыте внедрения 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, способы матчинга запросов, их верификацию и многое другое вы сможете найти в подробной документации. Код из примеров выше вы сможете найти по ссылке.