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
Що це. Колекція — це не просто список закладок із запитами. Це дерево: колекція містить папки, папки — запити, а кожен рівень може мати власні змінні, скрипти й налаштування авторизації, які успадковуються вниз. До запиту можна зберегти приклади відповідей (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.
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
Що це. 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.
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'));
│ 'resolved:', 'https://httpbin.org'
│ 'after local set:', 'http://localhost:3000'
│ 'local removed:', 'https://httpbin.org'
set кладуть різні значення в globals, колекцію й середовище, а pm.variables.get показує переможця — середовище. Локальна змінна з pm.variables.set перекриває всі, а після unset знову перемагає середовище.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 виконує 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.
// колекція
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);
❏ 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 друкує консоль у тому порядку, у якому скрипти виконались. Номери в повідомленнях збігаються з реальною послідовністю.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.Що це. 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.
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');
});
→ 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, що перевіряє тип і довжину. Падіння показує фактичне й очікуване значення, і цього зазвичай достатньо для баг-репорту.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.Що це. Ланцюжок запитів — це коли відповідь одного запиту (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 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
→ 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
{{orderId}} в URL, перевіряє, що сервер отримав саме цей ID, і прибирає змінну.{{orderId}} into the URL, checks the server got exactly that ID, and clears the variable.Що це. 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.
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');
});
→ 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}}, тож усі запити беруть свіжий токен автоматично.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.Що це. 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.
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));
→ Slideshow
GET https://httpbin.org/json [200 OK, 659B, 458ms]
✓ matches the schema
required на кожному рівні робить поля обов'язковими, type фіксує тип, а items описує кожен елемент масиву. Якщо author прийде числом або зникне slides, тест упаде з повідомленням Ajv про конкретний шлях.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.Що це. Вкладка 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.
{
"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, бо токена ще немає.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.Що це. 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.
user
ann
bob
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'));
});
$ 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
user, тож Newman робить дві ітерації. У першій {{user}} дорівнює ann, у другій — bob; тест порівнює токен із тим, що очікується саме для поточного рядка.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.Що це. Змінна в 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.
{
"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 }
]
}
$ 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
error — jq завершується з кодом 5, і крок CI падає. Порожній token проходить, бо це шаблон.error — jq exits with code 5 and the CI step fails. The empty token passes because it is a template.Що це. 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.
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 обирають, який збережений приклад повернути. Без них мок віддає найкращий збіг за методом і шляхом.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.Що це. Монітор запускає колекцію за розкладом (раз на 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.
// 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);
pm.expect(items, 'search results') з'явиться в тексті падіння, а console.log потрапить у лог прогону монітора.pm.expect(items, 'search results') appears in the failure text, and console.log ends up in the monitor run log.Що це. Колекція експортується у 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.
{
"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 -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 замість локального файлу... 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.Сигнали. Скрипти копіюються між запитами, бо спільних функцій немає; тести залежать від порядку; діфи колекції неможливо рев'юїти; потрібні фікстури, паралелізм, генерація даних, робота з БД чи чергами. Тоді колекція стала програмою без інструментів програміста, і кожна зміна коштує дедалі дорожче.
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.
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);
});
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)
@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-стеку.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.Що це. 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.
$ 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
Що це. Репортер визначає формат результату. Вбудовані: 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.
newman run users-api.postman_collection.json -e staging.postman_environment.json -d users.csv \
-r cli,junit --reporter-junit-export results/junit.xml
<?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 лишився для людей, що читають лог.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.Що це. 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.
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() змушує крок зі звітом виконатися навіть після червоного прогону.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.Діагноз. Найчастіші причини нестабільності колекції: запит бере дані, які поклав попередній (запуск окремої папки ламає все); кілька наборів пишуть в одне середовище; тест перевіряє асинхронний результат одразу після 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.
$ 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
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();
→ 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
pm.test ламає скрипт. Другий блок опитує ендпоінт кожні 500 мс і кидає зрозумілу помилку після 10 с; основний запит іде лише тоді, коли замовлення вже видно.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.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.
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
}
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 на один рядок..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.Що це. За замовчуванням 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.
$ 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: *
$ 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 відкидає тіло, щоб воно не змішувалось із заголовками.-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.-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.
$ 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"}}
$ 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}
$ 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"}
-d JSON перетворився на ключ форми, з --json розпізнаний як JSON. -d @file з'їв переноси, --data-binary їх зберіг. У multipart файл потрапив у files, а поле — у form.-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.Авторизація. -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.
$ 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 її прибирає, але й прибирає захист.-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.Що це. -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.
$ 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
Що це. 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.
{"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":[]}
]}
$ 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.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.Що це. 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.
$ 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
$ 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.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.Навіщо. Після деплою потрібна перевірка за хвилину, без залежностей і без 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.
#!/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"
$ ./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.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.Що це. Вкладка 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.
$ 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"}
--data-raw сам робить POST, а cookie й CSRF-токен узяті з сесії браузера. httpbin повернув усе, що отримав, — видно, що тіло, cookie і заголовок дійшли без змін.--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.Що це. 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.
$ 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}
$ 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
grep підтверджує, що токена й сесії у файлі більше немає.grep confirms the token and session are gone from the file.Що це. Проксі-налагоджувач стає посередником: клієнт з'єднується з ним, а він — із сервером. Для 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.
$ 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
--cacert curl бачить сертифікат, підписаний невідомим центром, і відмовляється (помилка 60). Коли CA довірений, запит проходить, а проксі бачить його розшифрованим.--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.Навіщо. Екран помилки, порожній список, неочікуване поле чи 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.
"""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"
$ 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, далі — те, що побачив клієнт.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.Що це. 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.
"""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
$ 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, і так само поведеться застосунок зі своїм таймаутом.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.Причини. Перша — 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.
<?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-збірці, тож до користувачів він не потрапить.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.Що це. 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: 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 три обов'язкові поля.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.Ідея. Специфікацію пишуть і погоджують до коду, а далі всі сторони працюють від неї: фронтенд — з моком, згенерованим зі специфікації, бекенд — під валідатором, що перевіряє кожну відповідь на відповідність тій самій специфікації. Специфікація лежить у репозиторії, зміни в ній проходять рев'ю, як код.
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 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, тож будь-який тест, що перевіряє статус, одразу червоніє.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.Що це. 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.
{
"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"]
}
}
]
}
$ 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
priority: 1; без токена лишається лише широкий стаб з 401. POST з qty > 0 повертає 201 з sku із тіла запиту, а з qty: 0 жоден стаб не підходить, і WireMock віддає 404.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.Сценарії зі станом. Поля 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.
{
"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 }
}
]
}
$ 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).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).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 -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
abc замість цілого числа і qty: 0 при minimum: 1 відхиляються з 422 і точним шляхом до поля. Prefer: code=404 повертає описану в специфікації відповідь 404, навіть якщо замовлення «існує».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».Проблема. Мок — це копія поведінки провайдера на момент, коли його написали. Провайдер змінюється: перейменовує поле, додає обов'язкове, міняє тип. Тести споживача лишаються зеленими, бо перевіряють мок, а не 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.
"""Валить 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)
$ 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 зупинить збірку. Шаблонні тіла скрипт чесно пропускає з позначкою.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.Що це. 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.
$ 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 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.Що це. 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.
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 -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
}
"id": "42" — рядок, бо так JSON-мапінг Protobuf подає int64. Помилка NotFound повертається як код gRPC, а grpcurl завершується з кодом 69 (64 + номер коду NotFound, 5)."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).Що це. 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.
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 -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.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.Чому не 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.
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);
}
█ 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
Проблема. Звичайний 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.
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
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, дія, опис» через табуляцію./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.З чого почати. Не з назв інструментів, а з питань: хто працює з 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.
Шар Вибір Чому Відкинуто
----------------- ---------------------- ------------------------------------- ---------------------------
ручне дослідж. 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
Що 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 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
Origin і cookie, зате є Postman-Token і Accept: */*. Браузерна копія несе Origin, cookie сесії та Sec-Fetch-Mode: cors — саме ці відмінності найчастіше пояснюють «у Postman працює».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».