Пошук уроків, статей та іншого контенту
Побудуєте E2E-тести, які перевіряють повний шлях запиту від HTTP-інтерфейсу до бази даних.
E2E-тест перевіряє функціональність через той самий інтерфейс, яким користується клієнт. Для backend-застосунку це зазвичай HTTP-запит:
клієнт надсилає HTTP-запит;
сервер розбирає маршрут і тіло запиту;
виконуються middleware та валідація;
викликається бізнес-логіка;
виконується запит до бази даних;
формується HTTP-відповідь.
На відміну від unit-тесту, E2E-тест не підміняє базу даних mock-об'єктом. Він працює зі справжнім тестовим екземпляром бази.
У цьому уроці використаємо:
Node.js;
Express;
Jest як test runner;
Supertest для HTTP-запитів;
SQLite як тестову базу даних;
better-sqlite3 для роботи зі SQLite.
Supertest дозволяє надсилати запити без відкриття реального TCP-порту. При цьому перевіряються маршрути, middleware, обробка HTTP і підключення до бази даних.
Застосунок матиме два endpoints:
POST /users — створення користувача;
GET /users/:id — отримання користувача за ідентифікатором.
Структура проєкту:
e2e-example/
├── package.json
├── src/
│ ├── app.js
│ └── db.js
└── tests/
└── users.e2e.test.jsВажливий принцип — не викликати app.listen() під час імпорту модуля застосунку. Це дозволяє тестам отримати Express-застосунок і самостійно керувати його життєвим циклом.
Створимо package.json:
{
"name": "e2e-backend-example",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "jest --runInBand"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"express": "^4.21.2"
},
"devDependencies": {
"jest": "^29.7.0",
"supertest": "^7.0.0"
}
}Встановимо залежності:
npm installПрапорець --runInBand запускає тести послідовно в одному процесі. Для тестової SQLite-бази це спрощує керування ресурсами й унеможливлює конфлікти між паралельними тестами.
Файл src/db.js відповідатиме за створення бази даних і схеми:
const Database = require("better-sqlite3");
function createDatabase(filename = ":memory:") {
const db = new Database(filename);
// Увімкнення перевірки зовнішніх ключів для цього підключення
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
return db;
}
module.exports = {
createDatabase
};Виклик createDatabase(":memory:") створює базу даних у пам'яті процесу.
Переваги такого підходу для E2E-тестів:
тестам не потрібен окремий файл;
дані автоматично зникають після закриття підключення;
тести не змінюють локальну або production-базу;
база швидко створюється.
Функція приймає ім'я файлу, тому для локальної розробки можна використати звичайну SQLite-базу:
const db = createDatabase("./development.sqlite");Файл src/app.js:
const express = require("express");
function createApp(db) {
const app = express();
app.use(express.json());
app.post("/users", (req, res) => {
const { email, name } = req.body;
if (
typeof email !== "string" ||
typeof name !== "string" ||
email.trim() === "" ||
name.trim() === ""
) {
return res.status(400).json({
error: "email and name are required"
});
}
try {
const result = db
.prepare(
`
INSERT INTO users (email, name)
VALUES (?, ?)
`
)
.run(email.trim(), name.trim());
const user = db
.prepare(
`
SELECT
id,
email,
name,
created_at AS createdAt
FROM users
WHERE id = ?
`
)
.get(result.lastInsertRowid);
return res.status(201).json(user);
} catch (error) {
if (error.code === "SQLITE_CONSTRAINT_UNIQUE") {
return res.status(409).json({
error: "email already exists"
});
}
throw error;
}
});
app.get("/users/:id", (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({
error: "id must be a positive integer"
});
}
const user = db
.prepare(
`
SELECT
id,
email,
name,
created_at AS createdAt
FROM users
WHERE id = ?
`
)
.get(id);
if (!user) {
return res.status(404).json({
error: "user not found"
});
}
return res.json(user);
});
app.use((error, req, res, next) => {
// Перетворення неочікуваних помилок на HTTP-відповідь
console.error(error);
if (res.headersSent) {
return next(error);
}
return res.status(500).json({
error: "internal server error"
});
});
return app;
}
module.exports = {
createApp
};Функція createApp приймає готове підключення до бази даних. Це називається dependency injection — залежність передається ззовні, а не створюється всередині маршруту.
Завдяки цьому production і test середовища можуть використовувати різні бази:
const productionDb = createDatabase("./production.sqlite");
const productionApp = createApp(productionDb);А в тестах:
const testDb = createDatabase(":memory:");
const testApp = createApp(testDb);Створимо tests/users.e2e.test.js:
const request = require("supertest");
const { createApp } = require("../src/app");
const { createDatabase } = require("../src/db");
describe("Users API", () => {
let db;
let app;
beforeAll(() => {
db = createDatabase(":memory:");
app = createApp(db);
});
afterEach(() => {
// Очищення стану після кожного тесту
db.prepare("DELETE FROM users").run();
});
afterAll(() => {
db.close();
});
test("POST /users створює користувача і зберігає його в базі", async () => {
const response = await request(app)
.post("/users")
.send({
email: "olena@example.com",
name: "Olena"
});
expect(response.status).toBe(201);
expect(response.body).toEqual(
expect.objectContaining({
id: expect.any(Number),
email: "olena@example.com",
name: "Olena",
createdAt: expect.any(String)
})
);
const storedUser = db
.prepare(
`
SELECT id, email, name
FROM users
WHERE id = ?
`
)
.get(response.body.id);
expect(storedUser).toEqual({
id: response.body.id,
email: "olena@example.com",
name: "Olena"
});
});
test("GET /users/:id повертає користувача, створеного через HTTP API", async () => {
const createResponse = await request(app)
.post("/users")
.send({
email: "andrii@example.com",
name: "Andrii"
});
const userId = createResponse.body.id;
const getResponse = await request(app).get(`/users/${userId}`);
expect(getResponse.status).toBe(200);
expect(getResponse.body).toEqual(
expect.objectContaining({
id: userId,
email: "andrii@example.com",
name: "Andrii",
createdAt: expect.any(String)
})
);
});
test("POST /users повертає 400 для невалідного тіла", async () => {
const response = await request(app)
.post("/users")
.send({
email: "",
name: "Unknown"
});
expect(response.status).toBe(400);
expect(response.body).toEqual({
error: "email and name are required"
});
const usersCount = db
.prepare("SELECT COUNT(*) AS count FROM users")
.get();
expect(usersCount.count).toBe(0);
});
test("POST /users повертає 409 для повторної електронної адреси", async () => {
const user = {
email: "duplicate@example.com",
name: "First user"
};
const firstResponse = await request(app)
.post("/users")
.send(user);
const secondResponse = await request(app)
.post("/users")
.send({
email: user.email,
name: "Second user"
});
expect(firstResponse.status).toBe(201);
expect(secondResponse.status).toBe(409);
expect(secondResponse.body).toEqual({
error: "email already exists"
});
const usersCount = db
.prepare("SELECT COUNT(*) AS count FROM users")
.get();
expect(usersCount.count).toBe(1);
});
test("GET /users/:id повертає 404 для відсутнього користувача", async () => {
const response = await request(app).get("/users/999");
expect(response.status).toBe(404);
expect(response.body).toEqual({
error: "user not found"
});
});
});Запуск тестів:
npm testУ першому тесті перевіряються одразу кілька рівнів:
HTTP-метод POST;
маршрут /users;
JSON-декодування тіла запиту;
валідація даних;
SQL INSERT;
генерація ідентифікатора;
SQL SELECT;
формування HTTP-відповіді;
фактичний стан бази даних.
Саме тому цей тест не є unit-тестом окремої функції. Він перевіряє повний шлях даних через застосунок.
Тести мають явно керувати ресурсами бази:
beforeAll — створити базу один раз для набору тестів;
afterEach — очистити дані після кожного тесту;
afterAll — закрити підключення.
beforeAll(() => {
db = createDatabase(":memory:");
app = createApp(db);
});
afterEach(() => {
db.prepare("DELETE FROM users").run();
});
afterAll(() => {
db.close();
});Очищення після кожного тесту забезпечує ізоляцію. Наприклад, тест перевірки дубліката не повинен впливати на тест отримання користувача.
Якщо таблиць багато, очищення можна винести в окрему функцію:
function clearDatabase(db) {
db.exec(`
DELETE FROM users;
`);
}Для складніших сценаріїв тестову базу можна створювати окремо для кожного тесту в beforeEach. Це дає сильнішу ізоляцію, але збільшує час виконання.
Основна перевірка E2E-тесту має проходити через HTTP API:
const response = await request(app)
.post("/users")
.send({
email: "user@example.com",
name: "Test user"
});
expect(response.status).toBe(201);Пряме читання з бази даних доречне як додаткова перевірка:
const storedUser = db
.prepare("SELECT email, name FROM users WHERE id = ?")
.get(response.body.id);
expect(storedUser).toEqual({
email: "user@example.com",
name: "Test user"
});Не варто будувати весь тест навколо внутрішніх SQL-запитів. Якщо тест перевіряє тільки виклик db.prepare, він не гарантує, що HTTP-маршрут, middleware і серіалізація працюють правильно.
Корисне правило:
для поведінки клієнта — перевіряти HTTP-відповідь;
для критичної персистентності — додатково перевіряти запис у базі;
внутрішню реалізацію перевіряти лише тоді, коли це необхідно для конкретного контракту.
Supertest може працювати без ручного виклику listen(). Це зручно, оскільки тестам не потрібно вибирати порт і завершувати сервер.
Якщо потрібно перевірити саме запуск HTTP-сервера, його можна створити у beforeAll:
const http = require("node:http");
const request = require("supertest");
let server;
beforeAll(() => {
server = http.createServer(app);
server.listen(0);
});
afterAll(async () => {
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
});
test("запит через реальний HTTP-сервер", async () => {
const response = await request(server)
.get("/users/999");
expect(response.status).toBe(404);
});Порт 0 означає, що операційна система вибере вільний порт. Для більшості тестів маршрутів варіант request(app) простіший і швидший, а реальний сервер варто запускати окремо, коли потрібно перевірити саме процес запуску.
Тестові дані мають бути:
зрозумілими з самого тесту;
незалежними від порядку виконання;
унікальними, якщо база має обмеження UNIQUE;
достатніми для перевірки конкретного сценарію.
Добре:
const user = {
email: "duplicate@example.com",
name: "First user"
};Погано:
const user = getRandomUserFromSomeGlobalFixture();Непередбачувані fixtures ускладнюють діагностику помилок. Якщо дані випадкові, тест може впасти з різних причин і не показати, який саме сценарій порушено.
Не використовуйте production-базу для E2E-тестів. Навіть якщо тест видаляє створені записи, це створює ризик:
пошкодження реальних даних;
конфліктів між тестами;
залежності від поточного стану бази;
випадкової відправки листів або запуску інших побічних ефектів.
app.listen() під час імпортуНевдалий варіант:
const app = express();
app.listen(3000);
module.exports = app;Такий код може призвести до:
зайнятого порту;
конфліктів між тестами;
незавершеного Node.js-процесу;
складного керування життєвим циклом сервера.
Краще експортувати фабрику або сам Express-застосунок без запуску сервера:
module.exports = {
createApp
};Тестове середовище повинно мати окреме сховище. Для цього прикладу використовується SQLite в пам'яті:
db = createDatabase(":memory:");Якщо один тест залишає записи, наступний може залежати від порядку виконання. Завжди визначайте стратегію очищення:
afterEach(() => {
db.prepare("DELETE FROM users").run();
});Така перевірка недостатня:
expect(response.status).toBe(201);Вона не гарантує, що відповідь містить правильні дані. Перевіряйте також тіло:
expect(response.body.email).toBe("olena@example.com");
expect(response.body.id).toEqual(expect.any(Number));Якщо замінити базу mock-об'єктом, тест більше не перевіряє:
правильність SQL;
схему таблиць;
обмеження UNIQUE;
фактичне збереження даних;
сумісність коду з драйвером бази.
Mock-об'єкти корисні в unit-тестах, але E2E-тест має використовувати реальну тестову базу.
Не слід припускати, що перший створений користувач завжди матиме id = 1. Надійніше отримати ідентифікатор із відповіді:
const userId = createResponse.body.id;
const response = await request(app).get(`/users/${userId}`);Незакритий db може залишити відкриті ресурси й завадити завершенню Jest:
afterAll(() => {
db.close();
});E2E-тест backend-застосунку перевіряє повний шлях від HTTP-запиту до бази даних і назад.
Express-застосунок краще створювати фабрикою, яка отримує підключення до бази через параметр.
request(app) із Supertest дозволяє тестувати HTTP-інтерфейс без ручного запуску TCP-сервера.
SQLite в режимі :memory: підходить для швидких і ізольованих тестів.
Тестова база не повинна бути пов'язана з production-базою.
Дані потрібно очищати між тестами, а підключення — закривати після завершення набору.
E2E-тести мають перевіряти не лише статус відповіді, а й тіло відповіді та критичні зміни в базі даних.