Пошук уроків, статей та іншого контенту
Створите тести HTTP API з перевіркою методів, статусів, заголовків, payload і помилок.
Тест HTTP API має перевіряти не лише код статусу. Повноцінний тест може підтверджувати:
HTTP-метод і маршрут;
статус відповіді;
заголовки;
структуру та значення payload;
коректну обробку неправильних даних;
поведінку для невідомих маршрутів або непідтримуваних методів.
Для прикладів використаємо вбудовані можливості Node.js:
node:test — тестовий раннер;
node:assert/strict — перевірки;
глобальний fetch — HTTP-запити.
Приклади розраховані на Node.js 18 або новішу версію.
Створимо простий API для роботи з користувачами.
Структура проєкту:
project/
├── api.js
└── test/
└── api.test.jsФайл api.js:
const http = require('node:http');
function createServer() {
const users = [];
let nextId = 1;
function sendJson(response, statusCode, payload, headers = {}) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
...headers
});
response.end(JSON.stringify(payload));
}
return http.createServer((request, response) => {
const { method, url } = request;
if (method === 'GET' && url === '/health') {
return sendJson(response, 200, {
status: 'ok'
});
}
if (url === '/users' && method === 'GET') {
return sendJson(response, 200, users);
}
if (url === '/users' && method === 'POST') {
const chunks = [];
request.on('data', (chunk) => {
chunks.push(chunk);
});
request.on('end', () => {
let payload;
try {
const body = Buffer.concat(chunks).toString('utf8');
payload = JSON.parse(body);
} catch {
return sendJson(response, 400, {
error: 'Request body must be valid JSON'
});
}
if (
!payload ||
typeof payload.name !== 'string' ||
payload.name.trim() === ''
) {
return sendJson(response, 422, {
error: 'The "name" field is required'
});
}
const user = {
id: nextId++,
name: payload.name.trim()
};
users.push(user);
return sendJson(response, 201, user, {
Location: `/users/${user.id}`
});
});
return;
}
if (url === '/users') {
return sendJson(
response,
405,
{
error: 'Method not allowed'
},
{
Allow: 'GET, POST'
}
);
}
return sendJson(response, 404, {
error: 'Route not found'
});
});
}
module.exports = {
createServer
};API підтримує такі запити:
GET /health — перевірка доступності сервера;
GET /users — отримання списку користувачів;
POST /users — створення користувача;
інші методи для /users — помилка 405;
невідомі маршрути — помилка 404.
Файл тесту може мати такі частини:
імпорт тестового раннера та перевірок;
запуск HTTP-сервера перед тестами;
допоміжна функція для HTTP-запитів;
самі тести;
зупинка сервера після завершення.
Для запуску сервера використаємо порт 0. У такому випадку операційна система автоматично вибере вільний порт.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createServer } = require('../api');
let server;
let baseUrl;
test.before(async () => {
server = createServer();
await new Promise((resolve) => {
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
baseUrl = `http://127.0.0.1:${address.port}`;
});
test.after(async () => {
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
});
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, options);
const text = await response.text();
let body = null;
if (text.length > 0) {
body = JSON.parse(text);
}
return {
response,
body
};
}Функція request повертає і сам об’єкт Response, і розібраний JSON:
const { response, body } = await request('/health');Це зручно, оскільки заголовки та статус доступні через response, а payload — через body.
Додамо тест для GET /health:
test('GET /health повертає статус сервера', async () => {
const { response, body } = await request('/health');
assert.equal(response.status, 200);
assert.equal(response.headers.get('content-type'), 'application/json; charset=utf-8');
assert.deepEqual(body, {
status: 'ok'
});
});Тут перевіряються три характеристики:
response.status — HTTP-статус;
response.headers.get(...) — значення заголовка;
body — точний payload відповіді.
Об’єкт Headers не розрізняє регістр назв заголовків, тому content-type і Content-Type дадуть однаковий результат.
Для GET /users очікуємо успішну відповідь із масивом:
test('GET /users повертає список користувачів', async () => {
const { response, body } = await request('/users');
assert.equal(response.status, 200);
assert.ok(Array.isArray(body));
assert.equal(response.headers.get('content-type'), 'application/json; charset=utf-8');
});Тест не перевіряє точну кількість елементів, оскільки стан сховища може змінюватися під час інших тестів. Натомість він перевіряє контракт endpoint:
статус дорівнює 200;
відповідь має JSON-тип;
payload є масивом.
Для надсилання JSON у fetch потрібно:
встановити method;
додати заголовок Content-Type;
серіалізувати об’єкт через JSON.stringify.
test('POST /users створює користувача', async () => {
const { response, body } = await request('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Olena'
})
});
assert.equal(response.status, 201);
assert.equal(response.headers.get('content-type'), 'application/json; charset=utf-8');
const location = response.headers.get('location');
assert.ok(location);
assert.match(location, /^\/users\/\d+$/);
assert.equal(typeof body.id, 'number');
assert.equal(body.name, 'Olena');
});Статус 201 означає, що ресурс створено. Заголовок Location містить шлях до створеного ресурсу.
Для значень, які можуть змінюватися, краще перевіряти форму значення:
assert.match(location, /^\/users\/\d+$/);Такий тест очікує шлях на кшталт /users/1, але не прив’язується до конкретного ідентифікатора.
Клієнт може надіслати тіло, яке не є коректним JSON:
test('POST /users повертає 400 для некоректного JSON', async () => {
const { response, body } = await request('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: '{"name":'
});
assert.equal(response.status, 400);
assert.deepEqual(body, {
error: 'Request body must be valid JSON'
});
});Тест перевіряє не лише статус, а й точний формат повідомлення про помилку. Це важливо, якщо клієнтський застосунок використовує поле error для показу повідомлення користувачу.
Коректний JSON може все одно містити неправильні дані. Наприклад, у запиті відсутнє обов’язкове поле name:
test('POST /users повертає 422 без обов’язкового поля', async () => {
const { response, body } = await request('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
assert.equal(response.status, 422);
assert.deepEqual(body, {
error: 'The "name" field is required'
});
});У цьому випадку JSON синтаксично правильний, але не проходить перевірку даних. Тому API повертає 422 Unprocessable Entity, а не 400.
API має явно повідомляти, які методи доступні для маршруту:
test('DELETE /users повертає 405 і список дозволених методів', async () => {
const { response, body } = await request('/users', {
method: 'DELETE'
});
assert.equal(response.status, 405);
assert.equal(response.headers.get('allow'), 'GET, POST');
assert.deepEqual(body, {
error: 'Method not allowed'
});
});У цьому тесті перевіряються:
статус 405;
заголовок Allow;
payload із повідомленням про помилку.
Заголовок Allow є частиною контракту API: він пояснює клієнту, які методи підтримує endpoint.
Окремо потрібно перевіряти маршрути, яких не існує:
test('невідомий маршрут повертає 404', async () => {
const { response, body } = await request('/missing');
assert.equal(response.status, 404);
assert.deepEqual(body, {
error: 'Route not found'
});
});Такий тест допомагає переконатися, що сервер не повертає помилковий статус 200 для невідомого маршруту.
Файл test/api.test.js:
const test = require('node:test');
const assert = require('node:assert/strict');
const { createServer } = require('../api');
let server;
let baseUrl;
test.before(async () => {
server = createServer();
await new Promise((resolve) => {
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
baseUrl = `http://127.0.0.1:${address.port}`;
});
test.after(async () => {
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
});
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, options);
const text = await response.text();
return {
response,
body: text.length > 0 ? JSON.parse(text) : null
};
}
test('GET /health повертає статус сервера', async () => {
const { response, body } = await request('/health');
assert.equal(response.status, 200);
assert.equal(
response.headers.get('content-type'),
'application/json; charset=utf-8'
);
assert.deepEqual(body, {
status: 'ok'
});
});
test('GET /users повертає список користувачів', async () => {
const { response, body } = await request('/users');
assert.equal(response.status, 200);
assert.ok(Array.isArray(body));
assert.equal(
response.headers.get('content-type'),
'application/json; charset=utf-8'
);
});
test('POST /users створює користувача', async () => {
const { response, body } = await request('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Olena'
})
});
assert.equal(response.status, 201);
assert.equal(
response.headers.get('content-type'),
'application/json; charset=utf-8'
);
const location = response.headers.get('location');
assert.ok(location);
assert.match(location, /^\/users\/\d+$/);
assert.equal(typeof body.id, 'number');
assert.equal(body.name, 'Olena');
});
test('POST /users повертає 400 для некоректного JSON', async () => {
const { response, body } = await request('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: '{"name":'
});
assert.equal(response.status, 400);
assert.deepEqual(body, {
error: 'Request body must be valid JSON'
});
});
test('POST /users повертає 422 без обов’язкового поля', async () => {
const { response, body } = await request('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
assert.equal(response.status, 422);
assert.deepEqual(body, {
error: 'The "name" field is required'
});
});
test('DELETE /users повертає 405 і список дозволених методів', async () => {
const { response, body } = await request('/users', {
method: 'DELETE'
});
assert.equal(response.status, 405);
assert.equal(response.headers.get('allow'), 'GET, POST');
assert.deepEqual(body, {
error: 'Method not allowed'
});
});
test('невідомий маршрут повертає 404', async () => {
const { response, body } = await request('/missing');
assert.equal(response.status, 404);
assert.deepEqual(body, {
error: 'Route not found'
});
});Виконайте команду з кореня проєкту:
node --test test/api.test.jsNode.js запустить сервер, виконає HTTP-запити до нього, а потім зупинить сервер після завершення тестів.
Також можна запускати всі тести в проєкті:
node --testnode:assert/strictНайчастіше для HTTP API використовують такі перевірки:
assert.equal(response.status, 200);
assert.deepEqual(body, expectedPayload);
assert.ok(body);
assert.match(headerValue, /expected-pattern/);
assert.equal(typeof body.id, 'number');assert.equalПорівнює прості значення:
assert.equal(response.status, 201);
assert.equal(body.name, 'Olena');assert.deepEqualПорівнює об’єкти та масиви за вмістом:
assert.deepEqual(body, {
error: 'Route not found'
});assert.okПеревіряє, що значення є істинним:
assert.ok(response.headers.get('location'));assert.matchПеревіряє рядок регулярним виразом:
assert.match(location, /^\/users\/\d+$/);Для кожного endpoint зручно послідовно перевіряти:
правильний маршрут і метод;
очікуваний статус;
обов’язкові заголовки;
структуру payload;
конкретні значення важливих полів;
помилкові вхідні дані;
непідтримувані методи.
Наприклад, для POST /users мінімальний набір перевірок містить:
успішне створення з валідними даними;
помилку для некоректного JSON;
помилку для відсутнього name;
статус 201;
заголовок Location;
поля id і name у відповіді.
Такий тест недостатній:
assert.equal(response.status, 200);Сервер може повернути 200, але неправильний payload або неправильний Content-Type. Перевіряйте всі частини контракту, які важливі для клієнта.
response.json() без перевірки форматуЯкщо endpoint повернув не JSON, виклик response.json() завершиться помилкою. Для API, яке за контрактом завжди повертає JSON, це може бути бажаною помилкою тесту. У допоміжній функції з прикладу тіло спочатку читається як текст, а потім розбирається явно.
Якщо не викликати server.close(), Node.js може не завершити процес після тестів. Для цього використовують test.after.
Не варто в одному тесті очікувати дані, створені іншим тестом. Краще перевіряти властивості відповіді, які не залежать від порядку, або створювати потрібні дані безпосередньо в тесті.
Ідентифікатор або порт можуть змінюватися. Замість такого тесту:
assert.equal(body.id, 1);краще перевірити тип і наявність значення:
assert.equal(typeof body.id, 'number');
assert.ok(body.id > 0);HTTP-тести перевіряють метод, маршрут, статус, заголовки та payload.
Для вбудованого тестування Node.js можна використовувати node:test і node:assert/strict.
HTTP-запити зручно виконувати через глобальний fetch.
Успішні та помилкові сценарії потрібно тестувати окремо.
Для JSON-відповідей варто перевіряти Content-Type.
Статичні поля можна порівнювати через deepEqual, а динамічні значення — перевіряти за типом або шаблоном.
Сервер потрібно запускати перед тестами та коректно зупиняти після них.