
Всем привет, меня зовут Булат Маскуров, я QA Lead в Uzum Fintech. В своей статье расскажу, как сделать WireMock «ультимативным» mock-сервером. А если пока вы не знакомы с этим инструментом, я введу в курс дела, объясню, как и зачем прикручивать к WireMock простой и удобный Web GUI, и самое интересное: покажу инструмент изнутри, опишу Extension API и расскажу про наше кастомное расширение, которое решило реальную проблему.
Как мы пришли к WireMock
Наш набор технологий для тестирования с годами развивался, усложнялось тестовое окружение. В какой-то момент мы поняли, что нам нужен единый mock-сервис для всех QA и разработчиков. Требовалось быстро найти инструмент, внедрить его в нашу экосистему и научить всех им пользоваться.
Кроме WireMock на рынке есть и другие решения, например, MockServer или Mountebank. Мы выбрали WireMock, и вот почему.
Мы используем связку Java и Spring. WireMock тоже написан на Java и фактически является стандартом в интеграционном тестировании. Многие наши бэкенд-разработчики уже применяли его по своей инициативе.
WireMock распространяется по Open source лицензии, поэтому, при необходимости, мы можем дорабатывать его под себя.
Есть готовое серверное решение WireMock Standalone плюс официальный Docker-образ, который легко интегрировать в CI.
В WireMock Standalone все стабы хранятся в виде JSON-файлов. Благодаря этому с ним легко работать в связке с Git. Так решается проблема версионирования и коллаборации.
У WireMock Standalone простой и эффективный Rest API. С его помощью мы сможем создавать, изменять и удалять стабы, а также менять конфигурацию сервера WireMock без перезапуска.
И последняя причина, которая вытекает из первой: к WireMock можно подключать расширения, если стандартного функционала не хватает.
У альтернативных продуктов не было таких возможностей и особенностей. Например, Mountebank написан на JS, а у MockServer нет такого зрелого Extension API. Хотя, конечно, многое зависит от конкретных задач.
Основные возможности и преимущества WireMock
Статические стабы
Здесь можно задавать правила. Например, если пришёл запрос GET /users/1, то нужно вернуть заданный JSON:
{
"request": {
"method": "GET",
"url": "/users/1"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"id": 1,
"name": "John",
"surname": "Doe",
"age": 30
}
}
}
Система шаблонов
В ответах можно использовать Handlebars-шаблоны. Например, подставить случайное значение, сгенерировать дату или вернуть параметр из запроса. Это превращает статический mock в динамический.
Пример генерирования случайного значения:
{
"request": {
"method": "POST",
"url": "/users"
},
"response": {
"status": 201,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"id": "{{jsonPath request.body '$.id'}}"
}
}
}
Библиотеки под разные языки
Удобная библиотека для Java. Разработчики часто используют её в интеграционных тестах. Можно поднять embedded-сервер WireMock, а также императивно создавать стабы.
Проксирование и запись
Сервер WireMock можно использовать как прокси, записывать входящие через него запросы и ответы, а также использовать эти данные как стабы.
Расширения
У WireMock есть множество официальных и сторонних расширений. Вот несколько примеров.
WireMock State Extension позволяет передавать состояние между стабами. Например, POST-запрос создаёт пользователя, а GET-запрос возвращает его данные. Благодаря этому сервер функционирует не просто как заглушка, а как полноценный эмулятор.
Request
{
"firstName": "John",
"lastName": "Doe"}
Response
{
"id": "kn0ixsaswzrzcfzriytrdupnjnxor1is", # Random value
"firstName": "John",
"lastName": "Doe"
}
WireMock gRPC Extension позволяет мокировать gRPC-сервисы прямо в WireMock с помощью .proto-файлов. Например, можно быстро собрать простой gRPC-стаб.
Синтаксис почти не отличается от классического: в urlPath указывается имя сервиса и метода, а в method всегда используется POST.
{
"request" : {
"urlPath" : "/com.example.grpc.GreetingService/greeting",
"method" : "POST",
"bodyPatterns" : [{
"equalToJson" : "{ \"name\": \"Tom\" }"
}]
},
"response" : {
"status" : 200,
"body" : "{\n \"greeting\": \"Hi Tom\"\n}",
"headers" : {
"grpc-status-name" : "OK"
}
}
}
WireMock Faker Extension генерирует поддельные данные прямо в теле ответа, например имена и адреса. Под капотом он использует Datafaker:
{{ random 'Name.first_name' }}
WireMock JWT Extension добавляет в шаблонизатор новые хелперы для генерации JWT и JWKs. Это удобно, если нужно имитировать сценарии авторизации:
{{{jwt maxAge='12 days'}}}
{{{jwt exp=(parseDate '2040-02-23T21:22:23Z')}}}
Это лишь малая часть того, что умеет WireMock. Его базовые возможности хорошо задокументированы, поэтому я не буду на них останавливаться.
Графический интерфейс для WireMock
Мы успешно внедрили WireMock и начали популяризировать его внутри компании. Вскоре стало понятно, что нам не хватает графического интерфейса.
WireMock можно легко связать с Git, например, чтобы согласовывать стабы прямо в репозитории. Это удобно, но накладывает операционные расходы: любое изменение стабов требует merge request и deploy. При этом инженерам нужно было на лету изменять стабы и тестировать свои гипотезы.
Мы нашли готовое решение: этот репозиторий — форк официального WireMock. У него такая же функциональность, и при этом есть простой веб-интерфейс плюс Docker-образ, который легко запустить и встроить в окружение.
Посмотрим, что умеет WireMock GUI.
Экран Mappings

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

Чтобы стабы отображались в виде иерархии, достаточно хранить их в папках внутри репозитория:


WireMock GUI добавляет к стабам немного метаданных, на основе которых выстраивает дерево:

На этом экране можно создавать, удалять и редактировать стабы прямо в текстовом редакторе.
Кроме того, в WireMock GUI есть представление Overview, где стаб можно посмотреть целиком в удобном для чтения виде:

WireMock GUI позволяет сразу протестировать стаб и убедиться, что он работает, как нужно. Это заметно ускоряет процесс изменений.

Экраны Matched и Unmatched
Оба экрана работают как журналы запросов сервера WireMock.
На экране Matched отображаются запросы, которые удалось сопоставить со стабом и вернуть корректный ответ.
На экран Unmatched попадают запросы, которые не подошли ни под один стаб.

Экран Files
Этот экран самый простой: здесь можно работать с файлами, которые обычно хранятся в директории __files. По сути, это тот же интерфейс, что и в Mappings, только для файлов.

Экран StateMachine
Последний и, пожалуй, самый необычный экран: он визуализирует сценарии WireMock. Прежде чем смотреть, как это выглядит, напомню, что такое сценарии и зачем они нужны.
Сценарий — это способ управлять последовательным поведением API, например, чтобы один и тот же запрос возвращал разные ответы в зависимости от состояния.
У сценариев есть статусы, и в зависимости от текущего статуса меняется состояние стабов.
Допустим, мы тестируем жизненный цикл заказа в интернет-магазине. Назовём этот сценарий Order lifecycle. Заказ может находиться в трёх статусах:
CREATED — создан;
PAID — оплачен;
SHIPPED — отправлен.
Есть эндпоинт GET /order/{id}, которая по идентификатору возвращает заказ.
Посмотрим, как такой сценарий выглядит в WireMock.
Первый стаб описывает статус CREATED:
{
"scenarioName": "Order lifecycle",
"requiredScenarioState": "STARTED",
"newScenarioState": "CREATED",
"request": {
"method": "GET",
"url": "/order/123"
},
"response": {
"status": 200,
"body": "{ \"orderId\": 123, \"status\": \"CREATED\" }"
}
}
Здесь важно обратить внимание на три поля:
scenarioName— идентификатор сценария, который связывает между собой разные маппинги;requiredScenarioState— состояние, в котором должен находиться сценарий, чтобы сработал этот стаб (по умолчанию — STARTED);newScenarioState— состояние, в которое сценарий перейдёт после выполнения этого стаба.
Далее добавляем стабы для статусов PAID и SHIPPED:
{
"scenarioName": "Order lifecycle",
"requiredScenarioState": "CREATED",
"newScenarioState": "PAID",
"request": {
"method": "GET",
"url": "/order/123"
},
"response": {
"status": 200,
"body": "{ \"orderId\": 123, \"status\": \"PAID\" }"
}
}
{
"scenarioName": "Order lifecycle",
"requiredScenarioState": "PAID",
"newScenarioState": "SHIPPED",
"request": {"method": "GET",
"url": "/order/123"
},
"response": {
"status": 200,
"body": "{ \"orderId\": 123, \"status\": \"SHIPPED\" }"
}
}
В итоге получаем сценарий Order lifecycle с тремя стабами, которые последовательно переходят между состояниями: STARTED → CREATED → PAID → SHIPPED.
Теперь, когда принцип работы сценариев понятен, посмотрим, как они визуализированы на экране StateMachine в графическом интерфейсе.

Всё именно так, как описано выше: четыре состояния, переходящие из одного в другое. Если на стрелках нажать на один из кружков с буквой i, откроется стаб, который переводит сценарий из одного состояния в другое.

Сценарии — мощный и гибкий инструмент. В примере я показал всего четыре статуса, но на практике их может быть значительно больше.
Extension API
Напомню, что наша цель — создать универсальный mock-сервис для всех. Мы уже упростили работу с WireMock для ручного тестирования, а теперь посмотрим, как работает его Extension API.
Жизненный цикл стаба в WireMock
Любое расширение WireMock может вмешиваться в разные этапы жизненного цикла стаба. Когда в WireMock приходит запрос, он проходит несколько последовательных стадий:

Кратко рассмотрим основные стадии:
Request received: сервер принимает HTTP-запрос (
nse sentWiremock).Request matching: сервер проверяет все стабы (mappings) с учётом URL, методов, заголовков, параметров запроса, body matchers и т. д. Если найдено несколько совпадений, применяется scoring: выбирается наиболее подходящий стаб.
Scenario state (опционально): если стаб связан со сценарием, проверяется текущее состояние сценария (например, STARTED → PAID → SHIPPED).
Response definition resolution. WireMock находит описание ответа: статический JSON, файл из __files/, proxy на реальный сервис или динамическая генерация через расширение.
Templating: если включены Handlebars-шаблоны, в ответ подставляются значения из запроса (query, headers, random values и т. д.).
Transformations: на этом этапе работают ResponseTransformer из Extension API. Они могут модифицировать тело, статус, заголовки.
Response sent: сервер возвращает клиенту готовый ответ и параллельно фиксирует событие как ServeEvent (для журнала запросов, /__admin/requests).
Extension API
Это набор интерфейсов и абстрактных классов. А расширение — это просто реализация одного из этих интерфейсов.
Интерфейсов в API довольно много, и большинство из них узкоспециализированные. Я расскажу про самые полезные и часто применяемые.
RequestFilterV2
Этот интерфейс позволяет перехватывать входящий запрос, чтобы модифицировать его или записать в журнал. Основной метод здесь — filter().
Рассмотрим на примере:
public class AddHeaderFilter implements RequestFilterV2 {
@Override
public String getName() { return "add-header-filter"; }
@Override
public RequestFilterAction filter(Request request) {
Request modified = RequestWrapper.create()
.addHeader("X-Injected", "true")
.wrap(request);
return RequestFilterAction.continueWith(modified);
}
}
Как видно, написать свой фильтр несложно: он добавляет заголовок ко всем запросам, проходящим через WireMock.
Из интересного — метод RequestFilterAction.continueWith(). RequestFilterV2.filter() возвращает RequestFilterAction. И если заглянуть внутрь, видно следующее:
public class RequestFilterAction {
public static RequestFilterAction continueWith(Request request) {
return new ContinueAction(request);
}
public static RequestFilterAction stopWith(ResponseDefinition
responseDefinition) {
return new StopAction(responseDefinition);}
}
}
У RequestFilterAction есть два наследника — ContinueAction и StopAction. Первый используется, если нужно изменить запрос и продолжить его обработку, второй — чтобы остановить выполнение и вернуть ответ сразу.
ResponseTransformerV2
Этот интерфейс, пожалуй, самый популярный в API расширений.
ResponseTransformerV2 позволяет изменять ответ стаба перед отправкой клиенту. Он срабатывает на каждый запрос уже после того, как был найден стаб и его ResponseDefinition. Рассмотрим простой пример:
public class ForbiddenResponseTransformerV2 implements ResponseTransformerV2 {
@Override
public String getName() {
return "forbidden-transformer";
}
@Override
public Response transform(Response response, ServeEvent serveEvent) {
String url = serveEvent.getRequest().getUrl();
if (url != null && url.startsWith("/forbidden")) {
// Создаём новый Response и возвращаем
return Response.response()
.status(403)
.body("Access denied by transformer")
.build();
}
return response;
}
}
По контракту мы обязаны реализовать метод transform(). Его входные параметры — Response и ServeEvent. И также мы просим сервер WireMock отдавать generic-ответ на все запросы, путь к которым начинается с /forbidden.
Теперь немного сложнее: добавим к каждому ответу поле now с текущим временем.
public class AddTimestampResponseTransformerV2 implements
ResponseTransformerV2 {
@Override
public String getName() {
return "add-timestamp-transformer";
}
@Override
public Response transform(Response response, ServeEvent serveEvent) {
String originalBody = response.getBodyAsString();
Object parsed;
try {
// Попытаемся распарсить тело как JSON (если не JSON — упадём в catch)
parsed = Json.read(originalBody, Object.class);
} catch (Exception e) {
// Если не JSON — сохраняем как строку
parsed = originalBody;
}
Map<String, Object> result = new HashMap<>();
result.put("now", Instant.now().toString());
result.put("original", parsed);
String newBody = Json.write(result);
return Response.Builder.like(response)
.but()
.body(newBody)
.build();
}
}
Для этого мы
Получаем тело ответа в виде строки и пытаемся преобразовать его в JSON.
Кладём в
mapоригинальное тело ответа и рядом кладём полеnow.Вносим в оригинальный ответ новое тело и возвращаем.
Зачем может пригодиться ResponseTransformerV2:
Добавление динамического контента во все стабы (время, UUID и т. д.).
Тонкая настройка случайных ошибок и задержки сервера.
AdminApiExtension
Интерфейс позволяет расширять /__admin/ API Wiremock'а. Так можно добавлять новые собственные эндпоинты GET, POST, PUT, DELETE и другие.
Пример простого расширения:
public class HelloAdminApiExtension implements AdminApiExtension {
@Override
public void contributeAdminApiRoutes(Router router) {
router.add(RequestMethod.GET, "/hello", (Admin admin, ServeEvent
serveEvent, PathParams pathParams) ->
responseDefinition()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"message\": \"Hello from custom Admin API!\"}")
.build()
);
}
@Override
public String getName() {
return "hello-admin-api-extension";
}
}
Здесь во входных параметрах метода contributeAdminApiRoutes дан объект Router. Через него мы добавляем новую конечную точку GET /__admin/hello, которая возвращает простой JSON-ответ и статус 200.
Notifier
Notifier — это интерфейс, который WireMock использует для всех своих журналов. Для этого у него есть две реализации: ConsoleNotifier и Slf4jNotifier. По умолчанию WireMock пишет журналы в System.out и System.err, но мы можем заменить реализацию и направить логи:
в SLF4J, Logback, Log4j;
во внешнюю систему вроде Elastic;
или в свою базу данных или файлы.
Наше расширение
Однажды мы столкнулись с задачей, которую WireMock не мог решить из коробки.

У нас есть интеграция по HTTP: сначала отправляем запрос на создание платежа, затем — на его подтверждение. Оба запроса являются своего рода операциями, и в ответе мы ожидаем увидеть уникальный operation_id.
Проблема возникает во втором запросе. Сначала сервер отвечает нам синхронным ответом, в котором мы ожидаем получить payment_id и operation_id, но этот ответ является только подтверждением того, что запрос принят.
Сам результат подтверждения платежа мы получаем в вебхуке, который приходит через несколько секунд. В нём мы ожидаем увидеть всё те же payment_id и operation_id.
Оказывается, что WireMock не умеет подкладывать данные из ответа в вебхук.
Какие мы видели пути решения этой проблемы:
Создать контекст для стаба, в котором можно хранить локальные переменные и использовать их в ответах и вебхуках.
Класть данные из ответа в вебхук с помощью трансформера.
Мы решили пойти вторым путём, потому что он показался нам куда проще первого.
Для начала посмотрим на пример тела ответа и вебхука от нашей интеграции. Ответ:
{
"payment_id": "123",
"operation_id": "456",
"status": "ACCEPTED",
...
}
Вебхук:
{
"payment_id": "123",
"operation_id": "456",
"status": "COMPLETED",
...
}
Нам надо положить в вебхук payment_id и operation_id. Первый мы возьмём из запроса, а второй — из ответа, и для этого мы написали своё расширение.
Для трансформации вебхука в API расширений есть WebhookTransformer:
public interface WebhookTransformer extends Extension {
WebhookDefinition transform(ServeEvent serveEvent, WebhookDefinition
webhookDefinition);
// Defaulting this for backwards compatibility
default String getName() {
return "webhook-transformer-" + this.getClass().getSimpleName();
}
}
Он похож на ResponseTransformerV2: достаточно реализовать метод transform().
Определимся, как мы будем доставать значение из ответа и подкладывать в вебхук. WireMock использует шаблоны Handlebars. Они выглядят так: {{request.id}}. Мы достаём какое-то значение из запроса и подкладываем в ответ.
Как будет выглядеть наш алгоритм:
Разбиваем тело вебхука на кусочки и кладём в отсортированный список. Кусочки — это текст и шаблоны или заглушки.
Ищем соответствующие значения в теле ответа и кладём их в map, где ключом будет шаблон, а значением — то, что мы нашли в теле ответа.
Собираем новое тело вебхука, в которое подставляем найденные значения.
Для наглядности предположим, что нам дано такое тело ответа:
{
"name": "John",
"surname": "Doe","age": 30
}
…и такое тело вебхука:
Hello, {{response.name}}!
Реализуем расширение:
@Override
public WebhookDefinition transform(ServeEvent event, WebhookDefinition
webhook) {
// Достаем исходное тело ответа из запроса к серверу
String responseBody = event.getResponseDefinition().getBody();
// Достаем исходное тело вебхука
String webhookBody = webhook.getBody();
...
}
Достанем исходное определение ответа из объекта ServeEvent и тело вебхука в виде строки из WebhookDefinition. Поделим тело вебхука на кусочки и положим в отсортированный список. Их мы представим в виде объектов Substring. Cоздадим класс:
public class Substring {
private final String value;
private final Boolean isPlaceholder;
public Substring(String value, Boolean isPlaceholder) {
this.value = value;
this.isPlaceholder = isPlaceholder;
}
public String getValue() {
return value;
}
public Boolean getIsPlaceholder() {
return isPlaceholder;
}
}
У класса Substring всего два поля: value и isPlaceholder. По полю isPlaceholder понимаем, работаем мы с текстом или кастомным шаблоном.
Как выглядит алгоритм:
@Override
webhook) {
public WebhookDefinition transform(ServeEvent event, WebhookDefinition
…
// Делим тело вебхука на кусочки: текст и плейсхолдеры
Matcher matcher = Pattern.compile("\\{\\{response\\.([a-zA-Z0-9_]+)\\}\\}").matcher(webhookBody);
List<Substring> webhookSubstrings = new ArrayList<>();
int lastEnd = 0;
while (matcher.find()) {
// Добавляем текст перед найденным плейсхолдером
if (lastEnd < matcher.start()) {
webhookSubstrings.add(new Substring(
webhookBody.substring(lastEnd, matcher.start()),
false
));
}
// Добавляем сам плейсхолдер
webhookSubstrings.add(new Substring(
matcher.group(1),
true
));
lastEnd = matcher.end();
}
// Добавляем текст после последнего найденного плейсхолдера
if (lastEnd < webhookBody.length()) {
webhookSubstrings.add(new Substring(
webhookBody.substring(lastEnd),
false));
}
...
}
Здесь мы создаём регулярное выражение, которое соответствует нашему шаблону, и получаем из него javaMatcher. Далее формируем пустой список Substring, который будем заполнять, а также задаём нулевой индекс для матчера. В цикле находим кусочки тела вебхука (заглушки или шаблоны), которые соответствуют заданному регулярному выражению.
В список webhookSubstrings добавляем сначала текст, который стоял перед заглушкой, а далее саму заглушку. После цикла проверяем, остался ли текст в конце, и если да, тоже добавляем его в список.
После обработки получаем отсортированный список, который выглядит так:

Переходим ко второму шагу.
Ищем соответствующие значения в теле ответа и кладём их в map, где ключом будет наш шаблон, а значением — то, что мы нашли в теле ответа:
@Override
webhook) {
public WebhookDefinition transform(ServeEvent event, WebhookDefinition
…
вебхук
try {
// Найти все значения в ответе, которые соответствуют заглушкам в
Map<String, String> fieldValueMap = new HashMap<>();
// Парсим тело ответа в JSON
DocumentContext document = JsonPath.parse(responseBody;
for (Substring webhookSubstring : webhookSubstrings) {
if (!webhookSubstring.getIsPlaceholder()) {
continue;
}
// Находим в ответе значение заглушки
Optional.ofNullable(document.read(webhookSubstring.getValue())).map(Object::
toString)
// Кладем в map заглушку и значение из ответа
.ifPresent(value ->
fieldValueMap.put(webhookSubstring.getValue(), value));
}
} catch (Exception e) {
System.err.println("Error extracting values from input. Exception: "+ e.getMessage());
}
...
}
Что мы сделали
Создали пустую
map, которую нам предстоит наполнить.Преобразовали тело ответа из строки в JSON-объект.
Зашли в цикл, в котором будем работать со списком
webhookSubstrings, который мы наполнили в предыдущем шаге.Проверили, является ли
Substringзаглушкой. Если да, ищем значение заглушки в JSON-объекте. Если оно найдено, кладём его в map, где ключом является название поля из JSON-тела ответа, а значением — само значение этого поля, то естьJohn.
В итоге получаем map "name" -> "John".
И последний шаг алгоритма — собрать новое тело для вебхука и подставить в него найденные значения из ответа:
@Override
webhook) {
public WebhookDefinition transform(ServeEvent event, WebhookDefinition
…
// Склеиваем новое тело вебхука
StringBuilder result = new StringBuilder();
for (Substring substring : webhookSubstrings) {
if (!substring.getIsPlaceholder()) {
result.append(substring.getValue());
}
result.append(fieldValueMap.getOrDefault(substring.getValue(),""));
}
...
}
Здесь мы
Создаём новый
StringBuilder result.В цикле итерируемся по списку
webhookSubstrings, который создали на предыдущем шаге.-
Добавляем в
resultзначения из списка, проверяя, являются ли они заглушками.если не являются, просто кладём значение;
если являются, находим соответствующее значение в
fieldValueMapиз второго шага и добавляем его вresult.
В итоге получаем строку Hello, John!.
Остается подменить тело ответа исходного вебхука на то, что мы собрали:
@Override
webhook) {
public WebhookDefinition transform(ServeEvent event, WebhookDefinition
…
Parameters parameters = webhook.getExtraParameters();
Map<String, Object> transformed = new HashMap<>();
parameters.forEach((key, value) -> {
if ("body".equals(key)) {
transformed.put(key, result.toString());
} else {
transformed.put(key, value);
}
});
Parameters transformedParameters = Parameters.from(transformed);
return WebhookDefinition.from(transformedParameters);
...
}
Копируем все параметры вебхука, кроме body. Вместо него подставляем собранное тело с актуальными данными.
Итоги
На первый взгляд может показаться, что написать собственное расширение сложно, но на деле всё гораздо проще, если понимать концепцию Extension API и внутреннюю логику WireMock.
Расширение — это не обязательно что-то масштабное. Иногда 10 строчек кода решают небольшую проблему, и при этом экономят часы на дублировании одного и того же в JSON-стабах.
Мы рассмотрели:
что такое WireMock и как он работает;
как прикрутить к нему UI;
как устроен Extension API и как писать расширения самостоятельно.
Если у вас схожий стек и есть потребность в универсальном mock-сервере, WireMock точно стоит попробовать.