← Valtive QA Quiz quiz.valtive.io/notes/apitools valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для тестувальників, яких на співбесіді питають не «що таке REST», а «покажи, як ти це перевіриш». Тут інструменти, які QA відкриває щодня: Postman зі скриптами, Newman у CI, curl і jq у терміналі, DevTools і HAR, проксі на кшталт Charles і mitmproxy, моки WireMock і Prism, клієнти для GraphQL, gRPC і WebSocket. Теорію HTTP дивись у гайді REST API, а Java-бібліотеку — у гайді RestAssured; тут — робота руками й вибір інструмента.
For testers who get asked not «what is REST» but «show me how you would check it». These are the tools a QA engineer opens every day: Postman with scripts, Newman in CI, curl and jq in a terminal, DevTools and HAR files, proxies such as Charles and mitmproxy, WireMock and Prism mocks, and clients for GraphQL, gRPC and WebSocket. HTTP theory lives in the REST API guide and the Java library in the RestAssured guide; this one is about hands-on work and picking the right tool.
Як користуватисьHow to use it
Команди в картках запускались по-справжньому, а блоки «Вивід» скопійовані з терміналу — повтори їх і зміни один прапорець, щоб побачити різницю. Після кожної картки закрий її й розкажи відповідь уголос за блоком 🗣 Як читати вголос: на співбесіді цінують не назву прапорця, а те, як ти пояснюєш, навіщо він і що буде без нього.
The commands in the cards were really run, and the «Output» blocks are copied from the terminal — repeat them and change one flag to see the difference. After each card, close it and give the answer out loud using the 🗣 How to say it out loud block: an interviewer values not the flag's name but how you explain why it exists and what happens without it.
Три якорі — з них випливає більшість відповідейThree anchors — most answers follow from them
ЯКІР 1 · Бачити реальний запитANCHOR 1 · See the real request
Половина багів ховається в деталях, яких не видно в UI: заголовок, cookie, формат тіла, редирект. curl -v, Copy as cURL, HAR і проксі показують, що насправді пішло по мережі, — починай з цього, а не з припущень.
Half of all bugs hide in details the UI does not show: a header, a cookie, the body format, a redirect. curl -v, Copy as cURL, HAR and a proxy show what really went over the wire — start there, not with guesses.
ЯКІР 2 · Перевірка, що валить збіркуANCHOR 2 · A check that fails the build
Запит без перевірки — це демонстрація, а не тест. pm.test, jq -e, ненульовий код виходу Newman і пороги k6 перетворюють «я подивився» на сигнал, який CI помітить без людини.
A request without a check is a demo, not a test. pm.test, jq -e, Newman's non-zero exit code and k6 thresholds turn «I had a look» into a signal CI notices without a human.
ЯКІР 3 · Контракт замість здогадокANCHOR 3 · A contract instead of guesses
OpenAPI чи .proto — спільна правда для бекенду, фронтенду й тестів. З неї будуються моки, валідація відповідей і клієнти; мок без зв'язку з контрактом рано чи пізно почне брехати.
OpenAPI or .proto is the shared truth for backend, frontend and tests. Mocks, response validation and clients are built from it; a mock with no link to the contract will sooner or later start to lie.
45
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
12
інструментів запущеноtools actually run
Як повторити прикладиHow to reproduce the examples
Приклади перевірені на curl 8.22, jq 1.8, Newman 6.2, HTTPie 3.2, mitmproxy 12, Prism 5, WireMock 3.13, grpcurl 1.9, wscat 6.1, k6 2.3 і Playwright Test. Запити з curl, HTTPie і Newman йдуть до публічного httpbin.org, тож потрібен інтернет; для Prism, WireMock, gRPC і WebSocket у картках є мінімальні локальні сервери. Вивід скорочено лише там, де він повторювався, а час відповіді у твоєму запуску буде іншим.
The examples were checked with curl 8.22, jq 1.8, Newman 6.2, HTTPie 3.2, mitmproxy 12, Prism 5, WireMock 3.13, grpcurl 1.9, wscat 6.1, k6 2.3 and Playwright Test. The curl, HTTPie and Newman requests go to the public httpbin.org, so you need internet access; for Prism, WireMock, gRPC and WebSocket the cards include minimal local servers. Output is trimmed only where it repeated, and response times in your run will differ.
npm install -g newman @stoplight/prism-cli wscat
python3 -m pip install httpie mitmproxy
# curl and jq come from the system package manager; grpcurl, k6 and
# the WireMock standalone jar are single downloads from their releases
📚 7 РОЗДІЛІВ📚 7 SECTIONS
—
Junior
—
Middle
—
Senior
▶ 1 8
1 Як влаштована Postman-колекція і навіщо в ній папки, збережені приклади та налаштування на рівні колекції? How is a Postman collection organised, and why does it need folders, saved examples and collection-level settings?
PostmanPostman Junior ▶

Що це. Колекція — це не просто список закладок із запитами. Це дерево: колекція містить папки, папки — запити, а кожен рівень може мати власні змінні, скрипти й налаштування авторизації, які успадковуються вниз. До запиту можна зберегти приклади відповідей (examples) — вони стають документацією і джерелом даних для mock server.

What it is. A collection is more than a list of bookmarked requests. It is a tree: the collection holds folders, folders hold requests, and every level can carry its own variables, scripts and authorization settings that are inherited downwards. A request can store example responses, which become documentation and the data source for a mock server.

Як групувати. Зручно ділити за ресурсом чи сценарієм: auth/, orders/, окрема папка negative/ для 401, 404 і 422. Тоді Runner і Newman запускають потрібну частину через вибір папки, а спільну логіку — оновлення токена чи перевірку часу відповіді — пишуть один раз на рівні колекції.

How to group. Split by resource or by scenario: auth/, orders/, a separate negative/ folder for 401, 404 and 422. Runner and Newman can then run just the part you need by picking a folder, and shared logic such as token refresh or a response-time check is written once at collection level.

Приклад з проєкту. У команді з 150 запитами кожен тестувальник мав свою копію логіну в кожній папці. Після переносу авторизації на рівень колекції й видалення копій колекція скоротилася на третину, а зміна формату токена зайняла один рядок.

From a real project. In a team with 150 requests, every tester kept their own copy of the login in every folder. Once authorization moved to collection level and the copies were deleted, the collection shrank by a third, and a change of token format took one line.

Підводні камені. Назви на кшталт «New Request (3)» роблять звіт Newman нечитабельним — називай запит дією і очікуванням. І не змішуй у колекції запити для різних сервісів: окремий сервіс — окрема колекція зі своїм середовищем.

Pitfalls. Names like «New Request (3)» make a Newman report unreadable, so name a request by its action and expectation. And do not mix requests for different services in one collection: a separate service deserves its own collection and environment.

Структура колекції, яку легко підтримувати
A collection structure that is easy to maintain
Orders API                      auth: Bearer {{token}}   pre-request: оновити токен
├── auth
│   └── POST Login              post-response: зберегти токен
├── orders
│   ├── POST Create order       приклади: 201 створено, 422 qty = 0
│   ├── GET  Order by id        приклади: 200 оплачено, 404 не знайдено
│   └── DELETE Order
└── negative
    ├── GET  Order without token      чекає 401
    └── POST Order with qty 0         чекає 422
Orders API                      auth: Bearer {{token}}   pre-request: refresh token
├── auth
│   └── POST Login              post-response: save token
├── orders
│   ├── POST Create order       examples: 201 created, 422 qty is 0
│   ├── GET  Order by id        examples: 200 paid, 404 not found
│   └── DELETE Order
└── negative
    ├── GET  Order without token      expects 401
    └── POST Order with qty 0         expects 422
🔧 Розбір коду: Авторизація й pre-request скрипт стоять на корені дерева, тож їх бачать усі запити нижче. Приклади відповідей збережені біля запитів, до яких вони належать, а негативні сценарії зібрані в окрему папку, яку можна запускати окремо.
🔧 Code breakdown: Authorization and the pre-request script sit at the root of the tree, so every request below sees them. Example responses are saved next to the requests they belong to, and negative scenarios live in their own folder that can run on its own.
💡 Простими словами: Колекція — це шафа з полицями: що кладеш на верхню полицю, те видно на всіх нижніх.
💡 In plain words: A collection is a cupboard with shelves: whatever you put on the top shelf is visible on every shelf below.
🗣 Як читати вголос Колекцію я будую як дерево: сервіс, далі папки за ресурсами й окрема папка для негативних кейсів. Усе спільне — авторизацію, оновлення токена, базові перевірки — виношу на рівень колекції, щоб не копіювати в кожен запит. До ключових запитів зберігаю приклади відповідей, бо з них потім одразу виходить документація і mock server. І стежу за назвами: у звіті Newman має бути зрозуміло, що саме впало, без відкриття Postman.
🗣 How to say it out loud I build a collection as a tree: the service, then folders by resource and a separate folder for negative cases. Everything shared — authorization, token refresh, basic checks — goes to collection level so I never copy it into each request. For key requests I save example responses, because they give me documentation and a mock server for free. And I watch the names: a Newman report should say what failed without anyone opening Postman.
2 Які області змінних є в Postman і яка з них перемагає, коли ключ однаковий? Which variable scopes does Postman have, and which one wins when the key is the same?
PostmanPostman Junior ▶

Що це. Postman має п'ять областей: globals (на весь workspace), змінні колекції, змінні середовища, data-змінні з файлу ітерацій і локальні змінні, що живуть лише під час одного запуску. Коли ключ є в кількох місцях, перемагає найвужча область: local > data > environment > collection > globals.

What it is. Postman has five scopes: globals (for the whole workspace), collection variables, environment variables, data variables from the iteration file, and local variables that live for one run only. When a key exists in several places, the narrowest scope wins: local > data > environment > collection > globals.

Навіщо це знати. Типова загадка «запит іде не на той стенд» майже завжди пояснюється забутою змінною у вужчій області. pm.variables.get() повертає саме те значення, яке підставиться в {{baseUrl}}, тож ним зручно діагностувати, а pm.environment.get() читає лише одну область.

Why it matters. The classic mystery «the request goes to the wrong stand» is almost always a forgotten variable in a narrower scope. pm.variables.get() returns exactly the value that will replace {{baseUrl}}, which makes it a good diagnostic, while pm.environment.get() reads one scope only.

Що куди класти. Адреси, логіни тестових користувачів і все, що різниться між стендами, — у середовище. Константи самої колекції, як-от версія API чи ID тестового товару, — у змінні колекції. Проміжні значення одного прогону — у локальні. Globals краще не використовувати зовсім: їх бачать усі колекції, і вони дають найзаплутаніші конфлікти.

What goes where. URLs, test-user logins and anything that differs between stands go into the environment. Constants of the collection itself, such as an API version or a test product ID, go into collection variables. Intermediate values of a single run go into locals. Globals are best avoided altogether: every collection sees them, and they cause the most confusing conflicts.

Той самий ключ у кількох областях (pre-request скрипт)
The same key in several scopes (pre-request script)
pm.globals.set('baseUrl', 'https://global.example');
pm.collectionVariables.set('baseUrl', 'https://collection.example');
pm.environment.set('baseUrl', 'https://httpbin.org');
console.log('resolved:', pm.variables.get('baseUrl'));

pm.variables.set('baseUrl', 'http://localhost:3000'); // локальна: лише цей запуск
console.log('after local set:', pm.variables.get('baseUrl'));

pm.variables.unset('baseUrl');
console.log('local removed:', pm.variables.get('baseUrl'));
pm.globals.set('baseUrl', 'https://global.example');
pm.collectionVariables.set('baseUrl', 'https://collection.example');
pm.environment.set('baseUrl', 'https://httpbin.org');
console.log('resolved:', pm.variables.get('baseUrl'));

pm.variables.set('baseUrl', 'http://localhost:3000'); // local: this run only
console.log('after local set:', pm.variables.get('baseUrl'));

pm.variables.unset('baseUrl');
console.log('local removed:', pm.variables.get('baseUrl'));
Вивід Newman
Newman output
  │ 'resolved:', 'https://httpbin.org'
  │ 'after local set:', 'http://localhost:3000'
  │ 'local removed:', 'https://httpbin.org'
🔧 Розбір коду: Три set кладуть різні значення в globals, колекцію й середовище, а pm.variables.get показує переможця — середовище. Локальна змінна з pm.variables.set перекриває всі, а після unset знову перемагає середовище.
🔧 Code breakdown: Three set calls put different values into globals, the collection and the environment, and pm.variables.get shows the winner — the environment. A local variable from pm.variables.set overrides them all, and after unset the environment wins again.
💡 Простими словами: Питай Postman, як дитину з трьома мамами: слухається та, що стоїть найближче.
💡 In plain words: Postman is like a child with three parents: it listens to the one standing closest.
🗣 Як читати вголос У Postman п'ять областей змінних, і правило просте: вужча перемагає ширшу, тобто локальні сильніші за data, data за середовище, середовище за колекцію, а колекція за globals. Тому стенд-залежні речі я тримаю в середовищі, константи колекції — у змінних колекції, а globals майже не чіпаю. Коли запит іде не туди, першим ділом виводжу pm.variables.get для цього ключа — це саме те значення, яке підставиться в URL.
🗣 How to say it out loud Postman has five variable scopes, and the rule is simple: the narrower one wins, so locals beat data, data beats the environment, the environment beats the collection and the collection beats globals. So I keep stand-specific things in the environment, collection constants in collection variables, and I barely touch globals. When a request goes to the wrong place, the first thing I print is pm.variables.get for that key — that is exactly what ends up in the URL.
3 У якому порядку виконуються pre-request і post-response скрипти колекції, папки й запиту? In what order do the pre-request and post-response scripts of the collection, folder and request run?
PostmanPostman Middle ▶

Що це. Перед відправкою Postman виконує pre-request скрипти зверху вниз: колекція, папка, запит. Потім відправляє запит і так само зверху вниз запускає post-response (у старих версіях вкладка називалась Tests): колекція, папка, запит. Тобто в обох фазах першою йде колекція, а не сам запит.

What it is. Before sending, Postman runs pre-request scripts top down: collection, folder, request. It then sends the request and runs post-response scripts (the tab used to be called Tests) top down again: collection, folder, request. In both phases the collection goes first, not the request itself.

Що з цього випливає. Скрипт колекції бачить відповідь раніше за скрипт запиту, тож загальні перевірки (час відповіді, Content-Type, відсутність стеку помилки в тілі) логічно тримати там. Але якщо запит у своєму post-response змінює змінну, колекційний скрипт цієї зміни ще не бачить. Для pre-request навпаки: запит може перевизначити те, що підготувала колекція.

What follows. The collection script sees the response before the request script does, so shared checks (response time, Content-Type, no stack trace in the body) belong there. But if a request changes a variable in its own post-response, the collection script has not seen that change yet. For pre-request it is the other way round: a request can override what the collection prepared.

Підводний камінь. Колекційний post-response виконується для кожного запиту, включно з тими, де помилка очікувана. Перевірка «статус 2xx» на рівні колекції завалить усю папку negative/. Такі правила або роблять умовними (за назвою запиту чи прапорцем у змінній), або тримають на рівні папки.

Pitfall. The collection post-response runs for every request, including those where an error is expected. A «status is 2xx» check at collection level fails the whole negative/ folder. Such rules are either made conditional (on the request name or a flag in a variable) or kept at folder level.

Кожен рівень пише в консоль, коли спрацював
Each level logs when it runs
// колекція
console.log('1 collection pre-request');   // вкладка Pre-request
console.log('4 collection post-response'); // вкладка Post-response
// папка "orders"
console.log('2 folder pre-request');
console.log('5 folder post-response');
// запит "Get order"
console.log('3 request pre-request');
console.log('6 request post-response, status', pm.response.code);
// collection
console.log('1 collection pre-request');   // Pre-request tab
console.log('4 collection post-response'); // Post-response tab
// folder "orders"
console.log('2 folder pre-request');
console.log('5 folder post-response');
// request "Get order"
console.log('3 request pre-request');
console.log('6 request post-response, status', pm.response.code);
Вивід newman run order.postman_collection.json
Output of newman run order.postman_collection.json
❏ orders
↳ Get order
  │ '1 collection pre-request'
  │ '2 folder pre-request'
  │ '3 request pre-request'
  GET https://httpbin.org/anything/orders/42 [200 OK, 734B, 515ms]
  │ '4 collection post-response'
  │ '5 folder post-response'
  │ '6 request post-response, status', 200
🔧 Розбір коду: Шість рядків console.log розкладені по трьох рівнях, а Newman друкує консоль у тому порядку, у якому скрипти виконались. Номери в повідомленнях збігаються з реальною послідовністю.
🔧 Code breakdown: Six console.log lines are spread over three levels, and Newman prints the console in the order the scripts ran. The numbers in the messages match the real sequence.
💡 Простими словами: І до, і після запиту першим говорить найстарший у родині — колекція.
💡 In plain words: Both before and after the request the eldest in the family speaks first — the collection.
🗣 Як читати вголос Порядок такий: перед запитом pre-request колекції, потім папки, потім самого запиту; після відповіді так само — колекція, папка, запит. Я цим користуюсь: спільні перевірки, як час відповіді чи Content-Type, живуть у колекції й працюють для всіх запитів. Але пам'ятаю, що колекційна перевірка спрацює і на негативних кейсах, тож правила на кшталт «статус 2xx» тримаю на рівні папки, інакше вся папка з очікуваними 401 стане червоною.
🗣 How to say it out loud The order is: before the request, the collection pre-request, then the folder, then the request itself; after the response the same — collection, folder, request. I use that: shared checks such as response time or Content-Type live in the collection and cover every request. But I remember a collection check also fires on negative cases, so rules like «status is 2xx» stay at folder level, or the whole folder of expected 401s turns red.
4 Як писати перевірки в Postman через pm.test і pm.expect і що буде, коли перевірка падає? How do you write checks in Postman with pm.test and pm.expect, and what happens when a check fails?
PostmanPostman Junior ▶

Що це. pm.test(name, fn) реєструє іменований тест: якщо функція кидає помилку, тест червоний, інакше зелений. Усередині пишуть твердження Chai через pm.expect(value) або скорочення pm.response.to.have.status(200), to.have.header(), to.have.jsonBody(). Postman показує тести на вкладці Test Results, а Newman — у звіті й у коді виходу.

What it is. pm.test(name, fn) registers a named test: if the function throws, the test is red, otherwise green. Inside it you write Chai assertions via pm.expect(value) or shortcuts like pm.response.to.have.status(200), to.have.header(), to.have.jsonBody(). Postman shows tests on the Test Results tab, and Newman in its report and exit code.

Що перевіряти. Статус, Content-Type, ключові поля й їхні типи, бізнес-правило (сума = ціна × кількість), а не лише «прийшло 200». Для глибокого порівняння об'єктів — to.eql, бо to.equal порівнює посилання. Для чисел з плаваючою крапкою — to.be.closeTo(19.9, 0.001).

What to check. Status, Content-Type, key fields and their types, a business rule (total = price × quantity), not just «200 came back». For deep object comparison use to.eql, because to.equal compares references. For floating-point numbers use to.be.closeTo(19.9, 0.001).

Підводний камінь. Якщо pm.response.json() викликати поза pm.test, а сервер поверне HTML чи порожнє тіло, скрипт упаде з JSONError і решта тестів запиту навіть не зареєструється. Розбір тіла краще робити всередині тесту — тоді падіння буде зрозумілим тестом із назвою.

Pitfall. If you call pm.response.json() outside pm.test and the server returns HTML or an empty body, the script dies with a JSONError and the request's other tests are never registered. Parse the body inside a test, so the failure shows up as a clear, named test.

Post-response скрипт для GET /json
Post-response script for GET /json
pm.test('статус 200 і тіло JSON', () => {
  pm.response.to.have.status(200);
  pm.response.to.have.header('Content-Type', 'application/json');
});

pm.test('слайди не порожні', () => {
  const { slideshow } = pm.response.json();
  pm.expect(slideshow.slides).to.be.an('array').with.lengthOf.above(0);
});

pm.test('автор — команда QA', () => {
  pm.expect(pm.response.json().slideshow.author).to.eql('QA team');
});
pm.test('status is 200 and body is JSON', () => {
  pm.response.to.have.status(200);
  pm.response.to.have.header('Content-Type', 'application/json');
});

pm.test('slides are not empty', () => {
  const { slideshow } = pm.response.json();
  pm.expect(slideshow.slides).to.be.an('array').with.lengthOf.above(0);
});

pm.test('author is the QA team', () => {
  pm.expect(pm.response.json().slideshow.author).to.eql('QA team');
});
Вивід Newman: остання перевірка навмисно хибна
Newman output: the last check is wrong on purpose
→ Slideshow
  GET https://httpbin.org/json [200 OK, 659B, 1540ms]
  ✓  status is 200 and body is JSON
  ✓  slides are not empty
  1. author is the QA team

 #  failure         detail
 1.  AssertionError  author is the QA team
                     expected 'Yours Truly' to deeply equal 'QA team'
                     at assertion:2 in test-script
                     inside "Slideshow"
🔧 Розбір коду: Кожен pm.test — окремий рядок у звіті, тож назва має описувати очікування. to.be.an('array').with.lengthOf.above(0) — ланцюжок Chai, що перевіряє тип і довжину. Падіння показує фактичне й очікуване значення, і цього зазвичай достатньо для баг-репорту.
🔧 Code breakdown: Each pm.test is its own line in the report, so the name should state the expectation. to.be.an('array').with.lengthOf.above(0) is a Chai chain checking type and length. A failure shows the actual and the expected value, which is usually enough for a bug report.
💡 Простими словами: pm.test — це рамка з підписом, pm.expect — сама перевірка всередині рамки.
💡 In plain words: pm.test is a labelled frame, and pm.expect is the actual check inside the frame.
🗣 Як читати вголос У Postman я обгортаю кожну перевірку в pm.test з назвою, яка звучить як очікування, а всередині пишу pm.expect або скорочення на кшталт to.have.status. Перевіряю не лише 200, а й Content-Type, типи ключових полів і бізнес-правило. Для об'єктів беру eql, бо equal порівнює посилання й падає на однакових даних. І розбираю JSON усередині тесту: якщо прийде HTML, я побачу зрозумілий червоний тест, а не зламаний скрипт.
🗣 How to say it out loud In Postman I wrap each check in a pm.test whose name reads like an expectation, and inside I write pm.expect or shortcuts like to.have.status. I check not only the 200 but also Content-Type, the types of key fields and a business rule. For objects I use eql, because equal compares references and fails on identical data. And I parse JSON inside the test: if HTML comes back, I get a clear red test rather than a broken script.
5 Як передати значення з відповіді одного запиту в наступний і не залишити сміття після прогону? How do you pass a value from one response to the next request without leaving junk behind after the run?
PostmanPostman Junior ▶

Що це. Ланцюжок запитів — це коли відповідь одного запиту (ID створеного замовлення, токен, посилання на наступну сторінку) стає вхідним параметром іншого. У post-response скрипті значення зберігають у змінну, а наступний запит використовує його як {{orderId}} в URL, заголовку чи тілі.

What it is. Request chaining means one response (the ID of a created order, a token, a next-page link) becomes the input of another request. The post-response script saves the value into a variable, and the next request uses it as {{orderId}} in the URL, a header or the body.

Куди зберігати. Для проміжних значень одного прогону краще pm.collectionVariables чи локальні змінні, а не середовище: середовище спільне для всіх колекцій цього стенду і, якщо його експортують, потягне за собою старі ID. Після використання змінну прибирають через unset, щоб наступний запуск не взяв застарілий ID і не пройшов випадково.

Where to store it. For intermediate values of a single run, prefer pm.collectionVariables or locals over the environment: the environment is shared by all collections for this stand and, once exported, drags old IDs along. After use the variable is cleared with unset, so the next run does not pick up a stale ID and pass by accident.

Підводний камінь. Ланцюжок створює залежність від порядку: «Отримати замовлення» без «Створити замовлення» перед ним не працює. Для ручного дослідження це нормально, а для регресу краще, щоб кожен тест сам готував дані — про це окрема картка в розділі Newman.

Pitfall. A chain creates an order dependency: «Get order» does not work without «Create order» before it. That is fine for manual exploration, but a regression suite is better off with each test preparing its own data — a separate card in the Newman section covers that.

Create order → Get order (post-response скрипти)
Create order → Get order (post-response scripts)
// "Create order": GET https://httpbin.org/uuid замість POST /orders
pm.test('id замовлення видано', () => {
  const { uuid } = pm.response.json();
  pm.expect(uuid).to.match(/^[0-9a-f-]{36}$/);
  pm.collectionVariables.set('orderId', uuid);
});

// "Get order": GET https://httpbin.org/anything/orders/{{orderId}}
const id = pm.collectionVariables.get('orderId');
pm.test('повертається те саме замовлення', () => {
  pm.expect(pm.response.json().url).to.include(id);
});
pm.collectionVariables.unset('orderId'); // щоб наступний запуск не взяв старий id
// "Create order": GET https://httpbin.org/uuid stands in for POST /orders
pm.test('order id is issued', () => {
  const { uuid } = pm.response.json();
  pm.expect(uuid).to.match(/^[0-9a-f-]{36}$/);
  pm.collectionVariables.set('orderId', uuid);
});

// "Get order": GET https://httpbin.org/anything/orders/{{orderId}}
const id = pm.collectionVariables.get('orderId');
pm.test('the same order comes back', () => {
  pm.expect(pm.response.json().url).to.include(id);
});
pm.collectionVariables.unset('orderId'); // no stale id for the next run
Вивід Newman
Newman output
→ Create order
  GET https://httpbin.org/uuid [200 OK, 282B, 494ms]
  ✓  order id is issued

→ Get order
  GET https://httpbin.org/anything/orders/af1eafd7-a157-469f-a65d-0d00c1484cff [200 OK, 768B, 121ms]
  ✓  the same order comes back
🔧 Розбір коду: Перший скрипт перевіряє формат ID і лише тоді зберігає його, тож зламана відповідь не потрапить далі. Другий запит підставляє {{orderId}} в URL, перевіряє, що сервер отримав саме цей ID, і прибирає змінну.
🔧 Code breakdown: The first script checks the ID format and only then stores it, so a broken response never travels further. The second request puts {{orderId}} into the URL, checks the server got exactly that ID, and clears the variable.
💡 Простими словами: Ланцюжок — як естафета: паличку передають з рук у руки, а після фінішу її здають, а не лишають на доріжці.
💡 In plain words: Chaining is a relay race: the baton goes hand to hand, and after the finish it is handed in, not left on the track.
🗣 Як читати вголос Щоб передати значення далі, я в post-response скрипті беру його з відповіді, перевіряю формат і лише потім зберігаю — зазвичай у змінні колекції, а не в середовище, бо середовище спільне й живе довше за прогін. Наступний запит підставляє його через подвійні фігурні дужки. Після використання роблю unset, щоб старий ID не зробив наступний запуск випадково зеленим. І чесно кажу, що ланцюжок — це залежність від порядку, тому для регресу волію, щоб тест готував дані сам.
🗣 How to say it out loud To pass a value along, my post-response script takes it from the response, checks its format and only then stores it — usually in collection variables rather than the environment, because the environment is shared and outlives the run. The next request uses it in double curly braces. After use I unset it so an old ID never makes the next run green by accident. And I say honestly that a chain is an order dependency, so for regression I prefer each test to prepare its own data.
6 Як через pm.sendRequest отримувати токен лише тоді, коли старий ось-ось сплине? How do you use pm.sendRequest to fetch a token only when the old one is about to expire?
PostmanPostman Middle ▶

Що це. pm.sendRequest відправляє допоміжний HTTP-запит прямо зі скрипту, не створюючи окремого запиту в колекції. Типове застосування — pre-request на рівні колекції, який перед кожним запитом перевіряє, чи є валідний токен, і, якщо ні, отримує новий. Так будь-який запит чи папку можна запускати окремо, без ручного логіну.

What it is. pm.sendRequest sends a helper HTTP request straight from a script, without a separate request in the collection. The typical use is a collection-level pre-request that checks before every request whether a valid token exists and, if not, fetches a new one. That way any request or folder can run on its own, with no manual login.

Кешування. Запитувати токен перед кожним викликом — зайве навантаження на сервер авторизації і повільний прогін. Тому разом із токеном зберігають момент його закінчення й оновлюють із запасом, наприклад за 30 секунд до кінця, щоб запит не встиг отримати 401 у дорозі.

Caching. Requesting a token before every call puts useless load on the auth server and slows the run. So the expiry moment is stored with the token, and the token is renewed with a margin, say 30 seconds before the end, so a request never gets a 401 in flight.

Підводні камені. Колбек pm.sendRequest асинхронний, але Postman і Newman чекають на нього, перш ніж відправити основний запит. Помилку отримання токена треба кидати явно, інакше основний запит піде без авторизації й упаде з незрозумілим 401. І не логуй сам токен у консоль: логи CI часто доступні ширшому колу людей.

Pitfalls. The pm.sendRequest callback is asynchronous, but Postman and Newman wait for it before sending the main request. A failure to get the token must be thrown explicitly, otherwise the main request goes out unauthorised and fails with a puzzling 401. And never log the token itself: CI logs are often visible to a wider group of people.

Pre-request колекції: токен із запасом у 30 секунд
Collection pre-request: a token with a 30-second margin
const expiresAt = Number(pm.collectionVariables.get('tokenExpiresAt') || 0);
if (Date.now() < expiresAt - 30000) return; // ще дійсний 30+ с

pm.sendRequest({
  url: pm.environment.get('baseUrl') + '/anything/oauth/token',
  method: 'POST',
  header: { 'Content-Type': 'application/json' },
  body: { mode: 'raw', raw: JSON.stringify({ client_id: pm.environment.get('clientId') }) }
}, (err, res) => {
  if (err || res.code !== 200) {
    throw new Error('не вдалося отримати токен: ' + (err || res.code));
  }
  // httpbin повертає тіло назад; справжній сервер дає access_token і expires_in
  const token = 'tok-' + res.json().json.client_id;
  pm.collectionVariables.set('token', token);
  pm.collectionVariables.set('tokenExpiresAt', Date.now() + 600 * 1000);
  console.log('отримано новий токен');
});
const expiresAt = Number(pm.collectionVariables.get('tokenExpiresAt') || 0);
if (Date.now() < expiresAt - 30000) return; // still valid for 30+ s

pm.sendRequest({
  url: pm.environment.get('baseUrl') + '/anything/oauth/token',
  method: 'POST',
  header: { 'Content-Type': 'application/json' },
  body: { mode: 'raw', raw: JSON.stringify({ client_id: pm.environment.get('clientId') }) }
}, (err, res) => {
  if (err || res.code !== 200) {
    throw new Error('token request failed: ' + (err || res.code));
  }
  // httpbin echoes the body; a real server returns access_token and expires_in
  const token = 'tok-' + res.json().json.client_id;
  pm.collectionVariables.set('token', token);
  pm.collectionVariables.set('tokenExpiresAt', Date.now() + 600 * 1000);
  console.log('fetched a new token');
});
Вивід Newman: три запити, один логін
Newman output: three requests, one login
→ Profile 1
  POST https://httpbin.org/anything/oauth/token [200 OK, 864B, 747ms]
  │ 'fetched a new token'
  GET https://httpbin.org/bearer [200 OK, 285B, 113ms]
  ✓  authorised

→ Profile 2
  GET https://httpbin.org/bearer [200 OK, 285B, 114ms]
  ✓  authorised

→ Profile 3
  GET https://httpbin.org/bearer [200 OK, 285B, 113ms]
  ✓  authorised
🔧 Розбір коду: Перший рядок читає збережений час закінчення, і return виходить зі скрипту, якщо токен ще дійсний. Інакше pm.sendRequest робить POST, колбек перевіряє помилку і статус, зберігає токен і новий час. Авторизація колекції стоїть як Bearer {{token}}, тож усі запити беруть свіжий токен автоматично.
🔧 Code breakdown: The first line reads the stored expiry, and return leaves the script while the token is still valid. Otherwise pm.sendRequest makes the POST, the callback checks the error and status, then stores the token and the new expiry. The collection's auth is Bearer {{token}}, so every request picks up the fresh token automatically.
💡 Простими словами: Як перепустка на заводі: продовжуєш її, коли термін майже вийшов, а не щоранку і не після того, як охорона тебе вже не пустила.
💡 In plain words: Like a factory pass: you renew it when it is about to run out, not every morning and not after security has already turned you away.
🗣 Як читати вголос Для токенів я ставлю pre-request на рівні колекції з pm.sendRequest. Він дивиться на збережений час закінчення і ходить по новий токен лише тоді, коли до кінця лишилось менше пів хвилини, тож на сотню запитів буде один-два логіни. Помилку отримання токена кидаю явно, щоб не ганятися потім за дивними 401. Токен у лог не пишу. Головний плюс — будь-яку папку можна запустити окремо, і вона сама авторизується.
🗣 How to say it out loud For tokens I put a collection-level pre-request with pm.sendRequest. It looks at the stored expiry and fetches a new token only when less than half a minute is left, so a hundred requests cost one or two logins. I throw on a failed token request so I never chase strange 401s later. I never write the token to the log. The main win is that any folder can run on its own and authorise itself.
7 Як перевірити структуру відповіді за JSON Schema прямо в Postman? How do you validate the response structure against a JSON Schema right in Postman?
PostmanPostman Middle ▶

Що це. JSON Schema описує, які поля обов'язкові, яких вони типів, які значення допустимі. У Postman є вбудоване твердження pm.response.to.have.jsonSchema(schema), яке валідує тіло за допомогою Ajv. Старий tv4.validate застарів, і в нових колекціях його не використовують.

What it is. A JSON Schema describes which fields are required, what types they have and which values are allowed. Postman has a built-in assertion pm.response.to.have.jsonSchema(schema) that validates the body with Ajv. The old tv4.validate is deprecated and is not used in new collections.

Навіщо. Перевірки окремих полів ловлять те, про що ти подумав, а схема — те, про що не подумав: поле, що стало рядком, зниклий обов'язковий ключ, новий статус поза enum. Одна схема на ресурс покриває всі запити, які його повертають, а якщо в проєкті є OpenAPI, схему можна взяти звідти, а не писати вдруге.

Why. Checks of single fields catch what you thought of; a schema catches what you did not: a field that became a string, a missing required key, a new status outside the enum. One schema per resource covers every request that returns it, and if the project has OpenAPI, the schema can come from there instead of being written twice.

Підводні камені. Схема без required пропускає порожній об'єкт — усі поля за замовчуванням необов'язкові. А additionalProperties: false варто вмикати свідомо: він ловить витік зайвих даних (наприклад, хеш пароля у відповіді), але валить тести на кожне нове легальне поле.

Pitfalls. A schema without required lets an empty object through, because all fields are optional by default. And additionalProperties: false should be switched on deliberately: it catches leaks of extra data (such as a password hash in a response), but it fails tests on every new legitimate field.

Схема і перевірка в post-response скрипті
A schema and the check in a post-response script
const schema = {
  type: 'object',
  required: ['slideshow'],
  properties: {
    slideshow: {
      type: 'object',
      required: ['author', 'slides'],
      properties: {
        author: { type: 'string' },
        slides: { type: 'array', items: { type: 'object', required: ['title'] } }
      }
    }
  }
};
pm.test('відповідає схемі', () => pm.response.to.have.jsonSchema(schema));
const schema = {
  type: 'object',
  required: ['slideshow'],
  properties: {
    slideshow: {
      type: 'object',
      required: ['author', 'slides'],
      properties: {
        author: { type: 'string' },
        slides: { type: 'array', items: { type: 'object', required: ['title'] } }
      }
    }
  }
};
pm.test('matches the schema', () => pm.response.to.have.jsonSchema(schema));
Вивід Newman для GET https://httpbin.org/json
Newman output for GET https://httpbin.org/json
→ Slideshow
  GET https://httpbin.org/json [200 OK, 659B, 458ms]
  ✓  matches the schema
🔧 Розбір коду: required на кожному рівні робить поля обов'язковими, type фіксує тип, а items описує кожен елемент масиву. Якщо author прийде числом або зникне slides, тест упаде з повідомленням Ajv про конкретний шлях.
🔧 Code breakdown: required at each level makes fields mandatory, type pins the type, and items describes each array element. If author arrives as a number or slides disappears, the test fails with an Ajv message naming the exact path.
💡 Простими словами: Перевірка полів — це пошук конкретної помилки, схема — це трафарет, який показує будь-яку зайву чи відсутню деталь.
💡 In plain words: Field checks hunt for a specific mistake; a schema is a stencil that shows any extra or missing piece.
🗣 Як читати вголос Крім точкових перевірок полів, я валідую відповідь схемою через pm.response.to.have.jsonSchema — це Ajv усередині Postman. Схема ловить те, що я не передбачив: поле змінило тип, зник обов'язковий ключ, з'явився новий статус. Не забуваю про required, бо без нього схема пропустить навіть порожній об'єкт. Якщо є OpenAPI, беру схему звідти, щоб не мати двох джерел правди.
🗣 How to say it out loud Besides spot checks of fields I validate the response with a schema through pm.response.to.have.jsonSchema — that is Ajv inside Postman. The schema catches what I did not foresee: a field changed type, a required key vanished, a new status appeared. I never forget required, because without it the schema lets even an empty object through. If there is an OpenAPI spec, I take the schema from it so there are not two sources of truth.
8 Як налаштувати авторизацію в колекції через успадкування і де допомагає вбудований OAuth 2.0? How do you set up authorization in a collection through inheritance, and where does the built-in OAuth 2.0 helper help?
PostmanPostman Middle ▶

Що це. Вкладка Authorization є на колекції, папці й запиті. Якщо запит стоїть на Inherit auth from parent, він бере налаштування найближчого батька. Тому схему (Bearer, Basic, API Key, OAuth 2.0) задають один раз на колекції, а винятки — публічний ендпоінт чи запит з іншим користувачем — окремо на запиті через No Auth або власний токен.

What it is. The Authorization tab exists on the collection, folder and request. A request set to Inherit auth from parent uses the nearest parent's settings. So the scheme (Bearer, Basic, API Key, OAuth 2.0) is set once on the collection, and exceptions — a public endpoint or a request as another user — are set on the request with No Auth or its own token.

OAuth 2.0. Вбудований помічник отримує токен за обраним grant type (Authorization Code з PKCE, Client Credentials, Password, Implicit), показує його і може оновлювати автоматично через refresh token. Для ручного дослідження API з SSO це економить години. Для Newman у CI зазвичай простіше Client Credentials через pre-request, бо інтерактивний вхід у браузері там неможливий.

OAuth 2.0. The built-in helper gets a token with the chosen grant type (Authorization Code with PKCE, Client Credentials, Password, Implicit), shows it and can refresh it automatically with a refresh token. For manual exploration of an API behind SSO this saves hours. For Newman in CI, Client Credentials in a pre-request is usually simpler, because an interactive browser login is impossible there.

Підводний камінь. Токен, вписаний прямо в поле запиту, потрапляє в експорт колекції й у спільний workspace. Тримай його у змінній з локальним значенням, а в полі лишай лише {{token}}.

Pitfall. A token typed straight into a request field ends up in the collection export and the shared workspace. Keep it in a variable with a local value and leave only {{token}} in the field.

Фрагмент колекції: Bearer на корені, No Auth на логіні
Collection fragment: Bearer at the root, No Auth on login
{
  "info": { "name": "Users API smoke" },
  "auth": {
    "type": "bearer",
    "bearer": [{ "key": "token", "value": "{{token}}", "type": "string" }]
  },
  "item": [
    {
      "name": "Login",
      "request": { "auth": { "type": "noauth" }, "method": "POST", "url": "{{baseUrl}}/anything/login" }
    },
    {
      "name": "Profile",
      "request": { "method": "GET", "url": "{{baseUrl}}/bearer" }
    }
  ]
}
🔧 Розбір коду: Блок auth на корені застосовується до всіх запитів без власного auth: Profile його успадковує і шле Authorization: Bearer {{token}}. Login явно ставить noauth, бо токена ще немає.
🔧 Code breakdown: The root auth block applies to every request without its own auth: Profile inherits it and sends Authorization: Bearer {{token}}. Login explicitly sets noauth, because there is no token yet.
💡 Простими словами: Ключ від під'їзду один на весь будинок, а від комори — окремий, і його видають лише тим, кому треба.
💡 In plain words: One key opens the building's front door for everyone; the storeroom has its own key, given only to those who need it.
🗣 Як читати вголос Авторизацію я задаю один раз на рівні колекції, а запити лишаю на Inherit auth from parent. Винятки роблю явно: логін чи публічний ендпоінт — No Auth, запит від іншої ролі — власний токен. Для ручної роботи з SSO користуюсь вбудованим OAuth 2.0, він сам отримує й оновлює токен. Для CI беру Client Credentials у pre-request, бо браузерного входу там немає. Сам токен ніколи не вписую в поле — лише змінна.
🗣 How to say it out loud I set authorization once at collection level and leave requests on Inherit auth from parent. Exceptions are explicit: a login or public endpoint gets No Auth, a request from another role gets its own token. For manual work behind SSO I use the built-in OAuth 2.0 helper, which fetches and refreshes the token itself. For CI I use Client Credentials in a pre-request, since there is no browser login there. I never type the token into a field — only a variable.
▶ 2 6
9 Як запустити одну й ту саму колекцію з різними даними з CSV чи JSON-файлу? How do you run the same collection with different data from a CSV or JSON file?
Командна роботаTeamwork Junior ▶

Що це. Data-driven прогін: Collection Runner у Postman або newman run -d читає файл даних і робить по одній ітерації на кожен рядок CSV чи елемент JSON-масиву. Заголовки колонок стають змінними, які підставляються як {{user}} у запитах, а в скриптах читаються через pm.iterationData.get('user').

What it is. A data-driven run: Collection Runner in Postman or newman run -d reads a data file and makes one iteration per CSV row or JSON array element. Column headers become variables used as {{user}} in requests and read in scripts via pm.iterationData.get('user').

Коли це доречно. Для перевірки однієї поведінки на наборі входів: різні ролі користувачів, граничні значення суми, локалі. У файл зручно класти й очікуваний результат — наприклад, колонку expectedStatus, — тоді один тест перевіряє і позитивні, і негативні рядки.

When it fits. For checking one behaviour against a set of inputs: different user roles, amount boundaries, locales. The expected result can go into the file too — for example an expectedStatus column — so one test checks both positive and negative rows.

Підводні камені. Data-змінні сильніші за змінні середовища, тож колонка baseUrl у CSV непомітно перенаправить запити. CSV усе читає як рядки: 30 у файлі буде '30', тож для чисел і вкладених об'єктів краще JSON-файл. І одна ітерація не має залежати від іншої — інакше падіння першого рядка потягне за собою всі наступні.

Pitfalls. Data variables beat environment variables, so a baseUrl column in the CSV silently redirects the requests. CSV reads everything as strings: 30 in the file becomes '30', so JSON data files are better for numbers and nested objects. And one iteration must not depend on another, or a failing first row drags down all the rest.

users.csv
users.csv
user
ann
bob
Перевірка в запиті Profile
The check in the Profile request
pm.test('токен прийнято', () => {
  pm.response.to.have.status(200);
  const body = pm.response.json();
  pm.expect(body.authenticated).to.be.true;
  pm.expect(body.token).to.eql('tok-' + pm.iterationData.get('user'));
});
pm.test('token is accepted', () => {
  pm.response.to.have.status(200);
  const body = pm.response.json();
  pm.expect(body.authenticated).to.be.true;
  pm.expect(body.token).to.eql('tok-' + pm.iterationData.get('user'));
});
Запуск і вивід (скорочено)
The run and its output (trimmed)
$ newman run users-api.postman_collection.json -e staging.postman_environment.json -d users.csv
Iteration 1/2
→ Login
  POST https://httpbin.org/anything/login [200 OK, 839B, 502ms]
  ✓  login returns 200
→ Profile
  GET https://httpbin.org/bearer [200 OK, 280B, 119ms]
  ✓  token is accepted
Iteration 2/2
→ Login
  POST https://httpbin.org/anything/login [200 OK, 839B, 122ms]
  ✓  login returns 200
→ Profile
  GET https://httpbin.org/bearer [200 OK, 280B, 122ms]
  ✓  token is accepted
🔧 Розбір коду: CSV має одну колонку user, тож Newman робить дві ітерації. У першій {{user}} дорівнює ann, у другій — bob; тест порівнює токен із тим, що очікується саме для поточного рядка.
🔧 Code breakdown: The CSV has one user column, so Newman runs two iterations. In the first {{user}} is ann, in the second bob; the test compares the token with what is expected for the current row.
💡 Простими словами: Один рецепт, багато інгредієнтів: запит той самий, змінюється лише те, що в нього кладуть.
💡 In plain words: One recipe, many ingredients: the request stays the same, only what goes into it changes.
🗣 Як читати вголос Коли треба перевірити одну поведінку на багатьох входах, я не копіюю запит, а роблю data-driven прогін: Collection Runner або newman з прапорцем -d і файлом даних. Кожен рядок — окрема ітерація, колонки стають змінними, а в скриптах я читаю їх через pm.iterationData. Часто додаю в файл очікуваний статус, щоб негативні кейси жили поруч із позитивними. Для чисел беру JSON, бо CSV перетворює все на рядки.
🗣 How to say it out loud When I need to check one behaviour against many inputs, I do not copy the request; I do a data-driven run: Collection Runner or newman with -d and a data file. Each row is an iteration, the columns become variables, and scripts read them through pm.iterationData. I often add an expected status to the file so negative cases live next to positive ones. For numbers I pick JSON, since CSV turns everything into strings.
10 Де в Postman безпечно тримати секрети, коли колекція й середовища спільні для команди? Where is it safe to keep secrets in Postman when collections and environments are shared by the team?
Командна роботаTeamwork Middle ▶

Що це. Змінна в Postman має два значення: спільне (initial, у новіших версіях інтерфейсу — shared) і поточне (current). Спільне синхронізується в хмарний workspace, його бачать усі учасники і воно потрапляє в експорт. Поточне лишається на твоєму комп'ютері. Секрети — токени, паролі, API-ключі — кладуть лише в поточне значення, а тип змінної ставлять secret, щоб значення маскувалось на екрані.

What it is. A Postman variable has two values: a shared one (initial; newer versions of the interface may call it shared) and a current one. The shared value syncs to the cloud workspace, every member sees it and it lands in exports. The current value stays on your machine. Secrets — tokens, passwords, API keys — go only into the current value, and the variable type is set to secret so the value is masked on screen.

Сильніші варіанти. Postman Vault зберігає секрети локально й підставляє їх як {{vault:api-key}}, а в CI секрети приходять із сховища пайплайна через newman --env-var. Файли середовищ у git містять лише ключі з порожніми значеннями — як шаблон.

Stronger options. Postman Vault stores secrets locally and injects them as {{vault:api-key}}, and in CI secrets come from the pipeline's store via newman --env-var. Environment files in git hold only keys with empty values, as a template.

Як ловити витік. Простий крок у CI перевіряє, що в закомічених середовищах немає заповнених секретів, і валить збірку. Це дешевше, ніж ротація ключа після того, як його побачив весь workspace чи публічний репозиторій.

How to catch a leak. A simple CI step checks that committed environments contain no filled-in secrets and fails the build. That is cheaper than rotating a key after the whole workspace or a public repository has seen it.

prod.postman_environment.json з витоком
prod.postman_environment.json with a leak
{
  "name": "prod",
  "values": [
    { "key": "baseUrl", "value": "https://api.shop.test", "enabled": true },
    { "key": "apiKey", "value": "sk_live_51H8xQ", "type": "secret", "enabled": true },
    { "key": "token", "value": "", "type": "secret", "enabled": true }
  ]
}
Перевірка в CI і її вивід
The CI check and its output
$ jq -e '[.values[] | select(.type == "secret" or (.key | test("token|key|secret|password"; "i")))
         | select(.value != "") | .key]
         | if length > 0 then error("secrets in \(input_filename): \(join(", "))") else true end' \
    prod.postman_environment.json > /dev/null
jq: error (at prod.postman_environment.json:8): secrets in prod.postman_environment.json: apiKey
$ echo $?
5
🔧 Розбір коду: jq відбирає змінні типу secret або з «секретною» назвою, лишає лише непорожні й, якщо такі є, викликає error — jq завершується з кодом 5, і крок CI падає. Порожній token проходить, бо це шаблон.
🔧 Code breakdown: jq picks variables of type secret or with a «secret-looking» name, keeps only non-empty ones and, if any remain, calls error — jq exits with code 5 and the CI step fails. The empty token passes because it is a template.
💡 Простими словами: Спільне значення — це дошка в офісі, поточне — записник у кишені. Пароль на дошці не пишуть.
💡 In plain words: The shared value is the office whiteboard, the current value is the notebook in your pocket. Nobody writes a password on the whiteboard.
🗣 Як читати вголос Я розрізняю спільне й поточне значення змінної: перше синхронізується в workspace і йде в експорт, друге лишається в мене. Тому секрети — лише в поточному значенні, з типом secret, а ще краще у Vault. У git лежать шаблони середовищ з порожніми секретами, а в CI значення приходять із секретів пайплайна через --env-var. І ставлю в CI маленьку перевірку на jq, яка валить збірку, якщо хтось закомітив заповнений ключ.
🗣 How to say it out loud I tell the shared and current values apart: the first syncs to the workspace and goes into exports, the second stays with me. So secrets live only in the current value, typed as secret, or better in the Vault. Git holds environment templates with empty secrets, and in CI the values come from pipeline secrets through --env-var. I also add a small jq check to CI that fails the build if someone commits a filled-in key.
11 Як працює Postman mock server і як змусити його повернути потрібний приклад відповіді? How does a Postman mock server work, and how do you make it return the example response you need?
Командна роботаTeamwork Middle ▶

Що це. Mock server у Postman прив'язаний до колекції й відповідає збереженими прикладами (examples) її запитів. Він отримує власну адресу на кшталт https://<id>.mock.pstmn.io, тож фронтенд чи тестувальник можуть працювати з API, якого ще немає, або відтворити рідкісну відповідь.

What it is. A Postman mock server is tied to a collection and answers with the saved examples of its requests. It gets its own address like https://<id>.mock.pstmn.io, so a frontend or a tester can work with an API that does not exist yet, or reproduce a rare response.

Як обирається відповідь. Мок шукає приклад за методом і шляхом, а якщо прикладів кілька, його можна скерувати заголовками: x-mock-response-code: 404 обирає приклад із цим статусом, x-mock-response-name — приклад за назвою. Опційно вмикається зіставлення за заголовками й тілом, і тоді різні тіла запиту дають різні приклади.

How a response is chosen. The mock matches an example by method and path, and when there are several examples you steer it with headers: x-mock-response-code: 404 picks the example with that status, x-mock-response-name picks one by name. Header and body matching can be turned on, and then different request bodies return different examples.

Межі. Мок не має стану: POST не створить запис, який потім поверне GET. Для сценаріїв зі станом, затримок чи обривів з'єднання краще WireMock або Mockoon. А приватний мок потребує API-ключа в заголовку x-api-key — забутий ключ дає 401, який легко сплутати з багом.

Limits. The mock is stateless: a POST does not create a record that a later GET returns. For stateful scenarios, delays or dropped connections WireMock or Mockoon fit better. And a private mock needs an API key in the x-api-key header — a forgotten key gives a 401 that is easy to mistake for a bug.

Запити до моку з вибором прикладу
Requests to the mock choosing an example
MOCK=https://<mock-id>.mock.pstmn.io

# приклад за замовчуванням для GET /orders/42 (200 paid)
curl -s "$MOCK/orders/42" -H "x-api-key: $POSTMAN_API_KEY"

# збережений 404-приклад того самого запиту
curl -s "$MOCK/orders/42" -H "x-api-key: $POSTMAN_API_KEY" \
     -H "x-mock-response-code: 404"

# приклад, обраний за назвою
curl -s "$MOCK/orders/42" -H "x-api-key: $POSTMAN_API_KEY" \
     -H "x-mock-response-name: order is being refunded"
MOCK=https://<mock-id>.mock.pstmn.io

# the default example for GET /orders/42 (200 paid)
curl -s "$MOCK/orders/42" -H "x-api-key: $POSTMAN_API_KEY"

# the saved 404 example of the same request
curl -s "$MOCK/orders/42" -H "x-api-key: $POSTMAN_API_KEY" \
     -H "x-mock-response-code: 404"

# an example picked by its name
curl -s "$MOCK/orders/42" -H "x-api-key: $POSTMAN_API_KEY" \
     -H "x-mock-response-name: order is being refunded"
🔧 Розбір коду: Адреса моку й ключ беруться зі змінних, а заголовки x-mock-response-code і x-mock-response-name обирають, який збережений приклад повернути. Без них мок віддає найкращий збіг за методом і шляхом.
🔧 Code breakdown: The mock address and key come from variables, and the x-mock-response-code and x-mock-response-name headers pick which saved example to return. Without them the mock returns the best match by method and path.
💡 Простими словами: Мок — це автовідповідач: він відтворює записані повідомлення, але нічого не запам'ятовує з того, що ти йому сказав.
💡 In plain words: A mock is an answering machine: it plays back recorded messages but remembers nothing you tell it.
🗣 Як читати вголос Postman mock server я використовую, коли бекенду ще немає або треба відтворити рідкісну відповідь. Він відповідає збереженими прикладами запитів колекції, а заголовками x-mock-response-code чи x-mock-response-name я обираю конкретний приклад, наприклад 404 або довгий список. Пам'ятаю, що мок без стану: створене через POST не з'явиться в GET. Для сценаріїв зі станом, затримок і обривів з'єднання беру WireMock чи Mockoon.
🗣 How to say it out loud I use a Postman mock server when the backend does not exist yet or I need to reproduce a rare response. It answers with the collection's saved examples, and with the x-mock-response-code or x-mock-response-name headers I pick a specific one, such as a 404 or a long list. I keep in mind the mock is stateless: what a POST creates never shows up in a GET. For stateful scenarios, delays and dropped connections I reach for WireMock or Mockoon.
12 Для чого Postman Monitors і що в них варто, а що не варто запускати? What are Postman Monitors for, and what should and should not run in them?
Командна роботаTeamwork Middle ▶

Що це. Монітор запускає колекцію за розкладом (раз на 5 хвилин, годину, добу) на хмарних раннерах Postman з обраних регіонів і надсилає сповіщення, коли тест упав. По суті це синтетичний моніторинг: невелика перевірка з боку клієнта, що ключові сценарії живі.

What it is. A monitor runs a collection on a schedule (every 5 minutes, hour or day) on Postman's cloud runners from chosen regions and sends a notification when a test fails. In essence it is synthetic monitoring: a small client-side check that key flows are alive.

Що запускати. Кілька швидких сценаріїв, які не змінюють бойові дані: логін тестового акаунта, пошук, відкриття картки товару, перевірка статусу інтеграції. Пороги часу — з запасом, щоб мережевий шум не будив чергового. Повний регрес у монітор не кладуть: він створює й видаляє дані, навантажує prod і дає купу хибних тривог.

What to run. A few fast flows that do not change live data: a test-account login, a search, opening a product card, an integration status check. Time thresholds get a margin so network noise does not wake the on-call engineer. A full regression does not belong in a monitor: it creates and deletes data, loads prod and raises a pile of false alarms.

Обмеження. Хмарні раннери не бачать внутрішньої мережі: staging за VPN звідти недосяжний і дає таймаути. Тоді або відкривають доступ для статичних IP раннерів (залежить від тарифу), або запускають ту саму колекцію через Newman за розкладом у CI всередині мережі.

Limits. Cloud runners cannot see an internal network: a staging behind a VPN is unreachable and times out. Then either access is opened for the runners' static IPs (depending on the plan), or the same collection runs through Newman on a schedule in CI inside the network.

Скрипт, придатний для монітора: читає, не пише, дає контекст
A monitor-friendly script: reads, never writes, gives context
// GET {{baseUrl}}/api/products?query=keyboard з окремим акаунтом монітора
pm.test('пошук відповідає 200', () => pm.response.to.have.status(200));

pm.test('пошук повертає хоча б один товар', () => {
  const { items } = pm.response.json();
  pm.expect(items, 'результати пошуку').to.be.an('array').that.is.not.empty;
});

// поріг із запасом: тривога на справжнє сповільнення, а не на шум мережі
pm.test('пошук швидше за 3 с', () => {
  pm.expect(pm.response.responseTime).to.be.below(3000);
});

// контекст для того, хто отримає тривогу о третій ночі
console.log('region', pm.environment.get('region'), 'ms', pm.response.responseTime);
// GET {{baseUrl}}/api/products?query=keyboard with a dedicated monitor account
pm.test('search answers 200', () => pm.response.to.have.status(200));

pm.test('search returns at least one product', () => {
  const { items } = pm.response.json();
  pm.expect(items, 'search results').to.be.an('array').that.is.not.empty;
});

// generous threshold: alert on a real slowdown, not on network noise
pm.test('search under 3 s', () => {
  pm.expect(pm.response.responseTime).to.be.below(3000);
});

// context for whoever gets the alert at 3 a.m.
console.log('region', pm.environment.get('region'), 'ms', pm.response.responseTime);
🔧 Розбір коду: Запит лише читає дані й ходить окремим акаунтом, тож монітор не засмічує prod. Друге повідомлення в pm.expect(items, 'search results') з'явиться в тексті падіння, а console.log потрапить у лог прогону монітора.
🔧 Code breakdown: The request only reads data and uses a dedicated account, so the monitor does not litter prod. The second argument in pm.expect(items, 'search results') appears in the failure text, and console.log ends up in the monitor run log.
💡 Простими словами: Монітор — це датчик диму, а не пожежна інспекція: він маленький, дивиться на головне й кричить лише тоді, коли справді горить.
💡 In plain words: A monitor is a smoke detector, not a fire inspection: it is small, watches the essentials and only shouts when something is really burning.
🗣 Як читати вголос Монітор для мене — синтетичний моніторинг, а не місце для регресу. Туди йдуть кілька швидких read-only сценаріїв з окремим тестовим акаунтом: логін, пошук, картка товару, з порогами часу із запасом. Повний регрес на prod кожні п'ять хвилин — це зіпсовані дані, зайве навантаження й тривоги, на які перестануть реагувати. І пам'ятаю, що монітори біжать із хмари, тож внутрішній staging їм недоступний — там я ставлю Newman за розкладом у CI.
🗣 How to say it out loud To me a monitor is synthetic monitoring, not a home for regression. It gets a few fast read-only flows with a dedicated test account — login, search, a product card — with generous time thresholds. A full regression on prod every five minutes means corrupted data, extra load and alerts people stop reacting to. I also remember monitors run from the cloud, so an internal staging is out of their reach; there I schedule Newman in CI.
13 Що всередині експортованої колекції й середовища і як з ними працювати поза Postman? What is inside an exported collection and environment, and how do you work with them outside Postman?
Командна роботаTeamwork Middle ▶

Що це. Колекція експортується у JSON формату Collection v2.1: блок info з назвою і схемою, масив item з папками й запитами, блоки event зі скриптами (prerequest і test), auth і variable. Середовище — окремий JSON з масивом values. Саме ці файли приймає Newman і їх можна тримати в git.

What it is. A collection exports as JSON in the Collection v2.1 format: an info block with the name and schema, an item array of folders and requests, event blocks holding scripts (prerequest and test), auth and variable. An environment is a separate JSON with a values array. These are the files Newman accepts, and they can live in git.

Навіщо розбиратись у форматі. Колекцію можна аналізувати звичайними інструментами: порахувати запити без перевірок, знайти захардкоджені адреси, витягти список ендпоінтів для матриці покриття. Newman уміє брати колекцію і прямо з Postman API за UID, тож CI завжди запускає актуальну версію без ручного експорту.

Why know the format. A collection can be analysed with ordinary tools: count requests without checks, find hardcoded URLs, pull the list of endpoints for a coverage matrix. Newman can also fetch the collection straight from the Postman API by UID, so CI always runs the current version without a manual export.

Підводний камінь. Скрипти в JSON зберігаються масивом рядків, а при кожному експорті змінюються службові поля й ID, тож diff у pull request шумний і погано читається. Це одна з причин, чому команди з великими наборами переходять на Bruno або на тести в коді.

Pitfall. Scripts are stored in the JSON as arrays of lines, and every export changes service fields and IDs, so a pull-request diff is noisy and hard to read. That is one reason teams with large suites move to Bruno or to tests in code.

Фрагмент колекції: запит зі скриптом
Collection fragment: a request with a script
{
  "info": {
    "name": "Users API smoke",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "item": [
    {
      "name": "Login",
      "request": {
        "method": "POST",
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "url": "{{baseUrl}}/anything/login",
        "body": { "mode": "raw", "raw": "{\"user\": \"{{user}}\"}" }
      },
      "event": [{
        "listen": "test",
        "script": { "exec": [
          "pm.test('login returns 200', () => pm.response.to.have.status(200));",
          "pm.environment.set('token', 'tok-' + pm.response.json().json.user);"
        ] }
      }]
    }
  ]
}
Аналіз колекції через jq і запуск з Postman API
Analysing the collection with jq and running it from the Postman API
$ jq -r '.. | objects | select(has("request")) | "\(.request.method)\t\(.name)"' users-api.postman_collection.json
POST	Login
GET	Profile

$ newman run "https://api.getpostman.com/collections/$COLLECTION_UID?apikey=$POSTMAN_API_KEY" \
    -e staging.postman_environment.json
🔧 Розбір коду: Рекурсивний .. у jq обходить усе дерево, select(has("request")) лишає лише запити, незалежно від глибини папок. Другий рядок показує, що Newman приймає URL Postman API замість локального файлу.
🔧 Code breakdown: The recursive .. in jq walks the whole tree, and select(has("request")) keeps only requests, however deep the folders are. The second command shows Newman accepting a Postman API URL instead of a local file.
💡 Простими словами: Експортована колекція — той самий Postman, тільки у вигляді тексту, який читає будь-який інструмент.
💡 In plain words: An exported collection is the same Postman, just as text that any tool can read.
🗣 Як читати вголос Експорт колекції — це JSON формату v2.1: info, дерево item із запитами, event зі скриптами, auth і змінні. Середовище — окремий файл із values. Я використовую це, щоб аналізувати колекцію jq-ом: скільки запитів без перевірок, де захардкоджені адреси, який список ендпоінтів покрито. У CI Newman може брати колекцію прямо з Postman API. Мінус формату — шумні диффи, тому для великих наборів я розглядаю Bruno чи код.
🗣 How to say it out loud A collection export is v2.1 JSON: info, an item tree of requests, event blocks with scripts, auth and variables. An environment is a separate file with values. I use this to analyse a collection with jq: how many requests have no checks, where URLs are hardcoded, which endpoints are covered. In CI Newman can fetch the collection straight from the Postman API. The format's downside is noisy diffs, so for large suites I consider Bruno or code.
14 Коли регрес варто перенести з Postman у код і як це зробити без втрати покриття? When is it time to move regression from Postman into code, and how do you do it without losing coverage?
Командна роботаTeamwork Senior ▶

Сигнали. Скрипти копіюються між запитами, бо спільних функцій немає; тести залежать від порядку; діфи колекції неможливо рев'юїти; потрібні фікстури, паралелізм, генерація даних, робота з БД чи чергами. Тоді колекція стала програмою без інструментів програміста, і кожна зміна коштує дедалі дорожче.

Signals. Scripts are copied between requests because there are no shared functions; tests depend on order; collection diffs cannot be reviewed; you need fixtures, parallelism, data generation, access to a database or queues. At that point the collection has become a program without a programmer's tools, and each change costs more than the last.

Як переносити. Спершу інвентаризація: jq-ом вивантажити список запитів і перевірок, позначити, що дублюється, а що мертве. Далі переносити за ризиком, а не за алфавітом: авторизація, гроші, основні сценарії. Кожен тест у коді сам готує дані й прибирає за собою, спільне — в хелперах. Поки новий набір не покриє старий, обидва біжать у CI паралельно, а колекцію потім скорочують до набору для ручного дослідження.

How to move. Start with an inventory: pull the list of requests and checks with jq, mark what is duplicated and what is dead. Then move by risk, not alphabetically: authorization, money, core flows. Each test in code prepares its own data and cleans up; shared logic goes into helpers. Until the new suite covers the old one, both run in CI side by side, and the collection is later trimmed to a set for manual exploration.

Вибір стеку. Мова — та, яку знає команда й бекенд: Java з RestAssured поруч із Java-сервісом, TypeScript з Playwright request там, де вже є UI-тести на Playwright. Postman після переїзду лишається інструментом дослідження, а не другим джерелом правди.

Choosing the stack. The language is the one the team and the backend know: Java with RestAssured next to a Java service, TypeScript with Playwright request where UI tests on Playwright already exist. After the move Postman stays an exploration tool, not a second source of truth.

Той самий сценарій у Playwright Test (profile.spec.mjs)
The same scenario in Playwright Test (profile.spec.mjs)
import { test, expect } from '@playwright/test';

async function login(request, user) {
  const res = await request.post('/anything/login', { data: { user } });
  expect(res.ok()).toBeTruthy();
  return 'tok-' + (await res.json()).json.user;
}

test('профіль приймає свіжий токен', async ({ request }) => {
  const token = await login(request, 'ann');
  const res = await request.get('/bearer', {
    headers: { Authorization: 'Bearer ' + token },
  });
  expect(res.status()).toBe(200);
  expect(await res.json()).toMatchObject({ authenticated: true, token });
});

test('профіль відхиляє запит без токена', async ({ request }) => {
  const res = await request.get('/bearer');
  expect(res.status()).toBe(401);
});
import { test, expect } from '@playwright/test';

async function login(request, user) {
  const res = await request.post('/anything/login', { data: { user } });
  expect(res.ok()).toBeTruthy();
  return 'tok-' + (await res.json()).json.user;
}

test('profile accepts a fresh token', async ({ request }) => {
  const token = await login(request, 'ann');
  const res = await request.get('/bearer', {
    headers: { Authorization: 'Bearer ' + token },
  });
  expect(res.status()).toBe(200);
  expect(await res.json()).toMatchObject({ authenticated: true, token });
});

test('profile rejects a request without a token', async ({ request }) => {
  const res = await request.get('/bearer');
  expect(res.status()).toBe(401);
});
Вивід npx playwright test (baseURL: https://httpbin.org)
Output of npx playwright test (baseURL: https://httpbin.org)
Running 2 tests using 1 worker

  ✓  1 profile.spec.mjs:9:1 › profile accepts a fresh token (910ms)
  ✓  2 profile.spec.mjs:18:1 › profile rejects a request without a token (744ms)

  2 passed (2.2s)
Той самий перший тест на RestAssured (JUnit 5)
The same first test in RestAssured (JUnit 5)
@Test
void profileAcceptsFreshToken() {
    String token = "tok-" + given().contentType(ContentType.JSON)
            .body(Map.of("user", "ann"))
            .post("/anything/login")
            .then().statusCode(200)
            .extract().path("json.user");

    given().auth().oauth2(token)
            .get("/bearer")
            .then().statusCode(200)
            .body("authenticated", equalTo(true))
            .body("token", equalTo(token));
}
🔧 Розбір коду: Логін став функцією, яку викликає кожен тест, тож тести не залежать один від одного й можуть бігти паралельно. toMatchObject перевіряє підмножину полів, а негативний тест живе поруч із позитивним. Версія на RestAssured показує той самий сценарій для Java-стеку.
🔧 Code breakdown: Login became a function every test calls, so tests do not depend on each other and can run in parallel. toMatchObject checks a subset of fields, and the negative test lives next to the positive one. The RestAssured version shows the same scenario for a Java stack.
💡 Простими словами: Postman — блокнот для ескізів, код — креслення: ескіз швидкий, але будинок будують за кресленням.
💡 In plain words: Postman is a sketchbook and code is a blueprint: the sketch is quick, but houses are built from blueprints.
🗣 Як читати вголос Я пропоную переносити регрес у код, коли бачу скопійовані скрипти, залежність від порядку й діфи, які ніхто не може прорев'юїти. Роблю це поетапно: інвентаризую колекцію jq-ом, переношу спершу найризиковіше — авторизацію, гроші, головні сценарії — і тримаю обидва набори в CI, доки новий не покриє старий. Стек обираю під команду: RestAssured поруч із Java-сервісом або Playwright request, якщо UI вже на Playwright. Postman після цього лишається для дослідження.
🗣 How to say it out loud I propose moving regression into code when I see copied scripts, order dependence and diffs nobody can review. I do it in stages: inventory the collection with jq, move the riskiest parts first — auth, money, core flows — and keep both suites in CI until the new one covers the old. I pick the stack to suit the team: RestAssured next to a Java service, or Playwright request if the UI is already on Playwright. Postman then stays for exploration.
▶ 3 5
15 Що таке Newman, які прапорці потрібні щодня і як він повідомляє CI про падіння? What is Newman, which flags do you need every day, and how does it tell CI about a failure?
Newman і CINewman and CI Junior ▶

Що це. Newman — консольний раннер колекцій Postman на Node.js. Він бере експортовану колекцію (файл або URL), середовище, файл даних і виконує ті самі запити й скрипти, що й Postman, але без інтерфейсу. Саме так колекції потрапляють у CI і розклад.

What it is. Newman is a command-line runner for Postman collections, built on Node.js. It takes an exported collection (a file or a URL), an environment and a data file and runs the same requests and scripts as Postman, only without the UI. That is how collections get into CI and schedules.

Щоденні прапорці. -e — середовище, -d — файл даних, -n — кількість ітерацій, --folder — запустити лише папку, --env-var key=value — підставити значення без редагування файлу, --timeout-request — ліміт на один запит у мілісекундах, --bail — зупинитися на першому падінні, -k — не перевіряти TLS (лише для тестових стендів).

Everyday flags. -e — environment, -d — data file, -n — iteration count, --folder — run just one folder, --env-var key=value — inject a value without editing the file, --timeout-request — a per-request limit in milliseconds, --bail — stop at the first failure, -k — skip TLS checks (test stands only).

Код виходу. Якщо впав хоча б один тест, скрипт чи запит, Newman завершується з кодом 1, і CI вважає крок проваленим. Прапорець -x (--suppress-exit-code) і конструкція || true цей сигнал гасять — після них збірка зелена навіть на повністю червоному прогоні.

Exit code. If at least one test, script or request failed, Newman exits with code 1, and CI treats the step as failed. The -x flag (--suppress-exit-code) and the || true construct swallow that signal — after them the build is green even on a fully red run.

Типовий запуск і те, що бачить CI
A typical run and what CI sees
$ newman run checks.postman_collection.json
→ Slideshow
  GET https://httpbin.org/json [200 OK, 659B, 1540ms]
  ✓  status is 200 and body is JSON
  ✓  slides are not empty
  1. author is the QA team

 #  failure         detail
 1.  AssertionError  author is the QA team
                     expected 'Yours Truly' to deeply equal 'QA team'
$ echo $?
1
🔧 Розбір коду: Два тести пройшли, третій упав, і Newman повернув 1. Будь-яка CI-система зупиняє крок на ненульовому коді, тож окремо «парсити звіт» не потрібно.
🔧 Code breakdown: Two tests passed, the third failed, and Newman returned 1. Any CI system stops a step on a non-zero code, so there is no need to «parse the report» separately.
💡 Простими словами: Newman — це Postman без екрана: робить те саме, але так, щоб результат зрозумів робот.
💡 In plain words: Newman is Postman without a screen: it does the same work, but in a way a robot can understand.
🗣 Як читати вголос Newman — консольний раннер колекцій Postman, ним колекції запускають у CI. Щодня мені потрібні -e для середовища, -d для даних, --folder, щоб запустити частину, --env-var для секретів із CI і --timeout-request, щоб завислий запит не тримав пайплайн. Головне — код виходу: при будь-якому падінні Newman повертає 1, і збірка червоніє. Тому я завжди перевіряю, чи ніхто не дописав || true або -x, бо тоді тести є, а сигналу немає.
🗣 How to say it out loud Newman is the command-line runner for Postman collections; that is how collections run in CI. Every day I need -e for the environment, -d for data, --folder to run a part, --env-var for secrets from CI and --timeout-request so a hung request does not hold the pipeline. The key thing is the exit code: on any failure Newman returns 1 and the build goes red. So I always check nobody added || true or -x, because then the tests exist but the signal does not.
16 Які репортери є в Newman і як отримати результати у вкладці тестів CI та в HTML-звіті? Which reporters does Newman have, and how do you get results into the CI tests tab and an HTML report?
Newman і CINewman and CI Middle ▶

Що це. Репортер визначає формат результату. Вбудовані: cli (лог у консоль), json (повний машинний звіт), junit (XML для CI), progress і emojitrain. Сторонні встановлюють як npm-пакети newman-reporter-<name>; найпопулярніший — htmlextra з HTML-сторінкою, де видно запити, відповіді й падіння.

What it is. A reporter decides the result format. Built-in ones: cli (console log), json (a full machine-readable report), junit (XML for CI), progress and emojitrain. Third-party ones install as npm packages named newman-reporter-<name>; the most popular is htmlextra, an HTML page showing requests, responses and failures.

Як поєднувати. Прапорець -r cli,junit,htmlextra вмикає кілька репортерів одночасно, а шлях для кожного задається окремо: --reporter-junit-export, --reporter-htmlextra-export. JUnit XML читають Jenkins, GitLab, Azure DevOps і дії для GitHub, тож падіння видно у вкладці тестів з назвою запиту й тесту, без читання логу.

How to combine them. The -r cli,junit,htmlextra flag turns several reporters on at once, and each gets its own path: --reporter-junit-export, --reporter-htmlextra-export. Jenkins, GitLab, Azure DevOps and GitHub actions read JUnit XML, so a failure shows up in the tests tab with the request and test name, no log reading needed.

Підводний камінь. HTML-звіт htmlextra за замовчуванням містить тіла запитів і відповідей, а отже, токени й персональні дані. Для публічних артефактів його налаштовують без заголовків авторизації або не публікують зовсім.

Pitfall. The htmlextra HTML report includes request and response bodies by default, which means tokens and personal data. For public artifacts it is configured without authorization headers, or not published at all.

Команда з двома репортерами
A command with two reporters
newman run users-api.postman_collection.json -e staging.postman_environment.json -d users.csv \
  -r cli,junit --reporter-junit-export results/junit.xml
results/junit.xml після прогону
results/junit.xml after the run
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="Users API smoke" tests="4" time="0.865">
  <testsuite name="Login" timestamp="2026-09-29T14:38:20.153Z" tests="1" failures="0" errors="0" time="0.312">
    <testcase name="login returns 200" time="0.122" classname="Login"/>
  </testsuite>
  <testsuite name="Profile" timestamp="2026-09-29T14:38:20.153Z" tests="2" failures="0" errors="0" time="0.120">
    <testcase name="token is accepted" time="0.122" classname="Profile"/>
    <testcase name="answers under 2 s" time="0.122" classname="Profile"/>
  </testsuite>
</testsuites>
🔧 Розбір коду: Кожен запит став testsuite, а кожен pm.test — testcase з назвою, тож CI групує результати так само, як колекція. Консольний cli лишився для людей, що читають лог.
🔧 Code breakdown: Each request became a testsuite and each pm.test a testcase with its name, so CI groups results the same way the collection does. The console cli reporter stays for people reading the log.
💡 Простими словами: Репортер — це перекладач: той самий прогін він розповідає консолі, CI і людині з браузером різними мовами.
💡 In plain words: A reporter is a translator: it tells the same run to the console, to CI and to a person in a browser, each in their own language.
🗣 Як читати вголос Я вмикаю кілька репортерів одночасно: cli для логу, junit для CI і, якщо команда хоче, htmlextra для зручного перегляду. JUnit XML потрібен, щоб падіння було видно у вкладці тестів з назвою запиту й перевірки, а не десь у тисячі рядків логу. Для кожного репортера задаю окремий шлях експорту й зберігаю їх як артефакти. HTML-звіт публікую обережно, бо там за замовчуванням тіла відповідей і токени.
🗣 How to say it out loud I turn on several reporters at once: cli for the log, junit for CI and, if the team wants it, htmlextra for comfortable browsing. JUnit XML is there so a failure shows in the tests tab with the request and check name, not somewhere in a thousand lines of log. Each reporter gets its own export path, and I keep them as artifacts. I publish the HTML report carefully, because by default it holds response bodies and tokens.
17 Як виглядає надійний крок Newman у GitHub Actions із секретами, звітом і розкладом? What does a reliable Newman step in GitHub Actions look like, with secrets, a report and a schedule?
Newman і CINewman and CI Middle ▶

Що це. Workflow з одним job: checkout, Node.js, установка Newman, запуск колекції і збереження звітів. Секрети приходять з secrets репозиторію через --env-var, тож у git лежить лише шаблон середовища. GitHub маскує значення секретів у логах.

What it is. A workflow with one job: checkout, Node.js, installing Newman, running the collection and keeping the reports. Secrets come from the repository's secrets via --env-var, so git holds only an environment template. GitHub masks secret values in the logs.

Деталі, що рятують. timeout-minutes на job і --timeout-request на запит, щоб завислий стенд не з'їв годину раннера. if: always() на кроці зі звітом, бо без нього артефакт не збережеться саме тоді, коли він найпотрібніший, — після падіння. Версію Newman краще зафіксувати в package.json або в команді установки, щоб оновлення не змінило поведінку непомітно.

Details that save you. timeout-minutes on the job and --timeout-request per request, so a hung stand does not eat an hour of runner time. if: always() on the report step, because without it the artifact is not saved exactly when it matters most — after a failure. Pin the Newman version in package.json or in the install command, so an upgrade does not change behaviour silently.

Коли запускати. Після деплою на стенд (через workflow_run чи виклик із пайплайна деплою), за розкладом через schedule для нічного прогону і вручну через workflow_dispatch. Прогін на кожен push до гілки часто марний: код ще не розгорнуто, і тести перевіряють стару версію.

When to run. After a deploy to the stand (via workflow_run or a call from the deploy pipeline), on a schedule via schedule for a nightly run, and by hand via workflow_dispatch. Running on every push to a branch is often pointless: the code is not deployed yet, so the tests check the old version.

.github/workflows/api-smoke.yml
.github/workflows/api-smoke.yml
name: api-smoke
on:
  workflow_dispatch:
  schedule:
    - cron: '0 5 * * 1-5'   # 05:00 UTC у будні
jobs:
  newman:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - name: Install Newman
        run: npm install -g newman@6
      - name: Run the API smoke on staging
        run: >
          newman run collections/users-api.postman_collection.json
          -e environments/staging.postman_environment.json
          --env-var "apiKey=${{ secrets.STAGING_API_KEY }}"
          --timeout-request 10000
          -r cli,junit --reporter-junit-export results/junit.xml
      - name: Keep the report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: newman-report
          path: results/
name: api-smoke
on:
  workflow_dispatch:
  schedule:
    - cron: '0 5 * * 1-5'   # 05:00 UTC on weekdays
jobs:
  newman:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - name: Install Newman
        run: npm install -g newman@6
      - name: Run the API smoke on staging
        run: >
          newman run collections/users-api.postman_collection.json
          -e environments/staging.postman_environment.json
          --env-var "apiKey=${{ secrets.STAGING_API_KEY }}"
          --timeout-request 10000
          -r cli,junit --reporter-junit-export results/junit.xml
      - name: Keep the report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: newman-report
          path: results/
🔧 Розбір коду: run: > — складений YAML-рядок, тож довга команда читається по рядку на прапорець. Секрет потрапляє в Newman через --env-var і перекриває порожнє значення з файлу середовища. if: always() змушує крок зі звітом виконатися навіть після червоного прогону.
🔧 Code breakdown: run: > is a folded YAML string, so a long command reads one flag per line. The secret reaches Newman via --env-var and overrides the empty value from the environment file. if: always() makes the report step run even after a red run.
💡 Простими словами: CI-крок — це нічний сторож: він приходить за розкладом, працює без людини, а записи в журналі лишає навіть тоді, коли щось зламалось.
💡 In plain words: A CI step is a night watchman: it comes on schedule, works without people, and writes in the log book even when something broke.
🗣 Як читати вголос У GitHub Actions я роблю окремий workflow для Newman: checkout, Node, установка зафіксованої версії, запуск і збереження звіту. Секрети беру з secrets і передаю через --env-var, у репозиторії лише шаблон середовища. Обов'язково ставлю таймаут на job і на запит, а для кроку зі звітом — if: always(), бо звіт найпотрібніший саме після падіння. Запускаю після деплою, вночі за розкладом і вручну, а не на кожен push, коли код ще не розгорнутий.
🗣 How to say it out loud In GitHub Actions I create a separate workflow for Newman: checkout, Node, installing a pinned version, the run and keeping the report. Secrets come from secrets and go in via --env-var; the repository holds only an environment template. I always set a timeout on the job and on each request, and if: always() on the report step, because the report matters most right after a failure. I run it after a deploy, nightly on a schedule and by hand — not on every push, when the code is not deployed yet.
18 Набір у Newman падає то тут, то там: як прибрати залежність від порядку й асинхронності? A Newman suite fails here and there: how do you remove the dependence on order and asynchrony?
Newman і CINewman and CI Senior ▶

Діагноз. Найчастіші причини нестабільності колекції: запит бере дані, які поклав попередній (запуск окремої папки ламає все); кілька наборів пишуть в одне середовище; тест перевіряє асинхронний результат одразу після POST; тестові дані неунікальні, і паралельний прогін отримує 409. Рідше — справжні таймаути стенду.

Diagnosis. The most common causes of a flaky collection: a request uses data a previous one stored (running a single folder breaks everything); several suites write into one environment; a test checks an asynchronous result right after a POST; test data is not unique, and a parallel run gets a 409. Less often — real stand timeouts.

Лікування. Кожен запит самодостатній: токен і потрібні дані готує pre-request через pm.sendRequest. Проміжні значення — у змінних колекції, а не в спільному середовищі. Унікальні дані — через динамічні змінні {{$guid}} чи {{$timestamp}}. Асинхронні результати — опитуванням з інтервалом і дедлайном замість фіксованої паузи.

Treatment. Every request is self-sufficient: the token and required data are prepared in a pre-request via pm.sendRequest. Intermediate values live in collection variables, not in a shared environment. Unique data comes from dynamic variables {{$guid}} or {{$timestamp}}. Asynchronous results are polled with an interval and a deadline instead of a fixed pause.

Компроміс. Повтор усього прогону «доки не пройде» робить набір зеленим і марним водночас: справжня гонка в продукті ховається серед шуму. Якщо колекція потребує таких милиць у більшості запитів, це сигнал переносити регрес у код, де ретраї, фікстури й ізоляція — звичайні інструменти.

Trade-off. Rerunning the whole suite «until it passes» makes it green and useless at the same time: a real race in the product hides in the noise. If a collection needs such crutches in most requests, it is a signal to move the regression into code, where retries, fixtures and isolation are ordinary tools.

Симптом: папка Profile окремо, токена немає
The symptom: the Profile folder alone, no token
$ newman run users-api.postman_collection.json -e staging.postman_environment.json \
    -d users.csv -n 1 --folder Profile
→ Profile
  GET https://httpbin.org/bearer [401 UNAUTHORIZED, 272B, 523ms]
  1⠄ JSONError in test-script

 1.  JSONError   No data, empty input at 1:1
                 at test-script inside "Profile"
$ echo $?
1
Pre-request запиту Get order: опитування з дедлайном
Pre-request of Get order: polling with a deadline
const url = pm.environment.get('baseUrl') + '/orders/' + pm.collectionVariables.get('orderId');
const deadline = Date.now() + 10000; // черзі даємо щонайбільше 10 с

function poll() {
  pm.sendRequest(url, (err, res) => {
    if (!err && res.code === 200) {
      console.log('замовлення видно через', 10000 - (deadline - Date.now()), 'ms');
      return;
    }
    if (Date.now() > deadline) {
      throw new Error('замовлення не видно й через 10 с, останній статус ' + (err || res.code));
    }
    setTimeout(poll, 500);
  });
}
poll();
const url = pm.environment.get('baseUrl') + '/orders/' + pm.collectionVariables.get('orderId');
const deadline = Date.now() + 10000; // give the queue 10 s at most

function poll() {
  pm.sendRequest(url, (err, res) => {
    if (!err && res.code === 200) {
      console.log('order visible after', 10000 - (deadline - Date.now()), 'ms');
      return;
    }
    if (Date.now() > deadline) {
      throw new Error('order not visible after 10 s, last status ' + (err || res.code));
    }
    setTimeout(poll, 500);
  });
}
poll();
Вивід проти локального сервісу, де замовлення з'являється через 2,5 с
Output against a local service where the order appears after 2.5 s
→ Create order
  POST http://127.0.0.1:5056/orders [202 Accepted, 181B, 22ms]
  ✓  order accepted

→ Get order
  GET http://127.0.0.1:5056/orders/2 [404 Not Found, 173B, 3ms]
  GET http://127.0.0.1:5056/orders/2 [404 Not Found, 173B, 2ms]
  GET http://127.0.0.1:5056/orders/2 [404 Not Found, 173B, 2ms]
  GET http://127.0.0.1:5056/orders/2 [404 Not Found, 173B, 3ms]
  GET http://127.0.0.1:5056/orders/2 [404 Not Found, 173B, 2ms]
  GET http://127.0.0.1:5056/orders/2 [200 OK, 174B, 2ms]
  │ 'order visible after', 2536, 'ms'
  GET http://127.0.0.1:5056/orders/2 [200 OK, 174B, 4ms]
  ✓  order is created
🔧 Розбір коду: Перший вивід показує залежність від порядку: без логіну токен порожній, сервер відповідає 401, а розбір JSON поза pm.test ламає скрипт. Другий блок опитує ендпоінт кожні 500 мс і кидає зрозумілу помилку після 10 с; основний запит іде лише тоді, коли замовлення вже видно.
🔧 Code breakdown: The first output shows the order dependence: without the login the token is empty, the server answers 401, and parsing JSON outside pm.test breaks the script. The second block polls the endpoint every 500 ms and throws a clear error after 10 s; the main request is sent only once the order is visible.
💡 Простими словами: Тест, що працює лише після сусіда, — як ключ, який лежить під чужим килимком: поки сусід удома, двері відчиняються.
💡 In plain words: A test that works only after its neighbour is like a key under someone else's doormat: the door opens only while the neighbour is at home.
🗣 Як читати вголос Коли набір у Newman нестабільний, я спершу шукаю залежність від порядку: запускаю кожну папку окремо, і те, що падає, покладалось на попередній запит. Лікую самодостатністю: токен і дані готує pre-request через pm.sendRequest, унікальні значення дають динамічні змінні, а проміжні — живуть у змінних колекції, а не в спільному середовищі. Для асинхронних результатів ставлю опитування з дедлайном замість sleep. Перезапуск прогону до зеленого не приймаю, а якщо милиць забагато — пропоную переїзд у код.
🗣 How to say it out loud When a Newman suite is flaky, I first look for order dependence: I run each folder alone, and whatever fails relied on an earlier request. The cure is self-sufficiency: a pre-request with pm.sendRequest prepares the token and data, dynamic variables give unique values, and intermediate values live in collection variables rather than a shared environment. For asynchronous results I poll with a deadline instead of sleeping. I do not accept rerunning until green, and if the crutches pile up, I propose moving to code.
19 Чим Bruno та Insomnia відрізняються від Postman і коли їх обирати? How do Bruno and Insomnia differ from Postman, and when do you choose them?
Newman і CINewman and CI Middle ▶

Bruno. Відкритий офлайн-клієнт, у якому колекція — це тека з текстовими файлами .bru, по файлу на запит, плюс середовища в environments/. Колекція живе в тому самому git-репозиторії, що й код, рев'ю йде звичайним pull request, а CLI bru run запускає її в CI. Хмарної синхронізації немає взагалі, і для команд із суворими правилами щодо даних це головний аргумент.

Bruno. An open-source offline client where a collection is a folder of .bru text files, one per request, plus environments in environments/. The collection lives in the same git repository as the code, review goes through an ordinary pull request, and the bru run CLI runs it in CI. There is no cloud sync at all, which is the main argument for teams with strict data rules.

Insomnia. Клієнт від Kong з акуратним інтерфейсом, сильною підтримкою GraphQL і gRPC, дизайном OpenAPI-специфікацій і CLI inso. Вміє зберігати проєкти локально, у хмарі або синхронізувати з git, тож модель зберігання обирають під політику команди.

Insomnia. A client from Kong with a clean interface, strong GraphQL and gRPC support, OpenAPI spec design and the inso CLI. It can store projects locally, in the cloud or synced with git, so the storage model is chosen to fit the team's policy.

Компроміси. Postman дає найбільшу екосистему: монітори, mock server, спільні workspace, документацію, публічну мережу API. Bruno виграє там, де колекція має бути кодом у репозиторії, але монітори й хмарні моки доведеться замінити CI і WireMock. Обидва інструменти імпортують колекції Postman, тож перехід не з нуля, хоча складні скрипти доводиться перевіряти вручну.

Trade-offs. Postman offers the biggest ecosystem: monitors, mock servers, shared workspaces, documentation, a public API network. Bruno wins where the collection must be code in the repository, but monitors and cloud mocks have to be replaced by CI and WireMock. Both tools import Postman collections, so a move does not start from zero, though complex scripts need checking by hand.

profile.bru — один запит як текстовий файл
profile.bru — one request as a text file
meta {
  name: Profile
  type: http
  seq: 2
}

get {
  url: {{baseUrl}}/bearer
  body: none
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
  res.body.authenticated: eq true
}
Вивід bru run --env staging (Bruno CLI 4.2)
Output of bru run --env staging (Bruno CLI 4.2)
login (200 OK) - 544 ms
Tests
   ✓ login returns 200
profile (200 OK) - 469 ms
Assertions
   ✓ res.status: eq 200
   ✓ res.body.authenticated: eq true

📊 Execution Summary
│ Status        │    ✓ PASS    │
│ Requests      │ 2 (2 Passed) │
│ Tests         │     1/1      │
│ Assertions    │     2/2      │
🔧 Розбір коду: Файл .bru читається без інструмента: метадані, метод з URL, авторизація і декларативні assert. Токен кладе в змінну post-response скрипт логіну (bru.setVar), а зміна перевірки в pull request — це diff на один рядок.
🔧 Code breakdown: A .bru file is readable without any tool: metadata, the method and URL, authorization and declarative assert lines. The login's post-response script stores the token in a variable (bru.setVar), and changing a check in a pull request is a one-line diff.
💡 Простими словами: Postman — це спільний хмарний диск, Bruno — тека в репозиторії поруч із кодом.
💡 In plain words: Postman is a shared cloud drive; Bruno is a folder in the repository next to the code.
🗣 Як читати вголос Я дивлюсь на модель зберігання. Postman — хмарна синхронізація й найбагатша екосистема: монітори, моки, спільні workspace. Bruno тримає кожен запит текстовим файлом у git, тож колекція проходить рев'ю разом із кодом і запускається bru run у CI, але моніторів і хмарних моків там немає. Insomnia посередині: зручний для GraphQL і gRPC, зберігає локально, у хмарі або в git. Якщо політика забороняє хмару чи команда хоче колекцію як код, я обираю Bruno.
🗣 How to say it out loud I look at the storage model. Postman means cloud sync and the richest ecosystem: monitors, mocks, shared workspaces. Bruno keeps each request as a text file in git, so the collection is reviewed with the code and runs via bru run in CI, but there are no monitors or cloud mocks. Insomnia sits in between: handy for GraphQL and gRPC, storing locally, in the cloud or in git. If policy forbids the cloud or the team wants the collection as code, I pick Bruno.
▶ 4 7
20 Які прапорці curl потрібні, щоб побачити статус, заголовки й повний обмін із сервером? Which curl flags do you need to see the status, the headers and the full exchange with the server?
Командний рядокCommand line Junior ▶

Що це. За замовчуванням curl друкує лише тіло відповіді. -i додає статус і заголовки відповіді перед тілом. -v показує весь діалог: рядки з > — що пішло на сервер, з < — що прийшло, з * — з'єднання, TLS і перевірка сертифіката. -I відправляє HEAD і показує лише заголовки, але сервер може поводитися на HEAD інакше, ніж на GET.

What it is. By default curl prints only the response body. -i adds the status and response headers before the body. -v shows the whole dialogue: lines with > are what went to the server, < what came back, * the connection, TLS and certificate checks. -I sends HEAD and shows only headers, but a server may behave differently on HEAD than on GET.

Допоміжні прапорці. -s прибирає індикатор прогресу, -S разом із -s все ж показує помилки, -o file пише тіло у файл, -X задає метод, -H додає заголовок. -X потрібен рідко: -d і так робить POST, а для GET нічого вказувати не треба.

Helper flags. -s hides the progress meter, -S together with -s still shows errors, -o file writes the body to a file, -X sets the method, -H adds a header. -X is rarely needed: -d already makes a POST, and GET needs nothing at all.

Для баг-репорту. Вивід -v — найкращий доказ «що саме я відправив»: у ньому видно Host, Content-Type, Authorization і версію протоколу. Перед тим як вставити його в тікет, заміни токени й cookie на заглушки.

For a bug report. -v output is the best proof of «what exactly I sent»: it shows Host, Content-Type, Authorization and the protocol version. Before pasting it into a ticket, replace tokens and cookies with dummies.

-i: статус і заголовки відповіді
-i: status and response headers
$ curl -s -i https://httpbin.org/status/201
HTTP/2 201
date: Tue, 29 Sep 2026 14:37:20 GMT
content-type: text/html; charset=utf-8
content-length: 0
server: gunicorn/19.9.0
access-control-allow-origin: *
-v: запит і відповідь (рядки з > та <)
-v: request and response (lines with > and <)
$ curl -s -v -o /dev/null -H "Accept: application/json" https://httpbin.org/get 2>&1 | grep -E "^[<>]"
> GET /get HTTP/2
> Host: httpbin.org
> User-Agent: curl/8.22.0
> Accept: application/json
>
< HTTP/2 200
< content-type: application/json
< content-length: 268
< server: gunicorn/19.9.0
🔧 Розбір коду: -v пише діагностику в stderr, тож 2>&1 переводить її в stdout, а grep лишає лише обмін заголовками. -o /dev/null відкидає тіло, щоб воно не змішувалось із заголовками.
🔧 Code breakdown: -v writes diagnostics to stderr, so 2>&1 moves them into stdout and grep keeps just the header exchange. -o /dev/null discards the body so it does not mix with the headers.
💡 Простими словами: -i показує конверт відповіді, -v — ще й копію твого листа та штамп пошти.
💡 In plain words: -i shows the reply's envelope; -v also shows a copy of your own letter and the post office stamp.
🗣 Як читати вголос Коли розбираюся з API у терміналі, спершу додаю -i, щоб бачити статус і заголовки відповіді, а якщо підозрюю, що проблема в самому запиті, — -v. Там видно, які заголовки справді пішли на сервер, яка версія HTTP і як пройшов TLS. -X ставлю рідко, бо -d уже робить POST. Вивід -v часто вставляю в баг-репорт як доказ, але перед цим прибираю з нього токени й cookie.
🗣 How to say it out loud When I dig into an API in a terminal, I first add -i to see the status and response headers, and if I suspect the request itself, -v. It shows which headers really went to the server, the HTTP version and how TLS went. I rarely use -X, since -d already makes a POST. I often paste -v output into a bug report as evidence, but I strip tokens and cookies first.
21 Чим відрізняються -d, --data-binary, --json і -F у curl і яке тіло кожен із них відправляє? How do -d, --data-binary, --json and -F differ in curl, and what body does each one send?
Командний рядокCommand line Middle ▶

-d. Відправляє POST із Content-Type application/x-www-form-urlencoded. Якщо передати JSON без заголовка, сервер побачить форму з одним дивним ключем. -d @file читає файл, але викидає з нього переноси рядків — NDJSON чи CSV злипнуться в один рядок.

-d. Sends a POST with Content-Type application/x-www-form-urlencoded. Pass JSON without a header and the server sees a form with one odd key. -d @file reads a file but drops its line breaks — NDJSON or CSV collapse into one line.

--data-binary і --json. --data-binary @file шле вміст байт у байт — для NDJSON, XML з форматуванням і бінарних даних. --json (curl 7.82+) — скорочення, що ставить і тіло, і заголовки Content-Type: application/json та Accept: application/json.

--data-binary and --json. --data-binary @file sends the content byte for byte — for NDJSON, formatted XML and binary data. --json (curl 7.82+) is a shortcut that sets the body plus the Content-Type: application/json and Accept: application/json headers.

-F. Формує multipart/form-data: -F "file=@avatar.png;type=image/png" додає файл як частину форми, -F userId=42 — звичайне поле. Boundary curl генерує сам, тож вручну Content-Type для multipart ставити не треба — власний заголовок без boundary якраз ламає розбір.

-F. Builds multipart/form-data: -F "file=@avatar.png;type=image/png" adds a file as a form part, -F userId=42 a plain field. curl generates the boundary itself, so do not set a multipart Content-Type by hand — your own header without a boundary is exactly what breaks parsing.

JSON через -d без заголовка і через --json
JSON via -d without a header, and via --json
$ curl -s -X POST https://httpbin.org/post -d '{"name":"Ann"}' | jq -c '{ct: .headers["Content-Type"], form, json}'
{"ct":"application/x-www-form-urlencoded","form":{"{\"name\":\"Ann\"}":""},"json":null}

$ curl -s --json '{"name":"Ann"}' https://httpbin.org/post | jq -c '{ct: .headers["Content-Type"], accept: .headers.Accept, json}'
{"ct":"application/json","accept":"application/json","json":{"name":"Ann"}}
NDJSON-файл: -d проти --data-binary
An NDJSON file: -d versus --data-binary
$ printf '{"id":1}\n{"id":2}\n{"id":3}\n' > events.ndjson
$ curl -s -d @events.ndjson -H "Content-Type: application/x-ndjson" https://httpbin.org/post | jq -r .data
{"id":1}{"id":2}{"id":3}
$ curl -s --data-binary @events.ndjson -H "Content-Type: application/x-ndjson" https://httpbin.org/post | jq -r .data
{"id":1}
{"id":2}
{"id":3}
Multipart із файлом і полем
Multipart with a file and a field
$ curl -s -F "file=@avatar.png;type=image/png" -F "userId=42" https://httpbin.org/post \
    | jq -c '{form, files: (.files | keys), ct: (.headers["Content-Type"] | split(";")[0])}'
{"form":{"userId":"42"},"files":["file"],"ct":"multipart/form-data"}
🔧 Розбір коду: httpbin повертає те, що отримав, тож видно різницю: з -d JSON перетворився на ключ форми, з --json розпізнаний як JSON. -d @file з'їв переноси, --data-binary їх зберіг. У multipart файл потрапив у files, а поле — у form.
🔧 Code breakdown: httpbin echoes what it received, so the difference is visible: with -d the JSON became a form key, with --json it was recognised as JSON. -d @file ate the line breaks, --data-binary kept them. In multipart the file went to files and the field to form.
💡 Простими словами: -d — анкета, --json — лист у конверті з написом «JSON», --data-binary — посилка без розпакування, -F — коробка з кількома відділеннями.
💡 In plain words: -d is a form, --json a letter in an envelope marked «JSON», --data-binary a parcel nobody unpacks, -F a box with several compartments.
🗣 Як читати вголос Для тіла в curl я обираю прапорець за форматом. -d — це форма urlencoded, тому для JSON або додаю Content-Type, або одразу пишу --json, який ставить і тіло, і заголовки. Файл, де важливий кожен байт, як NDJSON, шлю через --data-binary, бо -d викидає переноси рядків. Для завантаження файлів — -F, і тут я не ставлю Content-Type вручну, бо boundary curl генерує сам. Перевіряю все на httpbin, який повертає отримане тіло.
🗣 How to say it out loud For a body in curl I pick the flag by format. -d is an urlencoded form, so for JSON I either add Content-Type or just use --json, which sets both body and headers. A file where every byte matters, like NDJSON, goes via --data-binary, because -d drops line breaks. For uploads it is -F, and I never set Content-Type by hand there, because curl generates the boundary. I check everything against httpbin, which echoes the body it got.
22 Як у curl передати Basic чи Bearer авторизацію, пройти за редиректом і що робити із самопідписаним сертифікатом? How do you pass Basic or Bearer auth in curl, follow a redirect, and deal with a self-signed certificate?
Командний рядокCommand line Junior ▶

Авторизація. -u user:password будує заголовок Authorization: Basic з base64; якщо пароль не вказати, curl запитає його інтерактивно й він не потрапить в історію shell. Для токенів пишуть заголовок явно: -H "Authorization: Bearer $TOKEN". Токен краще тримати у змінній середовища, а не в тексті команди.

Authorization. -u user:password builds an Authorization: Basic header with base64; leave the password out and curl asks for it interactively, so it stays out of the shell history. Tokens are sent with an explicit header: -H "Authorization: Bearer $TOKEN". Keep the token in an environment variable rather than in the command text.

Редиректи. curl не йде за 3xx сам: без -L ти бачиш відповідь 301 чи 302 і порожнє тіло. З -L він іде за Location, а %{num_redirects} і %{url_effective} у -w показують, скільки було стрибків і де скінчилося. Редирект http на https чи на сторінку логіну сам може бути багом.

Redirects. curl does not follow 3xx by itself: without -L you see the 301 or 302 and an empty body. With -L it follows Location, and %{num_redirects} and %{url_effective} in -w show how many hops there were and where it ended. A redirect from https to http, or to a login page, can itself be a bug.

TLS. Самопідписаний сертифікат дає помилку 60. -k вимикає перевірку зовсім — допустимо для локального стенду, небезпечно для скриптів, що ходять у prod. Правильніше передати кореневий сертифікат стенду через --cacert ca.pem: з'єднання буде перевіреним, просто з іншим довіреним центром.

TLS. A self-signed certificate gives error 60. -k turns checking off completely — acceptable for a local stand, dangerous for scripts that talk to prod. Better to pass the stand's root certificate with --cacert ca.pem: the connection stays verified, just against a different trusted authority.

Basic, Bearer, редирект і TLS на httpbin.org і badssl.com
Basic, Bearer, a redirect and TLS on httpbin.org and badssl.com
$ curl -s -o /dev/null -w "%{http_code}\n" -u qa:secret https://httpbin.org/basic-auth/qa/secret
200
$ curl -s -o /dev/null -w "%{http_code}\n" -u qa:wrong https://httpbin.org/basic-auth/qa/secret
401
$ curl -s -H "Authorization: Bearer abc123" https://httpbin.org/bearer
{
  "authenticated": true,
  "token": "abc123"
}
$ curl -s -o /dev/null -w "%{http_code}\n" "https://httpbin.org/redirect-to?url=/get&status_code=301"
301
$ curl -s -L -o /dev/null -w "%{http_code} redirects=%{num_redirects} final=%{url_effective}\n" \
    "https://httpbin.org/redirect-to?url=/get&status_code=301"
200 redirects=1 final=https://httpbin.org/get
$ curl -sS -o /dev/null https://self-signed.badssl.com/
curl: (60) SSL certificate OpenSSL verify result: self-signed certificate (18)
$ curl -s -k -o /dev/null -w "%{http_code}\n" https://self-signed.badssl.com/
200
🔧 Розбір коду: -w "%{http_code}" друкує лише статус, тож видно різницю між правильним і хибним паролем. Без -L редирект зупиняється на 301, з ним — доходить до 200. Помилка 60 — це відмова перевірити сертифікат; -k її прибирає, але й прибирає захист.
🔧 Code breakdown: -w "%{http_code}" prints only the status, which shows the difference between the right and the wrong password. Without -L the redirect stops at 301; with it the request reaches 200. Error 60 is a refused certificate check; -k removes it, and the protection along with it.
💡 Простими словами: -u — показати перепустку, -L — піти за вказівником, -k — зайти, не глянувши, хто відчинив двері.
💡 In plain words: -u is showing your pass, -L is following the sign, -k is walking in without looking at who opened the door.
🗣 Як читати вголос Basic-авторизацію я передаю через -u, а токени — явним заголовком Authorization: Bearer зі змінної середовища, щоб вони не лишились в історії. Якщо бачу 301 з порожнім тілом, додаю -L і дивлюсь url_effective — куди насправді веде редирект, бо стрибок на http чи на логін буває багом. Для самопідписаного сертифіката на стенді -k допустимий, але в скриптах краще --cacert із кореневим сертифікатом стенду, щоб не вимикати перевірку зовсім.
🗣 How to say it out loud I pass Basic auth with -u, and tokens as an explicit Authorization: Bearer header from an environment variable so they stay out of the history. If I see a 301 with an empty body, I add -L and look at url_effective to see where the redirect really goes, since a hop to http or to a login page can be a bug. For a self-signed certificate on a stand -k is acceptable, but in scripts I prefer --cacert with the stand's root certificate rather than turning verification off.
23 Як через curl -w розкласти час запиту на DNS, з'єднання, TLS і очікування відповіді? How do you use curl -w to split a request's time into DNS, connection, TLS and waiting for the response?
Командний рядокCommand line Middle ▶

Що це. -w (write-out) друкує після запиту змінні з метриками. Для часу головні: time_namelookup (DNS), time_connect (TCP), time_appconnect (TLS), time_starttransfer (перший байт відповіді, TTFB) і time_total. Усі значення накопичувальні, від старту запиту, тож тривалість етапу — це різниця сусідніх значень.

What it is. -w (write-out) prints metric variables after the request. For timing the key ones are time_namelookup (DNS), time_connect (TCP), time_appconnect (TLS), time_starttransfer (first response byte, TTFB) and time_total. All values are cumulative from the request start, so a phase's duration is the difference between neighbours.

Як читати. Якщо великий проміжок між time_appconnect і time_starttransfer — повільно думає сервер. Якщо великий time_connect — мережа чи далекий регіон. Якщо довгий DNS — проблема резолвера. Це грубий, але чесний перший крок перед тим, як звинувачувати бекенд.

How to read it. A large gap between time_appconnect and time_starttransfer means the server is slow to think. A large time_connect points at the network or a distant region. A long DNS phase is a resolver problem. It is a rough but honest first step before blaming the backend.

Межі. Один запит із ноутбука — це не вимірювання продуктивності: немає навантаження, розкид великий, а мережа тестувальника додає свій шум. Для порівняння «до і після» запускай серію й дивись медіану, а для висновків про навантаження бери k6 чи JMeter.

Limits. One request from a laptop is not a performance measurement: there is no load, variance is large and the tester's network adds its own noise. For a before-and-after comparison run a series and look at the median; for conclusions about load use k6 or JMeter.

Розклад часу для ендпоінта з затримкою 1 с
A timing breakdown for an endpoint with a 1 s delay
$ curl -s -o /dev/null -w "code=%{http_code} dns=%{time_namelookup}s connect=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n" \
    https://httpbin.org/delay/1
code=200 dns=0.005564s connect=0.114195s tls=0.337953s ttfb=1.449751s total=1.449838s

$ curl -s -o /dev/null -w "%{http_code} %{time_total}\n" https://httpbin.org/get
200 0.458959
🔧 Розбір коду: DNS зайняв 5 мс, TCP — ще близько 110 мс, TLS — близько 220 мс, а від кінця TLS до першого байта минуло 1,1 с — це й є штучна затримка сервера. Друга команда — мінімальна перевірка для CI: статус і загальний час.
🔧 Code breakdown: DNS took 5 ms, TCP about 110 ms more, TLS about 220 ms, and from the end of TLS to the first byte 1.1 s passed — that is the server's artificial delay. The second command is a minimal CI check: status and total time.
💡 Простими словами: -w — це чек із розбивкою: не лише «разом 1,45 с», а й скільки пішло на дорогу, на двері й на очікування в черзі.
💡 In plain words: -w is an itemised receipt: not just «1.45 s in total» but how much went on the road, the door and the queue.
🗣 Як читати вголос Коли кажуть «API повільне», я спершу розкладаю один запит через curl -w: DNS, TCP, TLS, перший байт і загальний час. Значення накопичувальні, тож дивлюся на різниці. Якщо довго між TLS і першим байтом — повільний сервер, якщо довге з'єднання — мережа чи регіон. Це швидкий спосіб зрозуміти, куди копати, але не вимірювання продуктивності: для висновків про навантаження я беру серію запитів або k6.
🗣 How to say it out loud When someone says «the API is slow», I first break one request down with curl -w: DNS, TCP, TLS, first byte and total. The values are cumulative, so I look at the differences. A long gap between TLS and the first byte means a slow server; a long connect means the network or the region. It is a quick way to see where to dig, but not a performance measurement: for conclusions about load I use a series of requests or k6.
24 Які рецепти jq потрібні тестувальнику, щоб фільтрувати відповідь і валити скрипт, коли дані неправильні? Which jq recipes does a tester need to filter a response and fail a script when the data is wrong?
Командний рядокCommand line Middle ▶

Що це. jq — мова запитів до JSON у терміналі. Базові конструкції: шлях .items[0].name, ітерація .items[], фільтр select(умова), побудова нового об'єкта {id, price}, функції length, map, type, all, any. -r друкує рядки без лапок, -c — компактно в один рядок.

What it is. jq is a query language for JSON in the terminal. The basics: a path .items[0].name, iteration .items[], a filter select(condition), building a new object {id, price}, and functions like length, map, type, all, any. -r prints strings without quotes, -c prints compactly on one line.

Перевірки. Прапорець -e робить код виходу залежним від результату: false чи null дають код 1. Тож jq -e 'умова' — готове твердження для bash-скрипту чи кроку CI. Для таблиць зручний @tsv, а --arg безпечно передає значення зі shell у фільтр без склеювання рядків.

Checks. The -e flag ties the exit code to the result: false or null give code 1. So jq -e 'condition' is a ready assertion for a bash script or a CI step. @tsv is handy for tables, and --arg passes shell values into a filter safely, without string gluing.

Підводний камінь. .price у jq не валідує тип: рядок "19.90" і число 19.9 обидва надрукуються як ціна. Тип перевіряють явно через type — саме так ловиться поле, яке в новому релізі почало приходити рядком.

Pitfall. .price in jq does not validate the type: the string "19.90" and the number 19.9 both print as a price. Check the type explicitly with type — that is how you catch a field that started arriving as a string in a new release.

products.json — відповідь каталогу
products.json — a catalogue response
{"page":1,"items":[
 {"id":101,"name":"Keyboard","price":49.9,"stock":12,"tags":["office"]},
 {"id":102,"name":"Mouse","price":"19.90","stock":0,"tags":["office","sale"]},
 {"id":103,"name":"Monitor","price":229,"stock":3,"tags":[]}
]}
Рецепти й реальний вивід
Recipes and real output
$ jq '.items | length' products.json
3
$ jq -r '.items[] | select(.stock == 0) | .name' products.json
Mouse
$ jq -c '[.items[] | {id, price}]' products.json
[{"id":101,"price":49.9},{"id":102,"price":"19.90"},{"id":103,"price":229}]
$ jq -c '[.items[] | select((.price | type) != "number") | .id]' products.json
[102]
$ jq -e '.items | all(.price | type == "number")' products.json; echo "exit=$?"
false
exit=1
$ jq -r '.items[] | [.id, .name, .stock] | @tsv' products.json
101	Keyboard	12
102	Mouse	0
103	Monitor	3
🔧 Розбір коду: select відбирає елементи за умовою, {id, price} — скорочення для {id: .id, price: .price}. Четвертий рецепт знаходить товар, у якого ціна прийшла рядком, а п'ятий з -e перетворює ту саму перевірку на код виходу 1.
🔧 Code breakdown: select keeps elements matching a condition, and {id, price} is short for {id: .id, price: .price}. The fourth recipe finds the product whose price came as a string, and the fifth, with -e, turns the same check into exit code 1.
💡 Простими словами: jq — це grep, який розуміє структуру JSON: шукає не рядки тексту, а поля й значення.
💡 In plain words: jq is a grep that understands JSON structure: it looks for fields and values, not lines of text.
🗣 Як читати вголос jq я використовую щодня в терміналі й у CI. Шляхи й ітерація дістають потрібні поля, select фільтрує, конструкція з фігурними дужками будує компактний звіт, а -r дає рядок без лапок для наступного curl. Найкорисніше для тестування — прапорець -e: з ним jq повертає код 1, якщо умова хибна, тож перевірка на кшталт «усі ціни — числа» стає твердженням, яке валить скрипт. Тип перевіряю явно, бо jq охоче надрукує і рядок, і число.
🗣 How to say it out loud I use jq daily in the terminal and in CI. Paths and iteration pull out the fields I need, select filters, curly braces build a compact report, and -r gives an unquoted string for the next curl. The most useful thing for testing is the -e flag: with it jq returns code 1 when the condition is false, so a check like «every price is a number» becomes an assertion that fails the script. I check types explicitly, because jq happily prints both a string and a number.
25 Чим HTTPie зручніший за curl для ручних запитів і як він формує JSON-тіло? Why is HTTPie handier than curl for manual requests, and how does it build a JSON body?
Командний рядокCommand line Junior ▶

Що це. HTTPie — консольний HTTP-клієнт з людським синтаксисом: http POST api.test/users name=Ann. Він за замовчуванням шле JSON, підсвічує й форматує відповідь і виводить заголовки. Для ручного дослідження API це швидше, ніж пам'ятати прапорці curl.

What it is. HTTPie is a command-line HTTP client with a human syntax: http POST api.test/users name=Ann. By default it sends JSON, highlights and formats the response and prints the headers. For manual API exploration that is faster than remembering curl flags.

Синтаксис елементів. key=value — рядкове поле JSON, key:=value — сире JSON-значення (число, true, масив), Header:value — заголовок, key==value — параметр рядка запиту, field@file — файл у формі. -f перемикає на форму, -v показує запит і відповідь, --print=b лишає лише тіло.

Item syntax. key=value is a string JSON field, key:=value a raw JSON value (a number, true, an array), Header:value a header, key==value a query-string parameter, field@file a file in a form. -f switches to a form, -v shows request and response, --print=b keeps just the body.

Підводні камені. age=30 відправить рядок "30", а не число — для чисел потрібне :=. У скриптах і CI HTTPie читає stdin, якщо той не термінал, і конфліктує з полями запиту — там додають --ignore-stdin або беруть curl, який є на будь-якому агенті.

Pitfalls. age=30 sends the string "30", not a number — numbers need :=. In scripts and CI HTTPie reads stdin when it is not a terminal and clashes with request items; there you add --ignore-stdin or use curl, which exists on any agent.

JSON із числом, булевим значенням і заголовком (-v показує запит)
JSON with a number, a boolean and a header (-v shows the request)
$ http --ignore-stdin -v --pretty=none POST httpbin.org/post name=Ann age:=30 active:=true X-Trace-Id:qa-1
POST /post HTTP/1.1
Accept: application/json, */*;q=0.5
Content-Type: application/json
X-Trace-Id: qa-1
Host: httpbin.org

{"name": "Ann", "age": 30, "active": true}

HTTP/1.1 200 OK
Те саме поле як форма
The same field as a form
$ http --ignore-stdin --pretty=none --print=b -f POST httpbin.org/post name=Ann | jq -c '{form, json}'
{"form":{"name":"Ann"},"json":null}
🔧 Розбір коду: age:=30 і active:=true пішли як число й булеве значення, а X-Trace-Id:qa-1 став заголовком. З -f той самий name=Ann потрапив у форму, а не в JSON.
🔧 Code breakdown: age:=30 and active:=true went out as a number and a boolean, and X-Trace-Id:qa-1 became a header. With -f the same name=Ann went into a form instead of JSON.
💡 Простими словами: HTTPie — curl, який говорить людською мовою: пишеш «ім'я дорівнює Ann», і він сам складає JSON.
💡 In plain words: HTTPie is curl that speaks human: you write «name equals Ann» and it builds the JSON itself.
🗣 Як читати вголос Для ручних запитів мені зручніший HTTPie: він за замовчуванням шле JSON, красиво показує відповідь, а синтаксис читається як речення. Поле через рівно — рядок, через двокрапку й рівно — сире значення, тож число пишу як age:=30, інакше піде рядок. Заголовок — через двокрапку, параметр запиту — через два знаки рівності. У CI я все ж частіше беру curl: він є скрізь, і там немає нюансу зі stdin.
🗣 How to say it out loud For manual requests I prefer HTTPie: it sends JSON by default, shows the response nicely, and the syntax reads like a sentence. A field with an equals sign is a string, with colon-equals it is a raw value, so I write a number as age:=30 or a string goes out. A header uses a colon and a query parameter uses a double equals. In CI I still reach for curl more often: it is everywhere and has no stdin quirk.
26 Як написати post-deploy smoke на bash із curl і jq, щоб він був надійним сигналом для пайплайна? How do you write a post-deploy smoke test in bash with curl and jq so that it is a reliable pipeline signal?
Командний рядокCommand line Senior ▶

Навіщо. Після деплою потрібна перевірка за хвилину, без залежностей і без Node чи Java на агенті: чи живий сервіс, чи працює авторизація, чи повертає ключовий ендпоінт правильну структуру. Bash із curl і jq є майже на будь-якому раннері, і такий скрипт легко вбудувати в пайплайн деплою як ворота перед переключенням трафіку.

Why. After a deploy you need a check that takes a minute, with no dependencies and no Node or Java on the agent: is the service alive, does auth work, does a key endpoint return the right structure. Bash with curl and jq exists on almost any runner, and such a script slots into the deploy pipeline as a gate before traffic is switched.

Що робить його надійним. set -euo pipefail, щоб будь-яка помилка зупиняла скрипт; --max-time на кожен запит; очікуваний статус для кожної перевірки, включно з негативними (401 без токена); jq -e для тверджень про тіло; зрозумілий рядок FAIL з очікуваним і фактичним значенням; ненульовий код виходу. Адреса стенду приходить зі змінної, а не захардкоджена.

What makes it reliable. set -euo pipefail so any error stops the script; --max-time on every request; an expected status for every check, negative ones included (401 without a token); jq -e for body assertions; a clear FAIL line with expected and actual values; a non-zero exit code. The stand URL comes from a variable, not hardcoded.

Компроміс. Коли перевірок стає більше двох десятків, з'являються дані, фікстури й звіти — bash перестає бути перевагою. Тоді smoke переїжджає в основний тестовий фреймворк, а скрипт лишається хіба що як найшвидший health-gate.

Trade-off. Once there are more than a couple of dozen checks, with data, fixtures and reports, bash stops being an advantage. Then the smoke moves into the main test framework, and the script survives at most as the fastest health gate.

smoke.sh
smoke.sh
#!/usr/bin/env bash
# Smoke API після деплою: валить пайплайн на першій зламаній перевірці.
set -euo pipefail
BASE="${BASE_URL:-https://httpbin.org}"

check() {  # check <name> <expected-status> <curl args...>
  local name="$1" expected="$2"; shift 2
  local code
  code=$(curl -s -o /tmp/body.json -w '%{http_code}' --max-time 5 "$@")
  if [[ "$code" != "$expected" ]]; then
    echo "FAIL $name: expected $expected, got $code"; exit 1
  fi
  echo "ok   $name ($code)"
}

check "health" 200 "$BASE/get"
check "auth required" 401 "$BASE/bearer"
check "login" 200 --json '{"user":"ann"}' "$BASE/anything/login"
user=$(jq -er '.json.user' /tmp/body.json)
check "profile" 200 -H "Authorization: Bearer tok-$user" "$BASE/bearer"
jq -e '.authenticated == true' /tmp/body.json > /dev/null
check "missing page" 404 "$BASE/status/404"
echo "smoke passed"
#!/usr/bin/env bash
# Post-deploy API smoke: fails the pipeline on the first broken check.
set -euo pipefail
BASE="${BASE_URL:-https://httpbin.org}"

check() {  # check <name> <expected-status> <curl args...>
  local name="$1" expected="$2"; shift 2
  local code
  code=$(curl -s -o /tmp/body.json -w '%{http_code}' --max-time 5 "$@")
  if [[ "$code" != "$expected" ]]; then
    echo "FAIL $name: expected $expected, got $code"; exit 1
  fi
  echo "ok   $name ($code)"
}

check "health" 200 "$BASE/get"
check "auth required" 401 "$BASE/bearer"
check "login" 200 --json '{"user":"ann"}' "$BASE/anything/login"
user=$(jq -er '.json.user' /tmp/body.json)
check "profile" 200 -H "Authorization: Bearer tok-$user" "$BASE/bearer"
jq -e '.authenticated == true' /tmp/body.json > /dev/null
check "missing page" 404 "$BASE/status/404"
echo "smoke passed"
Два запуски: справжній стенд і стенд, де авторизацію вимкнули
Two runs: the real stand and a stand where auth was switched off
$ ./smoke.sh; echo "exit=$?"
ok   health (200)
ok   auth required (401)
ok   login (200)
ok   profile (200)
ok   missing page (404)
smoke passed
exit=0
$ BASE_URL=https://httpbin.org/anything ./smoke.sh; echo "exit=$?"
ok   health (200)
FAIL auth required: expected 401, got 200
exit=1
🔧 Розбір коду: Функція check ховає повторюване: запит із таймаутом, збереження тіла, порівняння статусу й зрозуміле повідомлення. jq -er падає, якщо поля немає, тож логін без json.user зупинить скрипт. Другий запуск показує, навіщо негативна перевірка: стенд, що пускає без токена, валить smoke.
🔧 Code breakdown: The check function hides the repetition: a request with a timeout, saving the body, comparing the status and a clear message. jq -er fails if the field is missing, so a login without json.user stops the script. The second run shows why the negative check exists: a stand that lets you in without a token fails the smoke.
💡 Простими словами: Smoke — це перевірка, чи заводиться машина після ремонту: не тест-драйв на 500 км, а ключ, фари й гальма за хвилину.
💡 In plain words: A smoke test checks that the car starts after a repair: not a 500 km test drive, just the key, the lights and the brakes in a minute.
🗣 Як читати вголос Для smoke після деплою я пишу короткий bash-скрипт на curl і jq, бо вони є на будь-якому агенті. Роблю його суворим: set -euo pipefail, таймаут на кожен запит, очікуваний статус для кожної перевірки й обов'язково негативні кейси, наприклад 401 без токена. Для тіла використовую jq -e, щоб хибна умова давала ненульовий код. Повідомлення кажуть, що чекали й що прийшло. Коли перевірок стає багато, переношу smoke у фреймворк, а скрипт лишаю як швидкі ворота.
🗣 How to say it out loud For a post-deploy smoke I write a short bash script with curl and jq, because they exist on any agent. I make it strict: set -euo pipefail, a timeout per request, an expected status for every check and, always, negative cases such as a 401 without a token. For the body I use jq -e so a false condition gives a non-zero code. Messages say what was expected and what arrived. When the checks multiply, I move the smoke into the framework and keep the script as a fast gate.
▶ 5 6
27 Що тестувальник робить у вкладці Network DevTools, коли кнопка на сайті «нічого не робить»? What does a tester do in the DevTools Network tab when a button on the site «does nothing»?
ТрафікTraffic Junior ▶

Що це. Вкладка Network показує кожен запит сторінки: метод, статус, тип, розмір, час, ініціатора. Для API-багів корисні фільтр Fetch/XHR, пошук за URL і фільтри на кшталт status-code:500, method:POST чи -domain:analytics.example. Прапорець Preserve log зберігає запити після редиректу чи перезавантаження, а Disable cache прибирає ефект кешу.

What it is. The Network tab shows every request of the page: method, status, type, size, time, initiator. For API bugs the useful bits are the Fetch/XHR filter, URL search and filters such as status-code:500, method:POST or -domain:analytics.example. Preserve log keeps requests across a redirect or reload, and Disable cache removes cache effects.

Як шукати причину. Натискаєш кнопку й дивишся, чи пішов запит узагалі. Немає запиту — проблема у фронтенді (дивись Console). Є запит із 4xx чи 5xx — відкриваєш Headers, Payload і Response і порівнюєш із контрактом. Запит «висить» — вкладка Timing покаже, чи це очікування сервера. Колонка Initiator підкаже, який код відправив запит.

How to find the cause. Click the button and see whether a request went out at all. No request — the problem is in the frontend (check Console). A request with 4xx or 5xx — open Headers, Payload and Response and compare with the contract. A request «hanging» — the Timing tab shows whether it is server wait. The Initiator column tells you which code sent it.

Copy as cURL. Правий клік на запиті → Copy → Copy as cURL (є варіанти для bash і cmd) дає точну копію з усіма заголовками, cookie й тілом. Її запускають у терміналі, імпортують у Postman через Import → Raw text і прикладають до баг-репорту, попередньо прибравши cookie та токени.

Copy as cURL. Right-click a request → Copy → Copy as cURL (there are bash and cmd variants) gives an exact copy with every header, cookie and the body. You run it in a terminal, import it into Postman via Import → Raw text and attach it to a bug report, after removing cookies and tokens.

Скопійований з DevTools запит, повторений у терміналі
A request copied from DevTools, replayed in a terminal
$ curl 'https://httpbin.org/anything/api/cart/items' \
    -H 'accept: application/json' \
    -H 'content-type: application/json' \
    -H 'cookie: sid=8f2c1e; locale=uk' \
    -H 'origin: https://shop.test' \
    -H 'x-csrf-token: 4b1d0c' \
    --data-raw '{"sku":"KB-1","qty":2}' -s \
  | jq -c '{method, url, json, cookie: .headers.Cookie, csrf: .headers["X-Csrf-Token"]}'
{"method":"POST","url":"https://httpbin.org/anything/api/cart/items","json":{"qty":2,"sku":"KB-1"},"cookie":"sid=8f2c1e; locale=uk","csrf":"4b1d0c"}
🔧 Розбір коду: Так виглядає типовий Copy as cURL: метод не вказано, бо --data-raw сам робить POST, а cookie й CSRF-токен узяті з сесії браузера. httpbin повернув усе, що отримав, — видно, що тіло, cookie і заголовок дійшли без змін.
🔧 Code breakdown: This is what a typical Copy as cURL looks like: no method is given, because --data-raw makes a POST itself, and the cookie and CSRF token come from the browser session. httpbin echoed everything it got — the body, the cookie and the header arrived unchanged.
💡 Простими словами: Network — це журнал усіх листів, які сторінка надіслала серверу; Copy as cURL — ксерокопія одного з них.
💡 In plain words: Network is the log of every letter the page sent to the server; Copy as cURL is a photocopy of one of them.
🗣 Як читати вголос Коли кнопка «нічого не робить», я відкриваю Network з фільтром Fetch/XHR і тисну ще раз. Якщо запиту немає — дивлюсь Console, це фронтенд. Якщо є помилка — перевіряю Headers, Payload і Response і порівнюю з контрактом, а Timing показує, чи це довге очікування сервера. Для відтворення беру Copy as cURL: там усі заголовки й cookie, тож я запускаю його в терміналі чи імпортую в Postman і прикладаю до баг-репорту без секретів.
🗣 How to say it out loud When a button «does nothing», I open Network with the Fetch/XHR filter and click again. No request — I check Console, it is the frontend. An error — I look at Headers, Payload and Response and compare with the contract, and Timing shows whether it is a long server wait. To reproduce, I use Copy as cURL: it has every header and cookie, so I run it in a terminal or import it into Postman and attach it to the bug report without secrets.
28 Що міститься в HAR-файлі, як його аналізувати й очищати перед тим, як комусь передати? What is in a HAR file, and how do you analyse and sanitise it before handing it to anyone?
ТрафікTraffic Middle ▶

Що це. HAR (HTTP Archive) — JSON, у якому для кожного запиту записані URL, метод, заголовки, cookie, тіло, статус, відповідь і розбивка часу (blocked, dns, connect, send, wait, receive). Його експортують з DevTools кнопкою Export HAR, з Charles, Fiddler чи mitmproxy. Розробник відкриває файл у своєму DevTools через Import і бачить ту саму мережеву картину.

What it is. HAR (HTTP Archive) is JSON recording for every request the URL, method, headers, cookies, body, status, response and a timing breakdown (blocked, dns, connect, send, wait, receive). It is exported from DevTools with Export HAR, or from Charles, Fiddler or mitmproxy. A developer opens it via Import in their own DevTools and sees the same network picture.

Аналіз. На великих файлах зручніше jq, ніж скрол: список статусів і часу, запити з 4xx/5xx чи довшими за кілька секунд, найважчі відповіді. Поле timings.wait — час очікування сервера, аналог TTFB.

Analysis. On big files jq beats scrolling: a list of statuses and times, requests with 4xx/5xx or longer than a few seconds, the heaviest responses. The timings.wait field is the server wait time, an analogue of TTFB.

Безпека. HAR містить cookie сесії, Authorization, тіла з персональними даними. Свіжі версії Chrome за замовчуванням експортують його без чутливих заголовків, а повну версію треба вмикати окремим налаштуванням, але інші інструменти цього не роблять. Перед передачею заміни Authorization і Cookie, прибери cookies і postData, а файл від клієнта зберігай як секрет.

Security. A HAR holds session cookies, Authorization headers and bodies with personal data. Recent Chrome versions export it without sensitive headers by default, with the full version behind a separate setting, but other tools do not do this. Before handing it over, replace Authorization and Cookie, drop cookies and postData, and treat a file from a customer as a secret.

Огляд checkout.har і пошук проблемного запиту
An overview of checkout.har and finding the problem request
$ jq -r '.log.entries[] | [.response.status, .time, .request.method, .request.url] | @tsv' checkout.har
200	184	GET	https://shop.test/api/cart
504	30412	POST	https://shop.test/api/checkout
200	95	GET	https://shop.test/api/recommendations

$ jq -c '.log.entries[] | select(.response.status >= 400 or .time > 3000)
         | {url: .request.url, status: .response.status, wait_ms: .timings.wait}' checkout.har
{"url":"https://shop.test/api/checkout","status":504,"wait_ms":30400}
Очищення перед передачею
Sanitising before handing it over
$ jq '(.log.entries[].request.headers[] | select(.name | test("^(authorization|cookie)$"; "i")) | .value) |= "REDACTED"
      | del(.log.entries[].request.cookies, .log.entries[].response.cookies, .log.entries[].request.postData)' \
    checkout.har > checkout.sanitized.har
$ jq -c '[.log.entries[].request.headers[] | select(.value == "REDACTED") | .name]' checkout.sanitized.har
["Cookie","Authorization","Cookie"]
$ grep -cE "secret|8f2c1e" checkout.sanitized.har
0
🔧 Розбір коду: Перша команда дає таблицю статус–час–метод–URL, друга відбирає лише проблемне: checkout чекав на сервер 30,4 с і отримав 504 від шлюзу. Третя замінює значення Authorization і Cookie незалежно від регістру назви та видаляє масиви cookie й тіла запитів; grep підтверджує, що токена й сесії у файлі більше немає.
🔧 Code breakdown: The first command gives a status–time–method–URL table, the second keeps only the problem: checkout waited 30.4 s on the server and got a 504 from the gateway. The third replaces Authorization and Cookie values regardless of header-name case and deletes cookie arrays and request bodies; grep confirms the token and session are gone from the file.
💡 Простими словами: HAR — це запис чорної скриньки літака: у ньому все, що допоможе розслідуванню, і все, що не варто показувати стороннім.
💡 In plain words: A HAR is a flight recorder: it holds everything the investigation needs, and everything outsiders should never see.
🗣 Як читати вголос HAR — це JSON з усіма запитами сторінки: заголовки, тіла, статуси й розбивка часу. Я прошу його, коли баг залежить від мережі чи його не вдається відтворити, і аналізую jq-ом: таблиця статусів і часу, потім лише запити з помилками або довші за кілька секунд, а timings.wait показує очікування сервера. Перш ніж передати файл, прибираю Authorization, Cookie й тіла, бо там сесія й персональні дані. Файл від клієнта зберігаю як секрет.
🗣 How to say it out loud A HAR is JSON with every request of the page: headers, bodies, statuses and a timing breakdown. I ask for one when a bug depends on the network or cannot be reproduced, and I analyse it with jq: a table of statuses and times, then only the failing or multi-second requests, and timings.wait shows server wait. Before handing the file over I strip Authorization, Cookie and bodies, because they hold the session and personal data. A file from a customer I keep as a secret.
29 Як проксі на кшталт Charles, Fiddler чи mitmproxy бачать HTTPS-трафік і що для цього треба налаштувати? How do proxies like Charles, Fiddler or mitmproxy see HTTPS traffic, and what must be set up for it?
ТрафікTraffic Junior ▶

Що це. Проксі-налагоджувач стає посередником: клієнт з'єднується з ним, а він — із сервером. Для HTTPS проксі на льоту генерує сертифікат для потрібного домену, підписаний його власним кореневим CA. Клієнт прийме такий сертифікат, лише якщо довіряє цьому CA, тому кореневий сертифікат проксі встановлюють у систему, браузер чи на телефон.

What it is. A debugging proxy becomes the middleman: the client connects to it, and it connects to the server. For HTTPS the proxy generates a certificate for the requested domain on the fly, signed by its own root CA. The client accepts that certificate only if it trusts that CA, so the proxy's root certificate is installed in the system, the browser or on the phone.

Налаштування. Два кроки: направити трафік через проксі (системний проксі, налаштування Wi-Fi на телефоні, змінні HTTP_PROXY/HTTPS_PROXY чи прапорець -x у curl) і встановити кореневий сертифікат. Charles і Fiddler мають зручний інтерфейс, mitmproxy — консоль, вебінтерфейс і Python-скрипти для автоматизації.

Setup. Two steps: route traffic through the proxy (the system proxy, Wi-Fi settings on a phone, HTTP_PROXY/HTTPS_PROXY variables or the -x flag in curl) and install the root certificate. Charles and Fiddler have a friendly UI; mitmproxy has a console, a web UI and Python scripts for automation.

Підводні камені. Без сертифіката ти бачиш лише зашифровані тунелі CONNECT або отримуєш помилку TLS. Кореневий CA проксі — це ключ, яким можна підробити будь-який сайт, тож його не ставлять на особисті пристрої назавжди й видаляють після тестування.

Pitfalls. Without the certificate you only see encrypted CONNECT tunnels or get a TLS error. The proxy's root CA is a key that can fake any site, so it is not left on personal devices for good and is removed after testing.

curl через mitmproxy: без довіри до CA і з нею
curl through mitmproxy: without trusting the CA and with it
$ mitmdump -p 8899 --set confdir=./mitm &
$ ls mitm
mitmproxy-ca-cert.cer  mitmproxy-ca-cert.p12  mitmproxy-ca-cert.pem
mitmproxy-ca.p12  mitmproxy-ca.pem  mitmproxy-dhparam.pem

$ curl -sS -x http://127.0.0.1:8899 -o /dev/null https://httpbin.org/get
curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)

$ curl -s -x http://127.0.0.1:8899 --cacert mitm/mitmproxy-ca-cert.pem -o /dev/null -w "%{http_code}\n" https://httpbin.org/get
200
🔧 Розбір коду: При першому старті mitmproxy створює кореневий CA у форматах для різних систем. Без --cacert curl бачить сертифікат, підписаний невідомим центром, і відмовляється (помилка 60). Коли CA довірений, запит проходить, а проксі бачить його розшифрованим.
🔧 Code breakdown: On first start mitmproxy creates a root CA in formats for different systems. Without --cacert curl sees a certificate signed by an unknown authority and refuses (error 60). Once the CA is trusted, the request goes through and the proxy sees it decrypted.
💡 Простими словами: Проксі — це перекладач між двома людьми: щоб йому повірили, обидві сторони мають прийняти його печатку.
💡 In plain words: A proxy is an interpreter between two people: for them to trust it, both sides must accept its seal.
🗣 Як читати вголос Проксі-налагоджувач — це посередник між клієнтом і сервером. Для HTTPS він підставляє власний сертифікат для кожного домену, підписаний своїм кореневим CA, тож працює, лише коли клієнт цьому CA довіряє. Тому налаштування завжди з двох кроків: направити трафік через проксі і встановити кореневий сертифікат. Без другого кроку видно лише тунелі або помилку TLS. Після тестування сертифікат з пристрою прибираю, бо ним можна підробити будь-який сайт.
🗣 How to say it out loud A debugging proxy is a middleman between client and server. For HTTPS it presents its own certificate for each domain, signed by its root CA, so it works only when the client trusts that CA. Setup is therefore always two steps: route the traffic through the proxy and install the root certificate. Without the second step you only see tunnels or a TLS error. After testing I remove the certificate from the device, because it can be used to fake any site.
30 Як через проксі підмінити відповідь одного ендпоінта, щоб перевірити обробку помилки в застосунку? How do you use a proxy to replace one endpoint's response and test error handling in the app?
ТрафікTraffic Middle ▶

Навіщо. Екран помилки, порожній список, неочікуване поле чи 500 від одного сервісу важко отримати на справжньому стенді, особливо в мобільному застосунку, де не підставиш мок у коді. Проксі між застосунком і сервером дозволяє змінити відповідь лише для одного URL, а решту трафіку залишити справжньою.

Why. An error screen, an empty list, an unexpected field or a 500 from one service are hard to get on a real stand, especially in a mobile app where you cannot plug a mock into the code. A proxy between the app and the server lets you change the response for a single URL and leave the rest of the traffic real.

Інструменти. У Charles — Map Local (віддати файл замість відповіді), Rewrite (змінити статус, заголовок чи частину тіла за правилом) і Breakpoints (зупинити запит і відредагувати вручну). У Fiddler — AutoResponder. У mitmproxy — скрипт на Python із хуками request і response: його можна тримати в репозиторії й запускати однаково в кожного.

Tools. In Charles — Map Local (serve a file instead of the response), Rewrite (change the status, a header or part of the body by rule) and Breakpoints (pause a request and edit it by hand). In Fiddler — AutoResponder. In mitmproxy — a Python script with request and response hooks: it can live in the repository and runs the same for everyone.

Підводний камінь. Забуте правило Map Local — класичне джерело «бага», якого немає: через тиждень тестувальник бачить стару відповідь і заводить тікет. Правила називають зрозуміло, вмикають лише на час перевірки і вимикають одразу після неї.

Pitfall. A forgotten Map Local rule is a classic source of a «bug» that does not exist: a week later a tester sees the stale response and files a ticket. Rules get clear names, are switched on only for the check and switched off right after it.

fail_cart.py — зламати один ендпоінт, решту лишити
fail_cart.py — break one endpoint, leave the rest alone
"""mitmdump -s fail_cart.py: break one endpoint, leave the rest untouched."""
import json
from mitmproxy import http


def request(flow: http.HTTPFlow) -> None:
    # відповідаємо на GET /anything/cart самі, запит не доходить до сервера
    if flow.request.method == "GET" and flow.request.path.startswith("/anything/cart"):
        flow.response = http.Response.make(
            500,
            json.dumps({"error": "cart service unavailable"}),
            {"Content-Type": "application/json"},
        )


def response(flow: http.HTTPFlow) -> None:
    # позначаємо кожну справжню відповідь, щоб було видно, що вона пройшла через проксі
    flow.response.headers["X-Debug-Proxy"] = "mitmproxy"
"""mitmdump -s fail_cart.py: break one endpoint, leave the rest untouched."""
import json
from mitmproxy import http


def request(flow: http.HTTPFlow) -> None:
    # answer GET /anything/cart ourselves, the request never reaches the server
    if flow.request.method == "GET" and flow.request.path.startswith("/anything/cart"):
        flow.response = http.Response.make(
            500,
            json.dumps({"error": "cart service unavailable"}),
            {"Content-Type": "application/json"},
        )


def response(flow: http.HTTPFlow) -> None:
    # tag every real response so it is obvious it went through the proxy
    flow.response.headers["X-Debug-Proxy"] = "mitmproxy"
Запуск і перевірка через curl
Running it and checking with curl
$ mitmdump -s fail_cart.py -p 8899 --set confdir=./mitm &
$ curl -s -x http://127.0.0.1:8899 --cacert mitm/mitmproxy-ca-cert.pem -i https://httpbin.org/anything/cart \
    | grep -E "^HTTP|^\{|content-type"
HTTP/1.1 200 Connection established
HTTP/2 500
content-type: application/json
{"error": "cart service unavailable"}
$ curl -s -x http://127.0.0.1:8899 --cacert mitm/mitmproxy-ca-cert.pem -i https://httpbin.org/get | grep -iE "^HTTP|x-debug-proxy"
HTTP/1.1 200 Connection established
HTTP/2 200
x-debug-proxy: mitmproxy
🔧 Розбір коду: Хук request створює відповідь сам через http.Response.make, тож сервер цей запит навіть не отримує. Хук response додає заголовок до всіх справжніх відповідей. Перший рядок у виводі — відповідь проксі на CONNECT, далі — те, що побачив клієнт.
🔧 Code breakdown: The request hook builds the response itself via http.Response.make, so the server never even receives that request. The response hook adds a header to every real response. The first output line is the proxy's reply to CONNECT, then comes what the client saw.
💡 Простими словами: Проксі-правило — це дублер на знімальному майданчику: у небезпечній сцені замість актора, а в решті фільму — справжній актор.
💡 In plain words: A proxy rule is a stunt double: it stands in for the actor in the dangerous scene, and the real actor plays the rest of the film.
🗣 Як читати вголос Щоб перевірити, як застосунок переживає 500 чи дивну відповідь, я не прошу розробників ламати стенд, а ставлю правило в проксі лише для одного URL. У Charles це Map Local або Rewrite, у Fiddler — AutoResponder, а в mitmproxy — короткий Python-скрипт, який відповідає сам і не пускає запит до сервера. Скрипт зручний тим, що лежить у репозиторії й повторюється в кожного. І завжди вимикаю правило після перевірки, інакше через тиждень хтось заведе баг на мою підміну.
🗣 How to say it out loud To check how an app survives a 500 or an odd response, I do not ask developers to break the stand; I put a proxy rule on just one URL. In Charles that is Map Local or Rewrite, in Fiddler AutoResponder, and in mitmproxy a short Python script that answers itself and never lets the request reach the server. The script is handy because it lives in the repository and behaves the same for everyone. And I always turn the rule off afterwards, or a week later someone files a bug about my stub.
31 Як відтворити повільну мережу чи повільний ендпоінт, щоб знайти подвійні кліки, таймаути й зависання спінера? How do you reproduce a slow network or a slow endpoint to find double clicks, timeouts and a stuck spinner?
ТрафікTraffic Middle ▶

Що це. Throttling штучно обмежує швидкість і додає затримку. У DevTools є готові профілі Slow 4G і Fast 4G і власні профілі з заданою затримкою та пропускною здатністю — вони діють на одну вкладку. У Charles Throttle Settings діють на весь трафік через проксі, зокрема телефона. mitmproxy дозволяє сповільнити один конкретний ендпоінт скриптом.

What it is. Throttling artificially limits speed and adds latency. DevTools has ready Slow 4G and Fast 4G profiles and custom ones with set latency and throughput — they affect one tab. Charles Throttle Settings affect all traffic through the proxy, including a phone's. mitmproxy can slow down one specific endpoint with a script.

Що шукати. Повторну відправку форми (кнопка не блокується), неправильний порядок відповідей при швидкому переключенні фільтрів, спінер, що крутиться вічно без таймауту, відсутність повідомлення про помилку, коли клієнтський таймаут спрацював. Для оплат і замовлень це прямий шлях до подвійного списання, тож паралельно перевіряють ідемпотентність на бекенді.

What to look for. A form submitted twice (the button is not locked), responses applied in the wrong order when filters switch fast, a spinner that spins forever with no timeout, no error message when the client timeout fires. For payments and orders this is a direct path to double charges, so backend idempotency is checked at the same time.

Підводний камінь. Загальний throttling сповільнює все, включно зі статикою, і сценарій стає просто довгим. Точкова затримка лише для одного ендпоінта точніше відтворює реальну проблему — повільний платіжний шлюз, а не поганий інтернет.

Pitfall. Global throttling slows everything, static files included, and the scenario just becomes long. A targeted delay on one endpoint reproduces the real problem more precisely — a slow payment gateway rather than bad internet.

slow_api.py — затримка лише для /anything/pay
slow_api.py — a delay for /anything/pay only
"""mitmdump -s slow_api.py: add 3 s of latency to /anything/pay only."""
import asyncio
from mitmproxy import http


async def response(flow: http.HTTPFlow) -> None:
    if flow.request.path.startswith("/anything/pay"):
        await asyncio.sleep(3)  # сервер уже відповів, а клієнт чекає довше
"""mitmdump -s slow_api.py: add 3 s of latency to /anything/pay only."""
import asyncio
from mitmproxy import http


async def response(flow: http.HTTPFlow) -> None:
    if flow.request.path.startswith("/anything/pay"):
        await asyncio.sleep(3)  # the server already answered; the client waits longer
Час відповіді через проксі і клієнтський таймаут
Response time through the proxy and a client timeout
$ mitmdump -s slow_api.py -p 8898 --set confdir=./mitm &
$ for p in pay cart; do
    curl -s -x http://127.0.0.1:8898 --cacert mitm/mitmproxy-ca-cert.pem -o /dev/null \
         -w "$p %{http_code} %{time_total}s\n" -X POST https://httpbin.org/anything/$p
  done
pay 200 3.521515s
cart 200 0.488289s
$ curl -s -x http://127.0.0.1:8898 --cacert mitm/mitmproxy-ca-cert.pem -m 2 -o /dev/null -w "%{http_code}\n" \
    -X POST https://httpbin.org/anything/pay; echo "exit=$?"
000
exit=28
🔧 Розбір коду: Асинхронний хук response чекає 3 секунди через asyncio.sleep, не блокуючи інші з'єднання. /anything/pay відповідає за 3,5 с, а /anything/cart — за пів секунди. З -m 2 клієнт здається раніше, ніж прийшла відповідь: код 28 — таймаут curl, і так само поведеться застосунок зі своїм таймаутом.
🔧 Code breakdown: The asynchronous response hook waits 3 seconds with asyncio.sleep without blocking other connections. /anything/pay answers in 3.5 s, while /anything/cart takes half a second. With -m 2 the client gives up before the response arrives: code 28 is a curl timeout, and an app with its own timeout behaves the same way.
💡 Простими словами: Повільна мережа — це лупа для гонок: те, що на швидкому Wi-Fi триває мить, стає видимим і відтворюваним.
💡 In plain words: A slow network is a magnifying glass for races: what takes an instant on fast Wi-Fi becomes visible and repeatable.
🗣 Як читати вголос Подвійні кліки, зависання спінера й таймаути я ловлю, штучно сповільнюючи мережу. Для вебу є профілі в DevTools, для мобільного — Throttle у Charles, а коли треба сповільнити саме один ендпоінт, наприклад платіжний, пишу короткий скрипт у mitmproxy з асинхронною затримкою. Так видно, чи блокується кнопка, чи є таймаут і зрозуміле повідомлення. Для оплат паралельно прошу перевірити ідемпотентність на бекенді, бо друге натискання рано чи пізно дійде.
🗣 How to say it out loud I catch double clicks, stuck spinners and timeouts by slowing the network on purpose. For the web there are DevTools profiles, for mobile Charles Throttle, and when just one endpoint must be slow, such as payments, I write a short mitmproxy script with an asynchronous delay. That shows whether the button locks and whether there is a timeout and a clear message. For payments I also ask for backend idempotency checks, because sooner or later a second click gets through.
32 Чому трафік мобільного застосунку не видно в проксі, хоча сертифікат встановлено, і як це вирішують без послаблення prod-збірки? Why is a mobile app's traffic invisible in the proxy even with the certificate installed, and how is it solved without weakening the production build?
ТрафікTraffic Senior ▶

Причини. Перша — Android 7+ за замовчуванням не довіряє сертифікатам, встановленим користувачем, лише системним. Друга — certificate pinning: застосунок порівнює сертифікат сервера з вбудованим відбитком і розриває з'єднання з будь-яким іншим, навіть довіреним. Третя — застосунок ігнорує системний проксі або ходить через HTTP/3 (QUIC, UDP), який звичайний проксі не перехоплює.

Causes. First, Android 7+ does not trust user-installed certificates by default, only system ones. Second, certificate pinning: the app compares the server certificate with a built-in fingerprint and drops the connection with any other, even a trusted one. Third, the app ignores the system proxy or talks HTTP/3 (QUIC over UDP), which a regular proxy does not intercept.

Правильне рішення. Окрема debug- чи QA-збірка: network_security_config з блоком debug-overrides, що довіряє користувацьким CA лише в debuggable-збірці, і вимкнений пінінг через прапорець збірки. У release нічого з цього не потрапляє, і це перевіряють окремо — наприклад, тестом, що release-збірка відхиляє проксі-сертифікат. На iOS після встановлення профілю треба ще ввімкнути повну довіру в Certificate Trust Settings.

The right fix. A separate debug or QA build: a network_security_config with a debug-overrides block that trusts user CAs only in a debuggable build, and pinning turned off by a build flag. None of this reaches release, and that is checked separately — for example, by a test that the release build rejects the proxy certificate. On iOS, after installing the profile you must also enable full trust in Certificate Trust Settings.

Чого не робити. Обходити пінінг у prod-збірці через Frida чи патчинг APK — це вже дослідження безпеки, а не щоденне тестування, і воно потребує окремого погодження. Просити вимкнути пінінг «на тиждень» у бойовій версії — ще гірше: користувачі залишаться без захисту від підміни сервера.

What not to do. Bypassing pinning in the production build with Frida or APK patching is security research, not everyday testing, and needs separate approval. Asking to disable pinning «for a week» in the live version is worse still: users are left unprotected against a spoofed server.

res/xml/network_security_config.xml у QA-збірці Android
res/xml/network_security_config.xml in an Android QA build
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
    <!-- діє лише коли android:debuggable="true" -->
    <debug-overrides>
        <trust-anchors>
            <certificates src="user" />
        </trust-anchors>
    </debug-overrides>
</network-security-config>
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
    <!-- applies only when android:debuggable="true" -->
    <debug-overrides>
        <trust-anchors>
            <certificates src="user" />
        </trust-anchors>
    </debug-overrides>
</network-security-config>
🔧 Розбір коду: base-config довіряє лише системним CA — так поводиться й release. Блок debug-overrides додає довіру до користувацьких сертифікатів, але Android застосовує його лише в debuggable-збірці, тож до користувачів він не потрапить.
🔧 Code breakdown: base-config trusts system CAs only — which is how release behaves too. The debug-overrides block adds trust in user certificates, but Android applies it only in a debuggable build, so it never reaches users.
💡 Простими словами: Пінінг — це охоронець, який знає господаря в обличчя: йому байдуже, що в тебе є перепустка, якщо ти не той, кого він чекає.
💡 In plain words: Pinning is a guard who knows the owner by face: your pass does not matter if you are not the person he expects.
🗣 Як читати вголос Якщо мобільний трафік не видно в проксі, я перевіряю три речі: Android 7+ не довіряє користувацьким сертифікатам, застосунок може мати certificate pinning, або він ходить через QUIC чи ігнорує системний проксі. Рішення — окрема QA-збірка з debug-overrides у network security config і пінінгом, вимкненим прапорцем збірки. У release цього бути не повинно, і я окремо перевіряю, що бойова збірка відхиляє проксі-сертифікат. Обхід пінінгу в prod-збірці — це вже безпекове дослідження з погодженням.
🗣 How to say it out loud If mobile traffic does not show in the proxy, I check three things: Android 7+ does not trust user certificates, the app may use certificate pinning, or it talks QUIC or ignores the system proxy. The fix is a separate QA build with debug-overrides in the network security config and pinning disabled by a build flag. None of that may reach release, and I separately check that the production build rejects the proxy certificate. Bypassing pinning in a production build is security research that needs approval.
▶ 6 6
33 Як тестувальнику читати OpenAPI-специфікацію і що дає Swagger UI, крім красивої документації? How should a tester read an OpenAPI spec, and what does Swagger UI give beyond nice documentation?
КонтрактиContracts Junior ▶

Що це. OpenAPI — машинозчитуваний опис API у YAML чи JSON: шляхи, методи, параметри, тіла запитів, можливі статуси, схеми відповідей і приклади. Swagger UI малює з нього інтерактивну документацію: кожен ендпоінт розгортається, показує схему, а кнопка Try it out відправляє справжній запит і показує статус, заголовки, тіло й готову curl-команду.

What it is. OpenAPI is a machine-readable description of an API in YAML or JSON: paths, methods, parameters, request bodies, possible statuses, response schemas and examples. Swagger UI renders it as interactive documentation: each endpoint expands, shows its schema, and the Try it out button sends a real request and shows the status, headers, body and a ready curl command.

Що з неї брати для тестів. Обов'язкові поля (required) — кандидати на негативні тести без них. Обмеження (minimum, maxLength, enum, format) — межі для аналізу граничних значень. Перелік відповідей — очікувані статуси: якщо в специфікації для ендпоінта немає 404, а сервер його повертає, це розбіжність, яку варто обговорити.

What to take from it for tests. Required fields (required) are candidates for negative tests without them. Constraints (minimum, maxLength, enum, format) are the boundaries for boundary-value analysis. The list of responses gives the expected statuses: if the spec lists no 404 for an endpoint but the server returns one, that is a mismatch worth raising.

Підводний камінь. Приклад відповіді в специфікації — це обіцянка, а не результат. Тестувати «за прикладом», не відправивши запит, означає перевіряти документацію. І Swagger UI на prod з увімкненим Try it out виконує справжні запити — з POST і DELETE там обережно.

Pitfall. An example response in the spec is a promise, not a result. Testing «against the example» without sending a request means checking the documentation. And Swagger UI on prod with Try it out enabled performs real requests — be careful with POST and DELETE there.

openapi.yaml — фрагмент, з яким працюють наступні картки
openapi.yaml — the fragment the next cards work with
openapi: 3.0.3
info:
  title: Orders API
  version: 1.2.0
paths:
  /orders/{id}:
    get:
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: The order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
              example: { id: 42, status: paid, total: 19.9 }
        '404':
          description: No such order
  /orders:
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sku, qty]
              properties:
                sku: { type: string }
                qty: { type: integer, minimum: 1 }
      responses:
        '201':
          description: Created
components:
  schemas:
    Order:
      type: object
      required: [id, status, total]
      properties:
        id: { type: integer }
        status: { type: string, enum: [pending, paid, shipped] }
        total: { type: number }
🔧 Розбір коду: З цього фрагмента одразу видно тест-ідеї: id — ціле число (а що на abc?), qty має minimum: 1 (а 0 і -1?), status обмежений трьома значеннями, а в схемі Order три обов'язкові поля.
🔧 Code breakdown: Test ideas jump out of this fragment: id is an integer (what about abc?), qty has minimum: 1 (what about 0 and -1?), status is limited to three values, and the Order schema has three required fields.
💡 Простими словами: Специфікація — це договір оренди: у ньому написано, що має бути в квартирі, але заходити й перевіряти все одно треба самому.
💡 In plain words: A spec is a lease agreement: it says what the flat should have, but you still have to walk in and check.
🗣 Як читати вголос OpenAPI я читаю як джерело тест-ідей: required-поля дають негативні кейси, minimum, maxLength і enum — граничні значення, а список відповідей — статуси, які я маю побачити. Swagger UI зручний для першого знайомства: Try it out шле справжній запит і показує готову curl-команду. Але приклад у специфікації — це обіцянка, а не результат, тож перевіряю поведінку запитом. І на prod обережний із POST і DELETE через Try it out.
🗣 How to say it out loud I read OpenAPI as a source of test ideas: required fields give negative cases, minimum, maxLength and enum give boundary values, and the list of responses gives the statuses I should see. Swagger UI is handy for a first look: Try it out sends a real request and shows a ready curl command. But the example in the spec is a promise, not a result, so I check behaviour with a request. And on prod I am careful with POST and DELETE through Try it out.
34 Як побудувати contract-first тестування, щоб розбіжність між API та OpenAPI ловилася до релізу? How do you build contract-first testing so that a mismatch between the API and OpenAPI is caught before release?
КонтрактиContracts Senior ▶

Ідея. Специфікацію пишуть і погоджують до коду, а далі всі сторони працюють від неї: фронтенд — з моком, згенерованим зі специфікації, бекенд — під валідатором, що перевіряє кожну відповідь на відповідність тій самій специфікації. Специфікація лежить у репозиторії, зміни в ній проходять рев'ю, як код.

The idea. The spec is written and agreed before the code, and then every party works from it: the frontend against a mock generated from the spec, the backend under a validator that checks every response against the same spec. The spec lives in the repository, and changes to it go through review like code.

Інструменти перевірки. Prism у режимі proxy пропускає запити до справжнього сервісу й позначає порушення контракту в заголовку sl-violations, а з прапорцем --errors повертає 500 замість невалідної відповіді — наявні API-тести раптом починають ловити розбіжності. Schemathesis генерує запити зі специфікації й шукає відповіді, що їй не відповідають. У CI крок валідації стоїть перед деплоєм, а зміна специфікації без оновлення реалізації (чи навпаки) валить збірку.

Checking tools. Prism in proxy mode forwards requests to the real service and flags contract violations in the sl-violations header, and with --errors it returns a 500 instead of an invalid response — existing API tests suddenly start catching mismatches. Schemathesis generates requests from the spec and hunts for responses that do not match it. In CI the validation step runs before deploy, and a spec change without an implementation update (or vice versa) fails the build.

Компроміси. Специфікація, згенерована з коду після релізу, зручна, але перестає бути контрактом: вона просто описує те, що вже вийшло. Жорстка валідація на старті дасть сотні порушень у старому API — вмикай її поступово, ендпоінт за ендпоінтом, і фіксуй винятки явно, а не вимикай перевірку.

Trade-offs. A spec generated from code after release is convenient but stops being a contract: it merely describes what already shipped. Strict validation switched on at once yields hundreds of violations in a legacy API — roll it out gradually, endpoint by endpoint, and record exceptions explicitly rather than turning the check off.

Prism proxy перед сервісом, що повертає {"id": "42", "status": "cancelled"}
Prism proxy in front of a service returning {"id": "42", "status": "cancelled"}
$ prism proxy openapi.yaml http://127.0.0.1:5055 -p 4011 &
$ curl -s -i http://127.0.0.1:4011/orders/42 | grep -iE "^HTTP|sl-violations"
HTTP/1.1 200 OK
sl-violations: [{"location":["response","body","id"],"severity":"Error","code":"type","message":"Response body property id must be integer"},{"location":["response","body","status"],"severity":"Error","code":"enum","message":"Response body property status must be equal to one of the allowed values: pending, paid, shipped"}]

$ prism proxy openapi.yaml http://127.0.0.1:5055 -p 4012 --errors &
$ curl -s -i http://127.0.0.1:4012/orders/42 | sed -n '1p;/^{/p'
HTTP/1.1 500 Internal Server Error
{"type":"https://stoplight.io/prism/errors#VIOLATIONS","title":"Request/Response not valid","status":500,"detail":"Your request/response is not valid and the --errors flag is set, so Prism is generating this error for you.","validation":[...]}
🔧 Розбір коду: Сервіс віддає id рядком і статус поза enum. У звичайному режимі Prism пропускає відповідь, але додає sl-violations з точним шляхом до кожного порушення. З --errors він підміняє відповідь на 500, тож будь-який тест, що перевіряє статус, одразу червоніє.
🔧 Code breakdown: The service returns id as a string and a status outside the enum. In normal mode Prism passes the response through but adds sl-violations with the exact path of each violation. With --errors it swaps the response for a 500, so any test checking the status turns red at once.
💡 Простими словами: Контракт без перевірки — це договір, який ніхто не читає: підписали, а живуть як хочуть.
💡 In plain words: A contract nobody checks is an agreement nobody reads: everyone signed it, and everyone does as they please.
🗣 Як читати вголос Для мене contract-first — це коли специфікація погоджена до коду й перевіряється автоматично. Фронтенд працює з моком зі специфікації, а бекенд у CI проходить валідацію: я ставлю Prism у режимі proxy перед сервісом, і наявні API-тести починають бачити порушення, а з --errors — падати на них. Для глибшої перевірки додаю Schemathesis. На старому API вмикаю це поступово й фіксую винятки явно, бо специфікація, згенерована після релізу, вже не контракт, а опис того, що вийшло.
🗣 How to say it out loud To me contract-first means the spec is agreed before the code and checked automatically. The frontend works against a mock from the spec, and the backend is validated in CI: I put Prism in proxy mode in front of the service, so existing API tests start seeing violations and, with --errors, failing on them. For deeper checks I add Schemathesis. On a legacy API I roll this out gradually and record exceptions explicitly, because a spec generated after release is no longer a contract, just a description of what shipped.
35 Як описати стаб у WireMock: зіставлення запиту, пріоритети й шаблони відповіді? How do you describe a stub in WireMock: request matching, priorities and response templates?
КонтрактиContracts Middle ▶

Що це. WireMock — HTTP-сервер для моків, що запускається як standalone jar, у Docker або всередині JUnit-тесту. Стаб (mapping) складається з request — умов зіставлення — і response — що повернути. Файли кладуть у теку mappings, або стаби створюють на льоту через адмін-API /__admin/mappings.

What it is. WireMock is an HTTP server for mocks that runs as a standalone jar, in Docker or inside a JUnit test. A stub (mapping) has a request with matching conditions and a response with what to return. Files go into the mappings folder, or stubs are created on the fly through the admin API at /__admin/mappings.

Зіставлення. urlPath — точний шлях, urlPathPattern — регулярний вираз, headers і queryParameters з equalTo, matches, contains, bodyPatterns з equalToJson чи matchesJsonPath. Коли підходять кілька стабів, перемагає менше число priority (1 — найвищий), а за рівного — доданий останнім. Так роблять вузький «щасливий» стаб і широкий запасний для помилок.

Matching. urlPath is an exact path, urlPathPattern a regex, headers and queryParameters use equalTo, matches, contains, and bodyPatterns use equalToJson or matchesJsonPath. When several stubs match, the lower priority number wins (1 is highest), and on a tie the most recently added one. That is how you build a narrow «happy» stub and a broad fallback for errors.

Шаблони. Response templating на Handlebars підставляє в відповідь дані із запиту: {{jsonPath request.body '$.sku'}}, поточну дату. Це робить мок правдоподібнішим, але не додає йому стану — для станів є сценарії. Якщо жоден стаб не підійшов, WireMock відповідає 404 і в логах показує найближчий стаб і різницю — це перше місце, куди дивитися.

Templates. Handlebars response templating puts request data into the response: {{jsonPath request.body '$.sku'}}, the current date. It makes the mock more believable but gives it no state — scenarios exist for that. If no stub matches, WireMock answers 404 and logs the closest stub and the difference — the first place to look.

wm/mappings/orders.json
wm/mappings/orders.json
{
  "mappings": [
    {
      "priority": 1,
      "request": {
        "method": "GET",
        "urlPath": "/api/orders/42",
        "headers": { "Authorization": { "matches": "Bearer .+" } }
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json" },
        "jsonBody": { "id": 42, "status": "paid", "total": 19.9 }
      }
    },
    {
      "priority": 5,
      "request": { "method": "GET", "urlPathPattern": "/api/orders/[0-9]+" },
      "response": {
        "status": 401,
        "jsonBody": { "error": "missing token" },
        "headers": { "Content-Type": "application/json" }
      }
    },
    {
      "request": {
        "method": "POST",
        "urlPath": "/api/orders",
        "bodyPatterns": [{ "matchesJsonPath": "$[?(@.qty > 0)]" }]
      },
      "response": {
        "status": 201,
        "headers": { "Content-Type": "application/json" },
        "body": "{\"id\": 43, \"sku\": \"{{jsonPath request.body '$.sku'}}\", \"status\": \"pending\"}",
        "transformers": ["response-template"]
      }
    }
  ]
}
WireMock 3.13 standalone і запити до нього
WireMock 3.13 standalone and requests to it
$ java -jar wiremock-standalone-3.13.2.jar --port 8089 --root-dir wm --disable-banner &
$ curl -s -H "Authorization: Bearer abc" http://localhost:8089/api/orders/42
{"id":42,"status":"paid","total":19.9}
$ curl -s -w " %{http_code}\n" http://localhost:8089/api/orders/42
{"error":"missing token"} 401
$ curl -s -w " %{http_code}\n" --json '{"sku":"KB-1","qty":2}' http://localhost:8089/api/orders
{"id": 43, "sku": "KB-1", "status": "pending"} 201
$ curl -s -o /dev/null -w "%{http_code}\n" --json '{"sku":"KB-1","qty":0}' http://localhost:8089/api/orders
404
🔧 Розбір коду: Запит із токеном підходить під обидва GET-стаби, і перемагає priority: 1; без токена лишається лише широкий стаб з 401. POST з qty > 0 повертає 201 з sku із тіла запиту, а з qty: 0 жоден стаб не підходить, і WireMock віддає 404.
🔧 Code breakdown: A request with a token matches both GET stubs, and priority: 1 wins; without a token only the broad 401 stub is left. A POST with qty > 0 returns 201 echoing sku from the request body, and with qty: 0 no stub matches, so WireMock answers 404.
💡 Простими словами: Стаби — як правила на вахті: спершу перевіряють найконкретніше («це директор»), а загальне («без перепустки — не можна») спрацьовує для всіх інших.
💡 In plain words: Stubs are like rules at a front desk: the most specific one is checked first («this is the director»), and the general one («no pass, no entry») applies to everyone else.
🗣 Як читати вголос У WireMock стаб — це пара: умови зіставлення запиту й відповідь. Я зіставляю за методом, шляхом або регуляркою, заголовками й тілом через jsonPath. Коли стабів кілька, керую пріоритетом: вузький «щасливий» стаб з priority 1 і широкий запасний, який повертає помилку. Для правдоподібності вмикаю шаблони, щоб відповідь брала дані із запиту. А якщо отримую несподіваний 404, дивлюсь у лог WireMock — він показує найближчий стаб і в чому різниця.
🗣 How to say it out loud In WireMock a stub is a pair: request matching conditions and a response. I match on method, path or a regex, headers and the body via jsonPath. With several stubs I steer by priority: a narrow «happy» stub with priority 1 and a broad fallback returning an error. For realism I turn on templating so the response takes data from the request. And if I get an unexpected 404, I check the WireMock log — it shows the closest stub and what differs.
36 Як у WireMock змоделювати ретраї, обриви з'єднання й повільні відповіді? How do you model retries, dropped connections and slow responses in WireMock?
КонтрактиContracts Middle ▶

Сценарії зі станом. Поля scenarioName, requiredScenarioState і newScenarioState перетворюють набір стабів на скінченний автомат. Кожен сценарій починає зі стану Started, стаб спрацьовує лише у своєму стані й може перевести сценарій далі. Так моделюють «перша спроба — 503, друга — 200», «кошик порожній → після POST у ньому товар» і подібне. POST /__admin/scenarios/reset повертає всі сценарії на початок.

Stateful scenarios. The fields scenarioName, requiredScenarioState and newScenarioState turn a set of stubs into a state machine. Every scenario starts in state Started, a stub fires only in its own state and can move the scenario on. That models «first attempt 503, second 200», «cart empty → after POST it holds an item» and the like. POST /__admin/scenarios/reset returns every scenario to the start.

Збої й затримки. fault імітує проблеми нижче HTTP: CONNECTION_RESET_BY_PEER, EMPTY_RESPONSE, MALFORMED_RESPONSE_CHUNK, RANDOM_DATA_THEN_CLOSE. fixedDelayMilliseconds і delayDistribution додають затримку, а chunkedDribbleDelay віддає тіло шматками. Саме так перевіряють таймаути клієнта, circuit breaker і повідомлення користувачу.

Faults and delays. fault imitates problems below HTTP: CONNECTION_RESET_BY_PEER, EMPTY_RESPONSE, MALFORMED_RESPONSE_CHUNK, RANDOM_DATA_THEN_CLOSE. fixedDelayMilliseconds and delayDistribution add latency, and chunkedDribbleDelay sends the body in pieces. That is how client timeouts, circuit breakers and user messages are tested.

Підводний камінь. Стан сценарію глобальний для всього інстансу WireMock. Паралельні тести, що ділять один сценарій, заважають один одному, тож кожен тест або скидає сценарії перед собою, або має власний інстанс чи унікальну назву сценарію.

Pitfall. Scenario state is global for the whole WireMock instance. Parallel tests sharing one scenario interfere with each other, so each test either resets scenarios first or gets its own instance or a unique scenario name.

retry-scenario.json — завантажується через адмін-API
retry-scenario.json — loaded through the admin API
{
  "mappings": [
    {
      "scenarioName": "payment-retry",
      "requiredScenarioState": "Started",
      "newScenarioState": "gateway-recovered",
      "request": { "method": "POST", "urlPath": "/api/payments" },
      "response": { "status": 503, "headers": { "Retry-After": "1" } }
    },
    {
      "scenarioName": "payment-retry",
      "requiredScenarioState": "gateway-recovered",
      "request": { "method": "POST", "urlPath": "/api/payments" },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json" },
        "jsonBody": { "paymentId": "p-1", "status": "captured" }
      }
    },
    {
      "request": { "method": "GET", "urlPath": "/api/rates" },
      "response": { "fault": "CONNECTION_RESET_BY_PEER" }
    },
    {
      "request": { "method": "GET", "urlPath": "/api/slow" },
      "response": { "status": 200, "fixedDelayMilliseconds": 3000 }
    }
  ]
}
Що бачить клієнт
What the client sees
$ curl -s -X POST --data-binary @retry-scenario.json http://localhost:8089/__admin/mappings/import -o /dev/null -w "import %{http_code}\n"
import 200
$ for i in 1 2 3; do curl -s -X POST -o /dev/null -w "call $i -> %{http_code}\n" http://localhost:8089/api/payments; done
call 1 -> 503
call 2 -> 200
call 3 -> 200
$ curl -s -X POST http://localhost:8089/__admin/scenarios/reset -o /dev/null -w "reset %{http_code}\n"
reset 200
$ curl -s -X POST -o /dev/null -w "after reset -> %{http_code}\n" http://localhost:8089/api/payments
after reset -> 503
$ curl -sS http://localhost:8089/api/rates; echo "exit=$?"
curl: (56) Recv failure: Connection reset by peer
exit=56
$ curl -sS -m 1 http://localhost:8089/api/slow; echo "exit=$?"
curl: (28) Operation timed out after 1001 milliseconds with 0 bytes received
exit=28
🔧 Розбір коду: Перший виклик застає сценарій у Started і отримує 503, стаб переводить сценарій у gateway-recovered, і далі платіж проходить. Після reset усе починається спочатку. fault рве TCP-з'єднання (помилка 56 у curl), а трисекундна затримка перевищує клієнтський таймаут в 1 с (помилка 28).
🔧 Code breakdown: The first call finds the scenario in Started and gets 503, the stub moves the scenario to gateway-recovered, and the payment then succeeds. After reset it all starts again. fault tears down the TCP connection (curl error 56), and the three-second delay exceeds the 1 s client timeout (error 28).
💡 Простими словами: Сценарій WireMock — це настільна гра з картками стану: кожен хід відкриває наступну картку, а reset знову тасує колоду.
💡 In plain words: A WireMock scenario is a board game with state cards: each move reveals the next card, and reset shuffles the deck again.
🗣 Як читати вголос Для ретраїв і нестабільних залежностей я використовую сценарії WireMock: перший стаб у стані Started повертає 503 і переводить сценарій далі, другий віддає 200. Так перевіряю, що клієнт повторює запит, поважає Retry-After і не дублює платіж. Обриви з'єднання моделюю через fault, а таймаути — через fixedDelayMilliseconds. Пам'ятаю, що стан сценарію глобальний для інстансу, тож перед тестом роблю reset або даю паралельним тестам окремі сценарії.
🗣 How to say it out loud For retries and flaky dependencies I use WireMock scenarios: the first stub in state Started returns 503 and moves the scenario on, the second returns 200. That checks the client retries, respects Retry-After and does not duplicate the payment. I model dropped connections with fault and timeouts with fixedDelayMilliseconds. I keep in mind scenario state is global to the instance, so I reset before a test or give parallel tests their own scenarios.
37 Як отримати мок прямо з OpenAPI через Prism і чим від нього відрізняється Mockoon? How do you get a mock straight from OpenAPI with Prism, and how does Mockoon differ from it?
КонтрактиContracts Middle ▶

Prism. prism mock openapi.yaml піднімає сервер, який відповідає прикладами зі специфікації, а якщо прикладу немає — генерує дані за схемою (з -d — динамічно, при кожному запиті нові). Важливо, що Prism валідує і вхідні запити: невалідне тіло чи параметр дає 422 з описом порушення. Заголовок Prefer: code=404 чи Prefer: example=name обирає конкретну відповідь.

Prism. prism mock openapi.yaml starts a server that answers with the spec's examples and, when there is none, generates data from the schema (with -d, dynamically, fresh on every request). Crucially, Prism also validates incoming requests: an invalid body or parameter gets a 422 describing the violation. The Prefer: code=404 or Prefer: example=name header picks a specific response.

Mockoon. Десктопний застосунок і CLI (mockoon-cli start --data env.json), де моки налаштовують вручну: маршрути, кілька відповідей на маршрут із правилами вибору, шаблони, затримки, CRUD-маршрути зі станом у пам'яті. Він імпортує OpenAPI, але далі живе власним життям — це зручно для швидких експериментів і демо, але мок відходить від контракту.

Mockoon. A desktop app and CLI (mockoon-cli start --data env.json) where mocks are configured by hand: routes, several responses per route with selection rules, templates, delays, CRUD routes with in-memory state. It imports OpenAPI, but from then on lives its own life — handy for quick experiments and demos, but the mock drifts from the contract.

Коли що. Prism — коли специфікація є джерелом правди й мок має з нею збігатися завжди. Mockoon чи WireMock — коли потрібні стан, складні правила чи збої. Жоден мок не замінює перевірки справжнього сервісу на відповідність тому самому контракту.

When to use which. Prism — when the spec is the source of truth and the mock must always match it. Mockoon or WireMock — when you need state, complex rules or faults. No mock replaces checking the real service against the same contract.

prism mock openapi.yaml (Prism 5.16)
prism mock openapi.yaml (Prism 5.16)
$ prism mock openapi.yaml -p 4010 &
$ curl -s http://127.0.0.1:4010/orders/42
{"id":42,"status":"paid","total":19.9}
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:4010/orders/abc
422
$ curl -s --json '{"sku":"KB-1","qty":0}' http://127.0.0.1:4010/orders | jq -c '.validation'
[{"location":["body","qty"],"severity":"Error","code":"minimum","message":"Request body property qty must be >= 1"}]
$ curl -s --json '{"sku":"KB-1","qty":2}' http://127.0.0.1:4010/orders
{"id":43,"status":"pending","total":9.95}
$ curl -s -H "Prefer: code=404" -o /dev/null -w "%{http_code}\n" http://127.0.0.1:4010/orders/42
404
🔧 Розбір коду: GET віддає приклад зі специфікації; abc замість цілого числа і qty: 0 при minimum: 1 відхиляються з 422 і точним шляхом до поля. Prefer: code=404 повертає описану в специфікації відповідь 404, навіть якщо замовлення «існує».
🔧 Code breakdown: GET returns the spec's example; abc instead of an integer and qty: 0 against minimum: 1 are rejected with 422 and the exact field path. Prefer: code=404 returns the 404 described in the spec even though the order «exists».
💡 Простими словами: Prism — копія, знята з креслення, Mockoon — макет, який ти зліпив сам: перший точний, другий гнучкий.
💡 In plain words: Prism is a copy made from the blueprint; Mockoon is a model you built yourself: the first is exact, the second flexible.
🗣 Як читати вголос Якщо є OpenAPI, найшвидший мок — prism mock: він відповідає прикладами зі специфікації, а головне — валідує вхідні запити й повертає 422 з поясненням, тож фронтенд одразу бачить, що шле не те. Заголовком Prefer я обираю конкретний статус чи приклад. Mockoon зручний для ручних експериментів: маршрути, правила, стан у пам'яті, але після імпорту він живе окремо від контракту. Тому для командної роботи я волію мок зі специфікації.
🗣 How to say it out loud If there is OpenAPI, the fastest mock is prism mock: it answers with the spec's examples and, more importantly, validates incoming requests and returns a 422 with an explanation, so the frontend immediately sees it is sending the wrong thing. With the Prefer header I pick a specific status or example. Mockoon is handy for manual experiments — routes, rules, in-memory state — but after import it lives apart from the contract. So for team work I prefer a mock generated from the spec.
38 Моки роками зелені, а прод падає: як не допустити дрейфу між моками й справжнім API? Mocks are green for years while production breaks: how do you prevent drift between mocks and the real API?
КонтрактиContracts Senior ▶

Проблема. Мок — це копія поведінки провайдера на момент, коли його написали. Провайдер змінюється: перейменовує поле, додає обов'язкове, міняє тип. Тести споживача лишаються зеленими, бо перевіряють мок, а не API. Що довше живе ручний мок, то більша ймовірність, що він описує систему, якої вже немає.

The problem. A mock is a copy of the provider's behaviour at the moment it was written. The provider changes: renames a field, adds a required one, changes a type. The consumer's tests stay green because they check the mock, not the API. The longer a hand-written mock lives, the more likely it describes a system that no longer exists.

Як закрити петлю. Мок і провайдер мають перевірятися проти одного джерела. Варіант 1: OpenAPI — моки генеруються зі специфікації або перевіряються на відповідність їй у CI споживача, а провайдер валідується проти тієї самої специфікації у своєму CI. Варіант 2: consumer-driven contracts (Pact) — споживач публікує очікування, провайдер їх перевіряє перед релізом. В обох випадках розбіжність ловиться до продакшену, а не після.

How to close the loop. The mock and the provider must both be checked against one source. Option 1: OpenAPI — mocks are generated from the spec or validated against it in the consumer's CI, and the provider is validated against the same spec in its own CI. Option 2: consumer-driven contracts (Pact) — the consumer publishes expectations, and the provider verifies them before release. Either way the mismatch is caught before production, not after.

Дешевий старт. Навіть без Pact можна додати крок, який проганяє тіла всіх стабів через схему з OpenAPI й валить збірку на невідповідності. Це не доведе, що провайдер поводиться правильно, але гарантує, що моки не брешуть про формат. Плюс регулярний контрактний прогін тих самих тестів проти справжнього стенду.

A cheap start. Even without Pact you can add a step that runs every stub body through the OpenAPI schema and fails the build on a mismatch. It does not prove the provider behaves correctly, but it guarantees the mocks do not lie about the format. Add a regular contract run of the same tests against the real stand.

check_stubs.py — стаби WireMock проти схеми з OpenAPI
check_stubs.py — WireMock stubs against the OpenAPI schema
"""Валить CI, коли стаб WireMock повертає тіло, якого не дозволяє контракт OpenAPI."""
import json
import sys

import yaml
from jsonschema import Draft202012Validator

spec = yaml.safe_load(open("openapi.yaml"))
order = Draft202012Validator(spec["components"]["schemas"]["Order"])
stubs = json.load(open(sys.argv[1]))["mappings"]

drift = 0
for stub in stubs:
    req, res = stub["request"], stub["response"]
    path = req.get("urlPath") or req.get("urlPathPattern")
    if not path.startswith("/api/orders") or res.get("status") not in (200, 201):
        continue
    if "jsonBody" not in res:
        print(f"skip  {req['method']} {path}: шаблонне тіло, перевір вручну")
        continue
    errors = sorted(e.message for e in order.iter_errors(res["jsonBody"]))
    for message in errors:
        print(f"DRIFT {req['method']} {path}: {message}")
    drift += len(errors)
    if not errors:
        print(f"ok    {req['method']} {path}")
sys.exit(1 if drift else 0)
"""Fail CI when a WireMock stub returns a body the OpenAPI contract does not allow."""
import json
import sys

import yaml
from jsonschema import Draft202012Validator

spec = yaml.safe_load(open("openapi.yaml"))
order = Draft202012Validator(spec["components"]["schemas"]["Order"])
stubs = json.load(open(sys.argv[1]))["mappings"]

drift = 0
for stub in stubs:
    req, res = stub["request"], stub["response"]
    path = req.get("urlPath") or req.get("urlPathPattern")
    if not path.startswith("/api/orders") or res.get("status") not in (200, 201):
        continue
    if "jsonBody" not in res:
        print(f"skip  {req['method']} {path}: templated body, check it by hand")
        continue
    errors = sorted(e.message for e in order.iter_errors(res["jsonBody"]))
    for message in errors:
        print(f"DRIFT {req['method']} {path}: {message}")
    drift += len(errors)
    if not errors:
        print(f"ok    {req['method']} {path}")
sys.exit(1 if drift else 0)
Стаби з картки WireMock плюс один застарілий стаб
The stubs from the WireMock card plus one outdated stub
$ jq -c '.mappings[-1].response.jsonBody' stubs-check.json
{"id":7,"state":"paid","total":"5.00"}
$ python check_stubs.py stubs-check.json; echo "exit=$?"
ok    GET /api/orders/42
skip  POST /api/orders: templated body, check it by hand
DRIFT GET /api/orders/7: '5.00' is not of type 'number'
DRIFT GET /api/orders/7: 'status' is a required property
exit=1
🔧 Розбір коду: Скрипт бере схему Order прямо з openapi.yaml і валідує тіло кожного успішного стаба для замовлень. Застарілий стаб повертає state замість status і суму рядком — обидві розбіжності знайдено, і код виходу 1 зупинить збірку. Шаблонні тіла скрипт чесно пропускає з позначкою.
🔧 Code breakdown: The script takes the Order schema straight from openapi.yaml and validates the body of every successful order stub. The outdated stub returns state instead of status and the total as a string — both mismatches are found, and exit code 1 stops the build. Templated bodies are honestly skipped with a note.
💡 Простими словами: Мок без перевірки проти контракту — як стара карта міста: зручна, поки не приведе на міст, якого вже нема.
💡 In plain words: A mock never checked against the contract is an old city map: handy until it leads you to a bridge that is gone.
🗣 Як читати вголос Моки брешуть тоді, коли їх ніщо не пов'язує з провайдером. Тому я вимагаю одне джерело правди: або OpenAPI, з якого моки генеруються чи проти якого перевіряються в CI споживача, а провайдер валідується проти того самого файлу у своєму CI, або consumer-driven контракти на кшталт Pact. Дешевий перший крок — скрипт, що проганяє тіла всіх стабів через схему й валить збірку. І регулярно запускаю ті самі тести на справжньому стенді, щоб бачити дрейф ще до релізу.
🗣 How to say it out loud Mocks lie when nothing ties them to the provider. So I insist on one source of truth: either OpenAPI, from which mocks are generated or against which they are checked in the consumer's CI while the provider is validated against the same file in its own CI, or consumer-driven contracts such as Pact. A cheap first step is a script that runs every stub body through the schema and fails the build. And I regularly run the same tests against the real stand to see drift before release.
▶ 7 7
39 Як тестувати GraphQL-API в Altair чи Postman і чому статус 200 там нічого не гарантує? How do you test a GraphQL API in Altair or Postman, and why does status 200 guarantee nothing there?
Інші протоколиOther protocols Middle ▶

Що це. GraphQL має один ендпоінт, куди йде POST із query і variables. Клієнти на кшталт Altair, Postman (тип запиту GraphQL) чи Insomnia через introspection завантажують схему й дають автодоповнення полів і аргументів. Сама схема доступна запитом __schema — це швидкий спосіб дізнатися, які запити й мутації є.

What it is. GraphQL has a single endpoint that receives a POST with query and variables. Clients such as Altair, Postman (the GraphQL request type) or Insomnia load the schema via introspection and offer autocomplete for fields and arguments. The schema itself is available through a __schema query — a quick way to learn which queries and mutations exist.

Помилки. Більшість серверів повертає HTTP 200 навіть тоді, коли запит невалідний чи резолвер упав: проблему описує масив errors, а data може бути частковим чи null. Тому тест перевіряє відсутність errors і конкретні значення в data, а не статус. Деякі сервери за новою специфікацією GraphQL over HTTP відповідають на помилки валідації 400 — це варто уточнити й зафіксувати в тестах.

Errors. Most servers return HTTP 200 even when the query is invalid or a resolver failed: the problem is described by an errors array, and data may be partial or null. So a test checks that errors is absent and asserts concrete values in data, not the status. Some servers following the newer GraphQL over HTTP spec answer validation errors with 400 — worth confirming and pinning in tests.

Що ще перевіряти. Авторизацію на рівні полів (чужі дані через вкладені зв'язки), глибину й складність запиту (без ліміту один запит кладе сервер), пагінацію, введення в змінні замість конкатенації рядків. Introspection на prod часто вимикають — це нормально, клієнт тоді отримує схему з файлу.

What else to check. Field-level authorization (someone else's data through nested relations), query depth and complexity (without a limit one query takes the server down), pagination, and passing input through variables instead of string concatenation. Introspection is often disabled on prod — that is fine, the client then loads the schema from a file.

Запит зі змінними, помилка валідації і introspection (countries.trevorblades.com)
A query with variables, a validation error and introspection (countries.trevorblades.com)
$ curl -s --json '{"query":"query($code: ID!) { country(code: $code) { name capital currency } }","variables":{"code":"UA"}}' \
    https://countries.trevorblades.com/graphql
{"data":{"country":{"capital":"Kyiv","currency":"UAH","name":"Ukraine"}}}

$ curl -s -w "\nHTTP %{http_code}\n" --json '{"query":"{ country(code: \"UA\") { name population } }"}' \
    https://countries.trevorblades.com/graphql
{"errors":[{"message":"Cannot query field \"population\" on type \"Country\".","locations":[{"line":4,"column":5}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
HTTP 200

$ curl -s --json '{"query":"{ __schema { queryType { fields { name } } } }"}' https://countries.trevorblades.com/graphql \
    | jq -c '[.data.__schema.queryType.fields[].name]'
["continent","continents","countries","country","language","languages"]
🔧 Розбір коду: Змінна $code передається окремо від тексту запиту — так само роблять Altair і Postman. Запит до неіснуючого поля отримав HTTP 200 і масив errors з кодом GRAPHQL_VALIDATION_FAILED. Introspection повернула список кореневих запитів.
🔧 Code breakdown: The $code variable travels separately from the query text — exactly what Altair and Postman do. A query for a non-existent field got HTTP 200 plus an errors array with the GRAPHQL_VALIDATION_FAILED code. Introspection returned the list of root queries.
💡 Простими словами: У GraphQL статус 200 означає «лист доставлено», а не «у листі добрі новини» — читати треба сам лист.
💡 In plain words: In GraphQL a 200 means «the letter was delivered», not «the letter has good news» — you have to read the letter itself.
🗣 Як читати вголос GraphQL я тестую в Altair або Postman: вони через introspection підтягують схему, і я бачу всі запити й мутації. Головна пастка — статус 200 навіть при помилці, тож у тестах перевіряю масив errors і конкретні значення в data, а не код відповіді. Окремо дивлюсь на авторизацію полів у вкладених зв'язках, ліміти глибини й складності та на те, щоб вхідні дані йшли через змінні. Якщо introspection на prod вимкнена, беру схему з файлу.
🗣 How to say it out loud I test GraphQL in Altair or Postman: they pull the schema via introspection, so I see every query and mutation. The main trap is status 200 even on an error, so my tests check the errors array and concrete values in data, not the response code. I also look at field authorization in nested relations, depth and complexity limits, and that input goes through variables. If introspection is off on prod, I load the schema from a file.
40 Як досліджувати й викликати gRPC-сервіс через grpcurl і що робити, коли reflection вимкнено? How do you explore and call a gRPC service with grpcurl, and what do you do when reflection is off?
Інші протоколиOther protocols Middle ▶

Що це. gRPC — RPC поверх HTTP/2 з повідомленнями Protobuf, тож curl тут не допоможе. grpcurl — аналог curl для gRPC: list показує сервіси, describe — методи й типи, а виклик приймає JSON у -d і сам перекладає його в Protobuf. Postman та Insomnia мають окремий тип запиту gRPC з тим самим принципом.

What it is. gRPC is RPC over HTTP/2 with Protobuf messages, so curl will not help. grpcurl is curl for gRPC: list shows services, describe shows methods and types, and a call takes JSON in -d and translates it into Protobuf itself. Postman and Insomnia have a dedicated gRPC request type working the same way.

Reflection і proto. Щоб знати опис сервісів, клієнту потрібна або server reflection (сервер сам розповідає про себе), або .proto-файли. Без reflection передають -import-path і -proto (чи скомпільований -protoset). -plaintext потрібен для сервера без TLS — на prod його не буде.

Reflection and proto. To know the service descriptions a client needs either server reflection (the server describes itself) or the .proto files. Without reflection you pass -import-path and -proto (or a compiled -protoset). -plaintext is for a server without TLS — prod will not have one.

На що звертати увагу. Помилки — це не HTTP-статуси, а коди gRPC: NotFound, InvalidArgument, PermissionDenied, Unavailable, DeadlineExceeded. У JSON-поданні Protobuf поля int64 приходять рядками — тест, що чекає число, впаде. Варто перевіряти дедлайни (-max-time), метадані авторизації (-H) і стрімінгові методи.

What to watch. Errors are not HTTP statuses but gRPC codes: NotFound, InvalidArgument, PermissionDenied, Unavailable, DeadlineExceeded. In Protobuf's JSON form, int64 fields come back as strings — a test expecting a number will fail. Check deadlines (-max-time), authorization metadata (-H) and streaming methods.

proto/order.proto
proto/order.proto
syntax = "proto3";

package shop.v1;

service OrderService {
  rpc GetOrder (GetOrderRequest) returns (Order);
}

message GetOrderRequest {
  int64 id = 1;
}

message Order {
  int64 id = 1;
  string status = 2;
  double total = 3;
}
grpcurl 1.9: сервер з reflection (:50051) і без неї (:50052)
grpcurl 1.9: a server with reflection (:50051) and without it (:50052)
$ grpcurl -plaintext localhost:50051 list
grpc.reflection.v1alpha.ServerReflection
shop.v1.OrderService
$ grpcurl -plaintext localhost:50051 describe shop.v1.OrderService
shop.v1.OrderService is a service:
service OrderService {
  rpc GetOrder ( .shop.v1.GetOrderRequest ) returns ( .shop.v1.Order );
}
$ grpcurl -plaintext -d '{"id": 42}' localhost:50051 shop.v1.OrderService/GetOrder
{
  "id": "42",
  "status": "paid",
  "total": 19.9
}
$ grpcurl -plaintext -d '{"id": 7}' localhost:50051 shop.v1.OrderService/GetOrder; echo "exit=$?"
ERROR:
  Code: NotFound
  Message: order 7 not found
exit=69

$ grpcurl -plaintext localhost:50052 list; echo "exit=$?"
Failed to list services: server does not support the reflection API
exit=1
$ grpcurl -plaintext -import-path ./proto -proto order.proto -d '{"id": 42}' localhost:50052 shop.v1.OrderService/GetOrder
{
  "id": "42",
  "status": "paid",
  "total": 19.9
}
🔧 Розбір коду: З reflection grpcurl сам знаходить сервіс і методи; без неї той самий виклик працює, коли передати .proto. "id": "42" — рядок, бо так JSON-мапінг Protobuf подає int64. Помилка NotFound повертається як код gRPC, а grpcurl завершується з кодом 69 (64 + номер коду NotFound, 5).
🔧 Code breakdown: With reflection grpcurl discovers the service and methods itself; without it the same call works once the .proto is passed. "id": "42" is a string, because Protobuf's JSON mapping renders int64 that way. The NotFound error comes back as a gRPC code, and grpcurl exits with 69 (64 plus NotFound's code number, 5).
💡 Простими словами: Reflection — це меню на дверях ресторану; без нього теж можна замовити, якщо меню (.proto) принесеш із собою.
💡 In plain words: Reflection is the menu on the restaurant door; without it you can still order, if you bring the menu (.proto) with you.
🗣 Як читати вголос Для gRPC я беру grpcurl або gRPC-запит у Postman. Спершу list і describe, щоб побачити сервіси й типи, потім виклик із JSON у -d. Якщо reflection вимкнена, а на prod її зазвичай вимикають, передаю .proto через -import-path і -proto — тому прошу тримати proto-файли у спільному версіонованому репозиторії. У перевірках пам'ятаю про коди gRPC замість HTTP-статусів, дедлайни й те, що int64 у JSON приходить рядком.
🗣 How to say it out loud For gRPC I use grpcurl or a gRPC request in Postman. First list and describe to see the services and types, then a call with JSON in -d. If reflection is off, which it usually is on prod, I pass the .proto via -import-path and -proto — which is why I ask for proto files to live in a shared versioned repository. In checks I remember gRPC codes instead of HTTP statuses, deadlines, and that int64 arrives in JSON as a string.
41 Як перевірити WebSocket-з'єднання через wscat чи Postman і що тестувати, крім обміну повідомленнями? How do you check a WebSocket connection with wscat or Postman, and what should you test beyond exchanging messages?
Інші протоколиOther protocols Middle ▶

Що це. WebSocket починається як HTTP-запит з Upgrade: websocket, а далі стає двостороннім з'єднанням, де обидві сторони шлють повідомлення коли завгодно. wscat — консольний клієнт: -c підключитися, -H додати заголовок до рукостискання, -x надіслати повідомлення й вийти, -w почекати на відповіді. Postman має тип запиту WebSocket із журналом повідомлень і збереженими шаблонами.

What it is. WebSocket starts as an HTTP request with Upgrade: websocket and then becomes a two-way connection where both sides send messages whenever they like. wscat is a console client: -c connects, -H adds a handshake header, -x sends a message and exits, -w waits for replies. Postman has a WebSocket request type with a message log and saved templates.

Що тестувати. Авторизацію на рукостисканні (без токена — відмова ще до відкриття з'єднання); життєвий цикл: ping/pong, закриття з кодом, реконект після обриву мережі й повторну підписку; пропущені за час обриву повідомлення; порядок і дублікати; ліміт розміру повідомлення; поведінку під багатьма одночасними з'єднаннями.

What to test. Authorization at the handshake (without a token — refused before the connection opens); the lifecycle: ping/pong, closing with a code, reconnecting after a network drop and resubscribing; messages missed during the drop; ordering and duplicates; the message size limit; behaviour under many simultaneous connections.

Підводний камінь. Мірити WebSocket як REST — «надіслав, отримав, 200» — означає пропустити майже всі реальні баги: вони живуть у розривах, перепідключеннях і гонках. Сценарій «вимкнути Wi-Fi на 30 секунд і ввімкнути» часто корисніший за сотню однакових повідомлень.

Pitfall. Measuring WebSocket like REST — «sent, received, 200» — misses almost every real bug: they live in drops, reconnections and races. A «turn Wi-Fi off for 30 seconds and back on» scenario is often more useful than a hundred identical messages.

ws_server.cjs — мінімальний сервер з перевіркою токена (бібліотека ws)
ws_server.cjs — a minimal server with a token check (the ws library)
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({
  port: 8765,
  verifyClient: ({ req }) => req.headers['authorization'] === 'Bearer tok-ann',
});
wss.on('connection', (ws) => {
  ws.send(JSON.stringify({ type: 'welcome', room: 'support' }));
  ws.on('message', (data) => {
    const msg = JSON.parse(data);
    ws.send(JSON.stringify({ type: 'ack', id: msg.id, text: msg.text }));
  });
});
wscat 6.1: з токеном і без нього
wscat 6.1: with a token and without one
$ wscat -c ws://localhost:8765 -H "Authorization: Bearer tok-ann" -x '{"id":1,"text":"hello"}' -w 1
{"type":"welcome","room":"support"}
{"type":"ack","id":1,"text":"hello"}
$ wscat -c ws://localhost:8765 -x '{"id":1}' -w 1; echo "exit=$?"
error: Unexpected server response: 401
exit=255
🔧 Розбір коду: verifyClient перевіряє заголовок ще на рукостисканні, тож без токена сервер відповідає HTTP 401 і з'єднання не відкривається. З токеном wscat отримує привітання, надсилає повідомлення з -x, чекає секунду (-w 1) і друкує підтвердження з тим самим id.
🔧 Code breakdown: verifyClient checks the header during the handshake, so without a token the server answers HTTP 401 and the connection never opens. With a token wscat gets the welcome, sends the -x message, waits a second (-w 1) and prints the acknowledgement with the same id.
💡 Простими словами: REST — це листування, WebSocket — телефонна розмова: важливо не лише що сказали, а й що буде, коли зв'язок обірвався посеред речення.
💡 In plain words: REST is letter writing, WebSocket is a phone call: what matters is not only what was said, but what happens when the line drops mid-sentence.
🗣 Як читати вголос WebSocket я перевіряю через wscat або WebSocket-запит у Postman. Спершу рукостискання: без токена з'єднання має відхилятися ще до відкриття, з токеном — відкриватися. Далі не обмежуюсь «надіслав — отримав»: перевіряю ping/pong, закриття з кодом, реконект після обриву мережі з повторною підпискою, пропущені повідомлення, порядок і дублікати, ліміт розміру. Найбільше багів знаходжу саме у сценаріях обриву, а не в звичайному обміні.
🗣 How to say it out loud I check WebSocket with wscat or a WebSocket request in Postman. First the handshake: without a token the connection must be refused before it opens, with one it must open. Then I go beyond «sent, received»: ping/pong, closing with a code, reconnecting after a network drop with a resubscribe, missed messages, ordering and duplicates, the size limit. I find most bugs in the drop scenarios, not in the normal exchange.
42 Як швидко прикинути поведінку API під навантаженням і чому Collection Runner для цього не підходить? How do you get a quick feel for an API under load, and why is Collection Runner not the tool for it?
Інші протоколиOther protocols Middle ▶

Чому не Runner. Collection Runner і Newman виконують запити послідовно від одного клієнта: наступна ітерація чекає на попередню. Це функціональна перевірка, повторена багато разів, а не навантаження — немає паралельних користувачів, керованої інтенсивності, перцентилів і порогів.

Why not Runner. Collection Runner and Newman execute requests sequentially from one client: the next iteration waits for the previous one. That is a functional check repeated many times, not load — there are no concurrent users, no controlled rate, no percentiles or thresholds.

Швидка перевірка. k6 дає це за десять рядків JavaScript: кількість віртуальних користувачів (VU) і тривалість, check для відповіді, а thresholds перетворюють результат на pass/fail — k6 повертає ненульовий код, якщо поріг порушено, тож прогін вбудовується в CI. JMeter робить те саме з GUI й великою бібліотекою плагінів. Детально моделі навантаження, профілі й аналіз розібрано в гайді з продуктивності.

A quick check. k6 does this in ten lines of JavaScript: a number of virtual users (VUs) and a duration, check for the response, and thresholds turn the result into pass/fail — k6 exits non-zero when a threshold is broken, so the run fits into CI. JMeter does the same with a GUI and a large plugin library. Load models, profiles and analysis are covered in detail in the performance guide.

Правила пристойності. Навантаження — лише на свій стенд і за погодженням, ніколи на prod без плану й на сторонні сервіси. Навіть «швидкий» тест на 50 VU проти спільного staging може зламати роботу колегам.

Etiquette. Load goes only onto your own stand and with agreement, never onto prod without a plan and never onto third-party services. Even a «quick» 50-VU test against a shared staging can break colleagues' work.

quick-check.js — 3 VU на 10 секунд із порогами
quick-check.js — 3 VUs for 10 seconds with thresholds
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 3,
  duration: '10s',
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<800'],
  },
};

export default function () {
  const res = http.get('https://httpbin.org/get');
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(1);
}
Вивід k6 run quick-check.js (k6 2.3, скорочено)
Output of k6 run quick-check.js (k6 2.3, trimmed)
  █ THRESHOLDS

    http_req_duration
    ✓ 'p(95)<800' p(95)=253.1ms

    http_req_failed
    ✓ 'rate<0.01' rate=0.00%

  █ TOTAL RESULTS

    checks_succeeded...: 100.00% 26 out of 26
    http_req_duration..............: avg=167.19ms min=114.5ms med=119.09ms max=1.13s p(90)=156.35ms p(95)=253.1ms
    http_reqs......................: 26     2.453943/s
    vus............................: 3      min=3       max=3

running (10.6s), 0/3 VUs, 26 complete and 0 interrupted iterations
🔧 Розбір коду: Три VU паралельно виконують функцію за замовчуванням по колу з паузою в секунду. Пороги кажуть: помилок менше 1%, p95 швидше за 800 мс — обидва виконано. Навмисно маленьке навантаження: це пробна перевірка на публічному сервісі, а не тест продуктивності.
🔧 Code breakdown: Three VUs run the default function in a loop in parallel with a one-second pause. The thresholds say: under 1% errors and p95 under 800 ms — both met. The load is deliberately tiny: this is a trial check against a public service, not a performance test.
💡 Простими словами: Runner, прогнаний тисячу разів, — це одна людина, що тисячу разів заходить у двері; навантаження — це натовп біля дверей одночасно.
💡 In plain words: Runner repeated a thousand times is one person walking through the door a thousand times; load is a crowd at the door at once.
🗣 Як читати вголос Collection Runner і Newman для навантаження не підходять: вони шлють запити послідовно з одного клієнта, тож це функціональний тест, повторений багато разів. Для швидкої оцінки я пишу маленький k6-скрипт: кілька віртуальних користувачів, check на статус і пороги на помилки та p95, і k6 сам поверне ненульовий код, якщо поріг порушено. Запускаю лише на своєму стенді й за погодженням, а для серйозного тесту продуктивності будую повноцінну модель навантаження.
🗣 How to say it out loud Collection Runner and Newman do not fit load testing: they send requests sequentially from one client, so it is a functional test repeated many times. For a quick estimate I write a small k6 script: a few virtual users, a status check and thresholds on errors and p95, and k6 itself returns a non-zero code when a threshold is broken. I run it only on my own stand and with agreement, and for a serious performance test I build a proper load model.
43 Як підключити OWASP ZAP до перевірки API без UI і як вбудувати це в пайплайн, не зупиняючи розробку? How do you point OWASP ZAP at an API with no UI, and how do you build it into the pipeline without stalling development?
Інші протоколиOther protocols Senior ▶

Проблема. Звичайний spider ZAP шукає посилання в HTML, а JSON API посилань не має — сканер бачить лише корінь і майже нічого не атакує. Для API ZAP потрібен опис: OpenAPI, SOAP (WSDL) чи GraphQL-схема. Скрипт zap-api-scan.py з офіційного Docker-образу імпортує визначення (-f openapi) і сканує кожен описаний ендпоінт із параметрами.

The problem. ZAP's ordinary spider looks for links in HTML, and a JSON API has none — the scanner sees only the root and attacks almost nothing. For an API ZAP needs a description: OpenAPI, SOAP (WSDL) or a GraphQL schema. The zap-api-scan.py script from the official Docker image imports the definition (-f openapi) and scans every described endpoint with its parameters.

Як вбудувати. Правила з файлу -c задають для кожного правила WARN, FAIL чи IGNORE, а код виходу відображає результат: 0 — чисто, 1 — є FAIL, 2 — лише WARN. На pull request — швидкий пасивний прогін чи API-скан на обмеженому наборі правил; повний активний скан — уночі на окремому стенді. Автентифікацію передають заголовком через змінні середовища ZAP, інакше сканується лише публічна частина.

How to build it in. A rules file passed with -c sets WARN, FAIL or IGNORE per rule, and the exit code reflects the result: 0 — clean, 1 — a FAIL exists, 2 — warnings only. On a pull request — a fast passive run or an API scan with a limited rule set; the full active scan runs nightly on a dedicated stand. Authentication is passed as a header via ZAP environment variables, otherwise only the public part gets scanned.

Межі ролі QA. ZAP знаходить типові проблеми — заголовки безпеки, розкриття інформації, частину ін'єкцій, — але не бізнес-логіку на кшталт IDOR між акаунтами: це ручні й автоматичні тести з двома користувачами. Активний скан змінює дані й навантажує систему, тому ніколи не спрямовується на prod і чужі сервіси без дозволу. Детальніше про самі вразливості — у гайді з безпеки.

The limits of the QA role. ZAP finds typical problems — security headers, information disclosure, some injections — but not business logic such as IDOR between accounts: that needs manual and automated tests with two users. An active scan changes data and loads the system, so it is never aimed at prod or at others' services without permission. More on the vulnerabilities themselves is in the security guide.

Нічний API-скан у Docker (команда для CI; у цьому гайді не запускалась)
A nightly API scan in Docker (a CI command; not run for this guide)
docker run --rm -v "$(pwd)":/zap/wrk/:rw \
  -e ZAP_AUTH_HEADER_VALUE="Bearer $ZAP_TOKEN" \
  -t ghcr.io/zaproxy/zaproxy:stable \
  zap-api-scan.py \
    -t https://staging.shop.test/openapi.json \
    -f openapi \
    -c zap-rules.conf \
    -r zap-report.html
echo "zap exit code: $?"   # 0 clean, 1 at least one FAIL, 2 warnings only
zap-rules.conf — рішення команди для окремих правил
zap-rules.conf — the team's decisions for individual rules
10021	FAIL	(X-Content-Type-Options Header Missing)
10038	WARN	(Content Security Policy Header Not Set)
40018	FAIL	(SQL Injection)
90022	FAIL	(Application Error Disclosure)
10049	IGNORE	(Storable and Cacheable Content)
🔧 Розбір коду: Поточна тека монтується як /zap/wrk, тож звіт і файл правил лежать поруч із пайплайном. -f openapi каже, як читати -t. Змінна ZAP_AUTH_HEADER_VALUE змушує ZAP додавати заголовок Authorization до кожного запиту сканера (назву заголовка змінює ZAP_AUTH_HEADER). Файл правил має формат «ID, дія, опис» через табуляцію.
🔧 Code breakdown: The current folder is mounted as /zap/wrk, so the report and the rules file sit next to the pipeline. -f openapi says how to read -t. The ZAP_AUTH_HEADER_VALUE variable makes ZAP add an Authorization header to every scanner request (ZAP_AUTH_HEADER changes the header name). The rules file uses «ID, action, description» separated by tabs.
💡 Простими словами: Spider шукає двері за вивісками, а в API вивісок немає — дай ZAP план будівлі (OpenAPI), і він обійде кожну кімнату.
💡 In plain words: A spider looks for doors by their signs, and an API has no signs — give ZAP the floor plan (OpenAPI) and it will visit every room.
🗣 Як читати вголос Для API без інтерфейсу spider ZAP майже нічого не знаходить, бо в JSON немає посилань. Тому я запускаю zap-api-scan з OpenAPI-специфікацією: він бачить усі ендпоінти й параметри. Щоб не гальмувати розробку, на pull request ставлю швидкий прогін з обмеженим набором правил, а повний активний скан — уночі на окремому стенді. Файл правил фіксує, що валить збірку, а що лише попереджає. І чесно кажу, що ZAP не замінює тестів на IDOR з двома користувачами.
🗣 How to say it out loud For an API without a UI the ZAP spider finds almost nothing, because JSON has no links. So I run zap-api-scan with the OpenAPI spec: it sees every endpoint and parameter. To avoid slowing development, pull requests get a fast run with a limited rule set, and the full active scan runs nightly on a dedicated stand. A rules file fixes what fails the build and what only warns. And I say honestly that ZAP does not replace IDOR tests with two users.
44 Як обрати набір API-інструментів для команди і не перетворити його на зоопарк? How do you choose an API toolset for a team without turning it into a zoo?
Інші протоколиOther protocols Senior ▶

З чого почати. Не з назв інструментів, а з питань: хто працює з API (лише QA чи ще розробники й аналітики), де мають жити колекції (хмара чи git), які протоколи є (REST, GraphQL, gRPC, WebSocket), що дозволяє політика безпеки, яка мова в автотестах і що вже стоїть у CI. Відповіді звужують вибір до двох-трьох варіантів.

Where to start. Not with tool names but with questions: who works with the API (QA only, or developers and analysts too), where collections should live (cloud or git), which protocols exist (REST, GraphQL, gRPC, WebSocket), what the security policy allows, which language the automated tests use and what already runs in CI. The answers narrow the choice to two or three options.

Шари. Дослідження руками (Postman, Bruno чи Insomnia), термінал і скрипти (curl, jq, HTTPie), автотести в коді (RestAssured, Playwright request, pytest із requests), моки (WireMock, Prism), трафік (DevTools, mitmproxy чи Charles), навантаження (k6 чи JMeter), безпека (ZAP). Для кожного шару — один основний інструмент, а не три паралельні: інакше знання розмазується, а колекції дублюються.

Layers. Hands-on exploration (Postman, Bruno or Insomnia), terminal and scripts (curl, jq, HTTPie), automated tests in code (RestAssured, Playwright request, pytest with requests), mocks (WireMock, Prism), traffic (DevTools, mitmproxy or Charles), load (k6 or JMeter), security (ZAP). One main tool per layer, not three in parallel: otherwise knowledge gets smeared and collections are duplicated.

Як ухвалити й закріпити. Пілот на реальному сервісі на два тижні з критеріями успіху, короткий ADR із причинами та відкинутими варіантами, шаблон репозиторію з прикладами. Перегляд раз на рік чи при зміні умов (нова політика, новий протокол), а не щоразу, коли хтось побачив новий інструмент на конференції.

How to decide and lock it in. A two-week pilot on a real service with success criteria, a short ADR with the reasons and rejected options, a repository template with examples. Review once a year or when conditions change (a new policy, a new protocol) — not every time someone sees a new tool at a conference.

Матриця вибору для команди з Java-бекендом і забороною хмари
A decision matrix for a team with a Java backend and a no-cloud policy
Шар                Вибір                   Чому                                   Відкинуто
-----------------  ----------------------  -------------------------------------  ---------------------------
ручне дослідж.     Bruno                   колекції в git, без хмари              Postman: хмарні workspace
термінал           curl + jq               є на кожному агенті, скрипти           HTTPie: немає на агентах CI
регрес             RestAssured + JUnit 5   та сама мова, що й бекенд              Newman: без фікстур, шумні diff
моки               WireMock                сценарії, збої, працює в JUnit         Postman mock: без стану, хмара
контракт           OpenAPI + Prism proxy   одна специфікація для моків і сервісу  лише ручні стаби
трафік             DevTools, mitmproxy     правила-скрипти лежать у репозиторії   Charles: платні місця
швидке навантаж.   k6                      пороги на JS, код виходу для CI        JMeter: важче рев'юїти
безпека            ZAP API-скан уночі      імпортує OpenAPI-визначення            скан лише spider-ом
Layer              Choice                  Why                                    Rejected
-----------------  ----------------------  -------------------------------------  ---------------------------
manual exploring   Bruno                   collections in git, no cloud sync      Postman: cloud workspaces
terminal checks    curl + jq               on every agent, scriptable             HTTPie: not on CI agents
regression         RestAssured + JUnit 5   same language as the backend           Newman: no fixtures, noisy diffs
mocks              WireMock                scenarios, faults, runs in JUnit       Postman mock: stateless, cloud
contract           OpenAPI + Prism proxy   one spec for mocks and provider        hand-written stubs only
traffic            DevTools, mitmproxy     scriptable rules kept in the repo      Charles: paid seats
quick load         k6                      JS thresholds, exit code for CI        JMeter: heavier to review
security           ZAP API scan nightly    imports the OpenAPI definition         spider-only scan
🔧 Розбір коду: Кожен рядок — один шар і один основний інструмент, а колонка «Відкинуто» фіксує, чому не обрали альтернативу. Саме вона рятує від повторної дискусії через пів року: причини записані, і їх можна переглянути, коли зміняться умови.
🔧 Code breakdown: Each row is one layer and one main tool, and the «Rejected» column records why the alternative lost. That column saves you from repeating the debate six months later: the reasons are written down and can be revisited when conditions change.
💡 Простими словами: Набір інструментів — як кухонні ножі: потрібні кілька добрих під різні задачі, а не двадцять однакових у шухляді.
💡 In plain words: A toolset is like kitchen knives: you need a few good ones for different jobs, not twenty similar ones in a drawer.
🗣 Як читати вголос Я обираю інструменти не за популярністю, а за умовами: хто працює з API, де мають жити колекції, які протоколи, що дозволяє безпека, яка мова автотестів і що вже є в CI. Далі розкладаю на шари — ручне дослідження, термінал, регрес у коді, моки, трафік, навантаження, безпека — і на кожен шар беру один основний інструмент. Рішення перевіряю пілотом на реальному сервісі й записую в ADR разом із відкинутими варіантами, щоб через пів року не сперечатися заново.
🗣 How to say it out loud I pick tools not by popularity but by conditions: who works with the API, where collections should live, which protocols exist, what security allows, the test language and what CI already has. Then I split it into layers — manual exploration, terminal, regression in code, mocks, traffic, load, security — and take one main tool per layer. I validate the choice with a pilot on a real service and record it in an ADR together with the rejected options, so we do not argue it all over again six months later.
45 Запит працює в Postman, але падає в застосунку: як шукати різницю і що Postman робить за тебе непомітно? A request works in Postman but fails in the app: how do you find the difference, and what does Postman silently do for you?
Інші протоколиOther protocols Senior ▶

Що Postman додає сам. Заголовки User-Agent: PostmanRuntime, Accept: */*, Cache-Control: no-cache, Postman-Token; cookie з власного cookie jar; автоматичне проходження редиректів; Content-Type за обраним типом тіла; можливо вимкнену перевірку SSL у налаштуваннях. А ще Postman — не браузер: він не надсилає Origin, не робить preflight і не застосовує CORS.

What Postman adds on its own. The headers User-Agent: PostmanRuntime, Accept: */*, Cache-Control: no-cache, Postman-Token; cookies from its own cookie jar; automatic redirect following; Content-Type from the chosen body type; possibly SSL verification turned off in settings. And Postman is not a browser: it sends no Origin, does no preflight and does not apply CORS.

Метод. Порівнювати треба два конкретні запити, а не враження. З браузера — Copy as cURL, з Postman — його консоль (реальні заголовки) або згенерований Code snippet. Далі різниця по пунктах: метод і URL (зайвий слеш, http замість https), заголовки (Origin, Content-Type, Accept, Authorization), cookie, тіло (кодування, формат дати, числа рядком). Кожну відмінність прибирають по одній, поки поведінка не зрівняється, — так знаходиться справжня причина.

Method. Compare two concrete requests, not impressions. From the browser — Copy as cURL; from Postman — its console (the real headers) or a generated Code snippet. Then go through the difference point by point: method and URL (an extra slash, http instead of https), headers (Origin, Content-Type, Accept, Authorization), cookies, the body (encoding, date format, numbers as strings). Remove differences one at a time until behaviour matches — that is how the real cause is found.

Типові знахідки. CORS: сервер не повертає Access-Control-Allow-Origin для домену фронтенду. Сесійна cookie з SameSite, яку браузер не шле в міждоменному запиті. Застосунок шле Accept, на який сервер відповідає іншим форматом. Проксі чи WAF блокує браузерний User-Agent. «Працює в Postman» — це дані для розслідування, а не аргумент, що бага немає.

Typical findings. CORS: the server does not return Access-Control-Allow-Origin for the frontend's domain. A session cookie with SameSite the browser will not send cross-site. The app sends an Accept the server answers with a different format. A proxy or WAF blocks the browser's User-Agent. «It works in Postman» is data for the investigation, not proof there is no bug.

Що насправді отримав сервер: запит із Newman (рушій Postman) і запит, скопійований із браузера
What the server really received: a request from Newman (the Postman runtime) and one copied from the browser
$ newman run headers.postman_collection.json      # GET https://httpbin.org/headers
  │ 'Accept: */*'
  │ 'Accept-Encoding: gzip, deflate, br'
  │ 'Cache-Control: no-cache'
  │ 'Host: httpbin.org'
  │ 'Postman-Token: befc5412-6577-4b4c-b24f-48f4c0731414'
  │ 'User-Agent: PostmanRuntime/7.39.1'

$ curl -s https://httpbin.org/headers -H 'accept: application/json' -H 'origin: https://shop.test' \
    -H 'cookie: sid=8f2c1e' -H 'sec-fetch-mode: cors' \
  | jq -r '.headers | del(.["X-Amzn-Trace-Id"]) | to_entries[] | "\(.key): \(.value)"'
Accept: application/json
Cookie: sid=8f2c1e
Host: httpbin.org
Origin: https://shop.test
Sec-Fetch-Mode: cors
User-Agent: curl/8.22.0
🔧 Розбір коду: httpbin повертає заголовки, які отримав. У запиті з рушія Postman немає Origin і cookie, зате є Postman-Token і Accept: */*. Браузерна копія несе Origin, cookie сесії та Sec-Fetch-Mode: cors — саме ці відмінності найчастіше пояснюють «у Postman працює».
🔧 Code breakdown: httpbin returns the headers it received. The request from the Postman runtime has no Origin and no cookie, but it has Postman-Token and Accept: */*. The browser copy carries Origin, the session cookie and Sec-Fetch-Mode: cors — exactly the differences that usually explain «it works in Postman».
💡 Простими словами: Postman і браузер — два різні листоноші: адреса та сама, але один стукає у двері, а другий спершу питає консьєржа, чи можна заходити.
💡 In plain words: Postman and a browser are two different postmen: same address, but one just knocks while the other first asks the concierge whether he may come in.
🗣 Як читати вголос Коли чую «у Postman працює», я порівнюю два конкретні запити: з браузера беру Copy as cURL, з Postman — консоль, де видно справжні заголовки. Пам'ятаю, що Postman сам додає свої заголовки, тримає власні cookie, іде за редиректами й не застосовує CORS, бо це не браузер. Далі прибираю відмінності по одній — Origin, cookie, Content-Type, Accept — доки поведінка не зрівняється. Зазвичай знаходжу CORS, SameSite-cookie чи інший формат тіла, тож це дані для розслідування, а не доказ, що бага немає.
🗣 How to say it out loud When I hear «it works in Postman», I compare two concrete requests: Copy as cURL from the browser and the Postman console, which shows the real headers. I remember Postman adds its own headers, keeps its own cookies, follows redirects and does not apply CORS, because it is not a browser. Then I remove the differences one by one — Origin, cookies, Content-Type, Accept — until behaviour matches. Usually I find CORS, a SameSite cookie or a different body format, so it is data for the investigation, not proof there is no bug.