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

Эволюция тестового окружения

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

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

Возможная схема использования моков
Возможная схема использования моков

Что делать с существующими тестами?

Если в компании тестовое окружение развивалось по вышеописанному сценарию, то и тестовый фреймворк с большим количеством тестов уже скорее всего написан и сильно зависит от текущей тестовой инфраструктуры и ее связанности с внешними сервисами.

В тот момент, когда команда решает изолировать свою тестовую инфраструктуру,  и возникает вопрос как подружить тесты с мок-сервисами. Я бы выделил два варианта  в организации связи тестов и моков:

  1. Когда используется какой-нибудь инструмент мокирования запросов, умеющий отвечать на запрос по какому-то специальному правилу, заданному извне. Таких инструментов уже достаточно много, и mockserver, о котором речь пойдет ниже, один из них.

  2. Когда есть самописный сервис, почти полностью имитирующий внешний в нужных нам точках взаимодействия.

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

Тест сам подготавливает все необходимые данные в моках перед выполнением

Для этого потребуется развернуть какой-то инструмент мокирования в тестовом окружении и перенаправить тестируемые сервисы на него.

Далее в тестовом фреймворке нужно будет создать все необходимые генераторы эмулируемых данных, написать клиента к этому сервису и расставить методы, которые создают необходимые ответы в сервисе моков, по имеющимся тестам и их шагам.

Например, нам нужно написать тест на создание пользователя, в процессе которого подгружаются данные из сторонней информационной адресной системы или микросервиса. И этот сторонний сервис нам и надо эмулировать. Итоговый результат в псевдо-коде может выглядеть так:

# Генерируем пользователя со случайными данными
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! А ведь это, по сути, позволяет нам создать “умный” мок на готовой платформе, описав минимумом кода только нужную часть эмулируемой логики.

Например, у нас есть задача по созданию сервиса, эмулирующего взаимодействие с системой управления пользователями. Концепт решения может выглядеть так:

  1. Каждый тест атомарен и создает нового пользователя запросом POST /api/v1/user в систему, с передачей параметров пользователя в payload-е запроса. Вдобавок к пользователю будет создаваться account и привязываться к нему.

  2. Реакцией на такой запрос POST будет выполнение необходимого нам кода - обработка переданных параметров пользователя, генерация уникального ID пользователя и создание нужных ответов в mockserver для будущих запросов тестируемой системы.

  3. Подготовка ожиданий (нужного ответа) на будущие 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, мы видим следующую картину:

Дашборд mockserver с созданным главным ожиданием
Дашборд mockserver с созданным главным ожиданием

Посылаем 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"
}

И на дашборде отображаются наши новые ожидания:

Созданные GET ожидания с автоматически сгенерированными идентификаторами
Созданные GET ожидания с автоматически сгенерированными идентификаторами

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

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