Self-hosted mock API і тестування на VPS
Розгорніть WireMock і Hurl на власному VPS: stubs зберігайте в git, тести запускайте в CI, а звіти не втрачайте після перебудови сервера.
Два завдання, що використовують один репозиторій
Self-hosted мокінг і тестування API — це два різні завдання. Якщо розглядати їх як одне, можна втратити тиждень. Mock server замінює залежність, до якої CI не може звернутися: платіжного провайдера, API партнера, upstream із rate limiting або сервіс, який інша команда ще не випустила. API test runner послідовно викликає власні ендпоїнти у фіксованому порядку та перевіряє відповіді, передаючи значення з однієї відповіді в наступний запит.
Ці завдання не перетинаються. Mock server ніколи не повідомляє, пройшов тест чи ні. Test runner не визначає, що має повертати платіжний провайдер, коли картку відхилено. Більшість команд, які вже орендують сервер, зрештою запускають по одному екземпляру кожного компонента. Обидва запускаються одним Docker Compose file, а зміни до них перевіряються в одному pull request.
Навіщо самостійно розгортати mock API та тестування?
Ваші fixtures містять дані, сформовані за зразком production. Тіло запиту в API-тесті — це реальний запис клієнта зі зміненим ім’ям або навіть без зміненого імені, якщо його ніхто не перевірив. Записані stubs ще небезпечніші: proxy recording зберігає фактичну відповідь upstream, тому в каталозі stubs, створеному під час запису, залишаються live tokens і адреси електронної пошти клієнтів, доки хтось не перегляне кожен файл. У hosted service ці дані стають чужим інцидентом і вашим розкриттям інформації.
Друга причина — доступність. Сервіс, прив’язаний до приватної адреси, недоступний із hosted runner, тому тест взагалі не запуститься. Кожен обхідний шлях має свою ціну. Публікація API в інтернеті заради тестування скасовує причину, через яку його зробили приватним. Tunnel або публічна staging-копія — це ще одна система для обслуговування, а staging-копія розходиться з production між релізами. Runner у тій самій приватній мережі звертається до сервісу безпосередньо й не потребує нічого з цього. Саме це є практичним обґрунтуванням для self-hosted runner GitHub Actions.
Який self-hosted mock server варто запустити?
Кожен із цих інструментів працює як container на сервері, яким ви керуєте. Важливо зрозуміти, що саме кожен із них вважає джерелом істини. Від цього залежить, чи відтворення container обійдеться без втрат, чи забере цілий день.
- WireMock зберігає кожен stub як JSON-файл у каталозі
mappings/, а великі тіла відповідей — у__files/. Образ —wiremock/wiremock, його кореневий каталог усередині container —/home/wiremock. WireMock також працює як proxy для запису трафіку. Файли на диску означають, що mock можна зберігати в git, як і будь-який інший код. - Mockoon CLI зберігає весь mock API в одному JSON-файлі даних. Встановіть його за допомогою
npm install -g @mockoon/cliі запустіть командоюmockoon-cli start --data ./data-file.jsonабо запустіть imagemockoon/cli, підключивши цей файл через bind mount. Desktop app редагує той самий файл, тому проєктування в UI та commit результату залишаються сумісними. - MockServer працює з image
mockserver/mockserverі прослуховує порт 1080. Expectations надходять через власний REST API. Це зручно для тестового коду, але небезпечно для deployment: expectation, створена HTTP-викликом, зникає після перезапуску container. Для stub, які мають бути постійними, використовуйте його JSON-файл ініціалізації. - Prism створює mock на основі документа OpenAPI, а не окремих stub-файлів. Встановіть його за допомогою
npm install -g @stoplight/prism-cli, а потім запустіть командоюprism mock openapi.yaml. Усередині container додайте-h 0.0.0.0, оскільки Prism за замовчуванням прив’язується до localhost інакше буде недоступний ззовні container. - Microcks — найбільш масштабний варіант: web UI, який імпортує документи OpenAPI та Postman collections, після чого надає їх як mock і запускає contract tests. Для повного встановлення потрібні MongoDB і Keycloak, а для асинхронних функцій — Kafka. Образ «все в одному»
microcks-uberмістить MongoDB у пам’яті. У документації проєкту зазначено, що такий режим призначений для ephemeral use, тому вважайте все створене в цьому UI тимчасовим і зберігайте вихідні артефакти в git.
Який self-hosted runner для тестування API варто використовувати?
У цьому випадку потрібна послідовність дій: автентифікуватися, створити замовлення, отримати його дані та перевірити, що стан змінився. Для цього потрібно зберегти значення з однієї відповіді й використати його в наступному запиті. Інструмент, який не може передавати стан між викликами, є health check, а не засобом тестування API.
- Hurl запускає звичайні текстові файли з HTTP-запитами з одного бінарного файлу. Секція
[Captures]отримує значення з відповіді, секція[Asserts]перевіряє їх, а--testперетворює цей набір на runner тестів із підсумком і кодом завершення. Станом на August 2026 актуальною є версія 8.0.1. - Bruno CLI запускає каталог файлів
.bru. Установіть його за допомогоюnpm install -g @usebruno/cli, а потім виконайтеbru run folder --env Local --reporter-junit results.xml. Формат колекції за задумом складається з текстових файлів у каталозі, тому зміни зручно переглядати під час code review. - Newman запускає колекції Postman за межами Postman:
npm install -g newman, потімnewman run collection.json -r cli,junit --reporter-junit-export results.xml. Проблема — у форматі. Колекція є одним експортованим JSON-об’єктом, тому редагування виконується в Postman, а файл у git є копією, яка з часом застаріває. - Schemathesis призначений для іншого типу перевірок. Він читає схему OpenAPI і генерує випадки, які намагаються отримати відповіді, неможливі згідно з вашою схемою:
uvx schemathesis run https://your.api/openapi.json. Він виявляє збої та порушення контракту, але нічого не знає про ваші бізнес-правила, тому має працювати поруч зі скриптовим набором тестів, а не замінювати його. - Hoppscotch у self-hosted варіанті є веб-інтерфейсом і потребує екземпляра Postgres. Усвідомте цей компроміс перед установленням: колекції зберігаються в базі даних, а не у вашому репозиторії.
Є один варіант, якого варто уникати. Step CI досі згадують у оглядах інструментів, а його формат YAML workflow добре читається, але останній commit до репозиторію надійшов у August 2024. Програма, яка працює між вашим CI та API, є невдалим місцем для коду без супроводу.
Розмістіть mock-сервер за firewall
Наведена нижче конфігурація запускає WireMock як заміну платіжному провайдеру. Якщо формат compose-файлу вам незнайомий, у матеріалі Docker Compose на VPS описано команди керування життєвим циклом, які передбачає цей розділ.
services:
mock-payments:
image: wiremock/wiremock:3.13.2
command: ["--verbose"]
volumes:
- ./mocks/payments:/home/wiremock
ports:
- "127.0.0.1:8080:8080"
restart: unless-stoppedПрефікс 127.0.0.1: для порту є важливою частиною. Звичайний запис 8080:8080 публікує mock на всіх інтерфейсах, зокрема на вашій публічній IP-адресі. Він залишається доступним навіть тоді, коли ufw забороняє цей порт, оскільки Docker додає власні правила до ланцюжка DOCKER iptables, а їх обробляють до правил INPUT ufw. Прив’яжіть сервіс до loopback-адреси або до адреси приватного інтерфейсу. Тоді ядро не прийматиме з’єднання ззовні.
Сервіс, який ви тестуєте, має звертатися до mock. Якщо сервіс працює в тому самому compose-проєкті, базова URL-адреса mock — http://mock-payments:8080, оскільки compose самостійно визначає імена сервісів у своїй мережі. Якщо сервіс працює на хості, використовуйте http://127.0.0.1:8080. Передавайте це значення через змінну середовища, а не зашивайте в код. Інакше тестова URL-адреса потрапить у production.
Stubs зберігайте в ./mocks/payments/mappings/, по одному JSON-файлу для кожного stub.
{
"request": {
"method": "POST",
"urlPath": "/v1/charges",
"bodyPatterns": [{ "matchesJsonPath": "$.amount" }]
},
"response": {
"status": 201,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "id": "ch_test_001", "status": "succeeded", "amount": 4200 }
}
}Запустіть сервіс, а потім перевірте, що саме було завантажено.
docker compose up -d --wait mock-payments
curl -fsS http://127.0.0.1:8080/__admin/mappings--wait очікує, доки контейнер не повідомить про стан healthy. Це працює, оскільки образ WireMock містить HEALTHCHECK для його кінцевої точки /__admin/health. Виклик mappings виводить список усіх stubs, які прочитав сервер. Якщо написаний вами stub відсутній у цьому списку, його не було завантажено. Перевірте, що файл розташований у mappings/, а не в корені змонтованого каталогу, і що JSON має правильний синтаксис.
Коли надходить запит, для якого немає відповідного stub, WireMock повертає 404 з тілом, що починається з Request was not matched, а потім наводить diff із найближчим stub, який зберігається на сервері. Прочитайте цей diff, перш ніж щось змінювати, оскільки він указує точне поле, яке відрізняється. Зазвичай це шлях із /v1/charge, тоді як у stub указано /v1/charges.
Напишіть тест як послідовність викликів зі збереженням стану між ними
Файли Hurl — це звичайний текст. Встановіть deb-пакет із releases проєкту.
VERSION=8.0.1
curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb
sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.debНабір тестів, який перевіряє власний API через mock, розміщено в tests/checkout.hurl.
POST {{base_url}}/orders
Content-Type: application/json
{
"sku": "ssd-1tb",
"amount": 4200
}
HTTP 201
[Captures]
order_id: jsonpath "$['id']"
GET {{base_url}}/orders/{{order_id}}
HTTP 200
[Asserts]
jsonpath "$.status" == "paid"
jsonpath "$.charge_id" == "ch_test_001"Блок [Captures] перетворює цей приклад на API-тест, а не на два незалежні запити. Значення order_id зчитується з першої відповіді та підставляється в URL другого запиту. Перевірка charge_id є основною частиною тесту: вона підтверджує, що ваш сервіс викликав платіжного провайдера та зберіг отриману відповідь. Значення для порівняння — це значення, яке ви записали в WireMock stub. Тепер один файл охоплює обидві частини потоку.
hurl --test --variable base_url=http://127.0.0.1:3000 \
--report-junit reports/junit.xml \
--report-json reports/json \
tests/Успішний запуск виводить один рядок для кожного файлу та підсумок.
tests/checkout.hurl: Success (2 request(s) in 61 ms)
Executed files: 1
Executed requests: 2 (30.1/s)
Succeeded files: 1 (100.0%)
Failed files: 0 (0.0%)
Duration: 64 msУ разі помилки виводиться error: Assert failure із назвою файлу та номером рядка, потім — отримане й очікуване значення. Після цього hurl завершується з ненульовим кодом, тому CI зупиняється. Якщо status читає pending, хоча очікувалося paid, ваш сервіс не обробив відповідь mock. Далі перевірте журнал запитів WireMock у /__admin/requests. Він показує, чи досяг виклик mock взагалі.
Запускайте набір тестів із власного CI runner
Якщо runner зареєстрований на тому самому сервері, workflow буде коротким. Runner працює на хості як звичайний процес, тому docker і hurl мають бути встановлені на цьому хості. Від hosted image нічого не успадковується.
name: api-tests
on: [push]
jobs:
hurl:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Start the mock
run: docker compose up -d --wait mock-payments
- name: Run the suite
run: hurl --test --variable base_url=http://127.0.0.1:3000 --report-junit reports/junit.xml tests/
- name: Archive the reports
if: always()
run: install -d /srv/api-tests/reports/$GITHUB_SHA && cp -r reports/. /srv/api-tests/reports/$GITHUB_SHA/
- name: Stop the mock
if: always()
run: docker compose downПараметр if: always() на кроці архівації має важливе значення. Без нього після невдалого запуску тестів копіювання пропускається, і ви втрачаєте саме той звіт, який хотіли переглянути. Копія також має зберігатися поза workspace, оскільки runner очищає workspace перед наступним job, і разом із ним видаляються звіти.
Зберігайте результати, а не лише дані останнього запуску
Окремий файл JUnit XML для кожного коміту відповідає лише на одне запитання: чи пройшов він успішно. Він не показує, коли endpoint почав працювати повільніше, оскільки після припинення перегляду цих файлів їх ніхто не обробляє. Для відстеження тенденції додавайте після кожного запуску один рядок до невеликої бази даних на цьому самому сервері. Достатньо однієї таблиці з SHA коміту, назвою файлу, кількістю успішних і невдалих перевірок та тривалістю. SQLite у production на VPS — практичний варіант для цього: один файл, окремий серверний процес не потрібен, а вся історія потрапляє до резервної копії, яку ви вже створюєте. Обробляйте вивід Hurl у форматі --report-json, а не JUnit XML, оскільки з цих двох форматів саме він призначений для машинного читання.
Що має зберігатися після перебудови контейнера
Визначення mock-об’єктів і набори тестів є вихідним кодом. Вони мають зберігатися в репозиторії поруч із сервісом, який вони описують. Їх потрібно змінювати в тому самому pull request, що й endpoint. Stub, змінений у вебінтерфейсі, або expectation, переданий до MockServer через його REST API під час роботи, існує лише в пам’яті цього контейнера або в базі даних цього інструмента. Виконайте docker compose down — і він зникне. Ніхто цього не помітить, доки тест не почне проходити з неправильної причини. Якщо ваші репозиторії також працюють на власному обладнанні, self-hosted git-сервер зберігає fixtures і сервіс у межах однієї зони довіри.
Далі — практичні правила. Фіксуйте теги образів, оскільки latest може змінити спосіб зіставлення mock із запитами без жодних змін у репозиторії. Такий збій дуже важко пов’язати з його причиною. Монтуйте каталоги зі stubs у режимі read-only, якщо інструменту не потрібно в них записувати. Ніколи не зберігайте stubs mock у named Docker volume. Інакше volume стане джерелом істини, а копія в git непомітно застаріє.
Є ще один момент, який часто пропускають. Якщо ви створюєте stubs, записуючи реальний трафік через proxy, прочитайте кожен згенерований файл перед commit. Запис містить саме те, що повернув upstream, зокрема bearer-токени й адреси електронної пошти клієнтів. Після commit ці дані назавжди потрапляють у репозиторій, оскільки git зберігає видалений вміст в історії.
FAQ
У чому різниця між mock-сервером API та засобом запуску тестів API?
Mock-сервер відповідає на запити. Він замінює залежність, до якої неможливо звернутися з CI, і ніколи не повідомляє, чи пройшов тест. Засіб запуску тестів API надсилає запити до вашого сервісу, перевіряє відповіді за допомогою assertions, передає значення з одного виклику в наступний і завершує роботу з ненульовим кодом, якщо assertion не виконано. Вони вирішують різні завдання. Типова конфігурація запускає обидва одночасно: runner викликає ваш сервіс, а ваш сервіс викликає mock.
Чи можна тестувати внутрішній API з hosted CI runner?
Ні, якщо не відкрити до нього доступ ззовні. Hosted runner працює за межами вашої мережі, тому не може звернутися до сервісу, прив’язаного до приватної адреси. Варіанти — опублікувати API, запустити tunnel або підтримувати публічну staging-копію. Кожен варіант додає систему, яка може відмовити або спричинити витік даних. Runner у тій самій приватній мережі звертається до сервісу безпосередньо. Це основна практична причина, через яку команди розміщують такі процеси у власній інфраструктурі.
Де зберігати mock stubs і набори тестів API?
У git, поруч із сервісом, який вони описують. Інструменти, що зберігають визначення у файлах, наприклад каталог mappings/ у WireMock, data file у Mockoon, файли Hurl і каталог .bru у Bruno, забезпечують code review та перебудову контейнера без додаткових витрат. Інструментам, що зберігають визначення в базі даних або web UI, потрібні резервне копіювання та крок експорту. Саме про експорт люди часто згадують лише після того, як контейнер уже втрачено.
Чому мій mock повертає 404, хоча stub виглядає правильно?
WireMock обробляє stub лише за точного збігу. Для запиту без збігу повертається 404 із тілом, що починається з Request was not matched, після чого наводиться diff порівняно з найближчим stub. У цьому diff зазначено поле, яке відрізняється. Поширені причини: кінцевий слеш у path; заголовок Content-Type, якого вимагає stub, але клієнт його не надіслав; urlPath замість urlPathPattern, коли stub очікує змінний сегмент; або body matcher, що не відповідає payload. Спочатку перевірте /__admin/requests, щоб підтвердити, що запит взагалі надійшов до mock.
Чи потрібні мені mocks, якщо є staging-середовище?
Так, з двох причин. Staging-копія upstream-сервісу, яким ви не керуєте, також може стати недоступною або обмежити частоту запитів. Через це набір тестів завершується помилкою з причин, не пов’язаних із вашим кодом. Крім того, staging не може повертати відповіді, які вам найважливіше тестувати, наприклад відхилену оплату карткою або timeout шлюзу. Mock повертає такі відповіді на вимогу зі швидкістю локальної мережі. Завдяки цьому набір тестів, який у sandbox виконується хвилинами, виконується за секунди. Використовуйте staging для фінальної перевірки перед релізом, а mocks — у CI.