Як протестувати додаток Express.js?
Тестування додатку Express.js означає написання автоматизованих перевірок для маршрутів, middleware та обробників за допомогою Supertest, який симулює HTTP-запити до живого екземпляра додатку без запуску реального сервера на порту.
Теорія
Коротко
- Supertest виступає фіктивним HTTP-клієнтом: піднімає сервер в пам'яті, надсилає запити і дозволяє перевіряти статус-коди, заголовки та тіло відповіді
- Розділяй
app.js(безlisten()) іserver.js(запускає сервер) - це єдина структурна зміна, яка робить все інше можливим - Юніт-тести мокають залежності; інтеграційні тести запускають весь стек через реальні middleware-ланцюжки
- Supertest підходить для будь-якого тесту маршруту; мокай базу даних після того, як переконався що HTTP-прошарок працює коректно
- Async-обробники без глобального error middleware викликають тихі падіння тестів
Базовий приклад
// app.js - експортуємо app, listen() тут не викликаємо
const express = require('express');
const app = express();
app.use(express.json());
app.get('/users', (req, res) => res.json({ users: [] }));
module.exports = app;
// app.test.js
const request = require('supertest');
const app = require('./app');
test('GET /users повертає 200', async () => {
const res = await request(app).get('/users');
expect(res.status).toBe(200); // HTTP статус
expect(res.body).toEqual({ users: [] }); // тіло відповіді
});Supertest імпортує об'єкт app напряму, піднімає сервер в пам'яті на випадковому порту і закриває його після тесту. Жодних конфліктів портів, жодного app.listen(3000).
Головна різниця: юніт-тести проти інтеграційних
Юніт-тести викликають функції-обробники напряму з мокнутими об'єктами req/res. Швидко, але повністю пропускають middleware-стек Express. Інтеграційні тести через Supertest пропускають запит через кожен app.use() по черзі - саме так це відбувається у продакшені. Більшість багів Express інтеграційний тест ловить там, де юніт-тест нічого не помічає.
Коли що використовувати
- Поведінка одного маршруту: Supertest, перевіряємо статус і тіло
- Middleware-ланцюжок (авторизація, логування, валідація): інтеграційний тест через повний pipeline
- Маршрут з базою даних: мокаємо сервісний шар через
jest.mock(), Supertest зверху - Захищені маршрути: генеруємо реальний JWT у тесті, передаємо через заголовок
Authorization - E2E сценарії: Cypress або Playwright, не Supertest (у Supertest немає браузера)
- CI/CD pipeline: тільки Supertest, живі порти не потрібні
Як Supertest працює зсередини
Коли викликаєш request(app), Supertest передає екземпляр Express у Node http.createServer() і прив'язує його до випадкового порту (наприклад, 52341). Запит проходить через реальний роутер і middleware-стек Express, V8 виконує кожен колбек, і відповідь повертається через сокет тест-агента. Після перевірки Supertest викликає server.close(). Жодного мережевого I/O, жодного порту 3000.
Стратегії для тестової бази даних
| Підхід | Швидкість | Ізоляція | Реалістичність |
|---|---|---|---|
| Мок сервісного шару | Найшвидший | Висока | Низька |
| SQLite в пам'яті | Швидка | Середня | Середня |
| Окрема тестова БД | Повільніша | Низька (треба очищення) | Висока |
| Docker-контейнер | Найповільніший | Висока | Найвища |
Починай з мокування сервісного шару. Коли впевнишся у коректності HTTP-прошарку, додавай SQLite в пам'яті для критичних шляхів.
Типові помилки
Помилка: app.listen() всередині app.js
// Неправильно - блокує порт під час тестів
const app = express();
app.listen(3000);
module.exports = app;
// Виправлення: app.js (без listen) + server.js
// server.js
const app = require('./app');
app.listen(3000);Коли app.js викликає listen(), тести або падають з EADDRINUSE, або Supertest піднімає другий сервер і запити йдуть не туди.
Помилка: відсутній await у Supertest
// Неправильно - тест завершується до отримання відповіді
test('GET /users', () => {
request(app).get('/users').expect(200); // немає await, немає return
});
// Виправлення
test('GET /users', async () => {
await request(app).get('/users').expect(200);
});Тест проходить «успішно» тому що Jest вважає його завершеним до того, як Promise резолвиться. Це одне з найпоширеніших джерел помилкових позитивів.
Помилка: async-обробники без глобального error middleware
// app.js - тихо падає в тестах
app.get('/data', async (req, res) => {
throw new Error('DB fail'); // необроблена помилка, відповідь не надсилається
});
// Виправлення: додати перед module.exports
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});Без 4-аргументного error handler Express 4 залишає сокет відкритим і тест зависає. Близько 40% розробників пропускають цей обробник саме для async-маршрутів.
Помилка: тестування обробника в обхід middleware аутентифікації
// Неправильно - auth middleware не виконується
const handler = require('./handlers/profile');
handler({ user: mockUser }, res, next);
// Правильно - тест через повний ланцюжок
const token = jwt.sign({ id: 1 }, process.env.JWT_SECRET);
await request(app)
.get('/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);Помилка: реальна база даних у тестах
Будь-який тест, що пише у продакшн або спільну БД, робить сюїт залежним від порядку виконання. Використовуй jest.mock('./db') або in-memory альтернативу. Один брудний запис від тесту A ламає тест B способами, які болісно дебажити.
Де зустрічається у реальних проектах
- NestJS використовує той самий підхід:
supertest(app.getHttpAdapter().getInstance()) - Express + Prisma: мокаємо Prisma-клієнт через
jest-mock-extended, Supertest для маршрутів - Strapi CMS: обгортає Supertest для тестування власних API-контролерів
- Мікросервісна архітектура: 60% юніт-тестів на обробниках, 30% інтеграційних з Supertest і мокнутим Redis, 10% контрактних тестів через Pact.js
Додаткові питання
Q: Чому Supertest, а не прямі http.request()?
A: Supertest ланцюжком додає .expect() перевірки, автоматично закриває сервер після кожного тесту і сам парсить JSON. З http.request() усе це треба робити вручну.
Q: Як тестувати middleware в ізоляції?
A: Створи мінімальний Express-додаток тільки для тесту, підключи лише цей middleware і бий по ньому через Supertest. Це виключає вплив інших middleware з основного додатку.
Q: Як мокати зовнішні HTTP-виклики всередині обробників?
A: Через nock: nock('https://api.stripe.com').post('/charges').reply(200, { id: 'ch_1' }). Він перехоплює вихідні запити на рівні Node http до того, як вони йдуть у мережу.
Q: Яка різниця між request(app) і request(server)?
A: request(app) створює тимчасовий сервер в пам'яті для кожного тесту. request(server) підключається до вже запущеного сервера на реальному порту. Для інтеграційних тестів завжди використовуй request(app).
Q: У мікросервісі зі 100+ маршрутами і кешуванням Redis як структурувати тести для 90% покриття без уповільнення?
A: Шарувата структура: 60% юніт-тестів на обробниках (Jest, без HTTP), 30% інтеграційних (Supertest з ioredis-mock), 10% контрактних (Pact.js для меж між сервісами). Запускай з --maxWorkers=4 для паралелізму. Дані для тестів генеруй через фабричні функції, не фікстури. Це тримає медіанний час інтеграційного тесту до 50ms.
Приклади
Базовий: тестування публічного маршруту
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
module.exports = app;
// health.test.js
const request = require('supertest');
const app = require('./app');
test('GET /health повертає status ok', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(typeof res.body.uptime).toBe('number');
});Health check endpoint зазвичай перший тест який пишуть. Він підтверджує що Supertest підключений правильно і модуль app експортується коректно.
Середній: тестування middleware разом з маршрутом
// routes/users.js
const router = require('express').Router();
// Timestamp middleware - виконується перед кожним обробником у роутері
router.use((req, res, next) => {
req.timestamp = Date.now();
next();
});
router.get('/', (req, res) => {
res.json({ users: [], requestedAt: req.timestamp });
});
module.exports = router;
// test/users.test.js
const request = require('supertest');
const app = require('../app');
describe('Users API', () => {
test('GET /users містить timestamp з middleware', async () => {
const res = await request(app).get('/users');
expect(res.status).toBe(200);
expect(res.body.requestedAt).toBeDefined(); // middleware виконався
expect(res.body.requestedAt).toBeGreaterThan(0); // реальний timestamp
expect(Array.isArray(res.body.users)).toBe(true);
});
});Цей тест підтверджує що middleware і обробник маршруту працюють разом. Юніт-тест на одному обробнику ніколи б не виявив зламаний виклик next() у middleware.
Просунутий: мок сервісного шару, авторизація та обробка помилок
// Мокаємо сервіс до імпорту додатку
jest.mock('../services/users.service', () => ({
findById: jest.fn(),
create: jest.fn(),
}));
const request = require('supertest');
const jwt = require('jsonwebtoken');
const app = require('../app');
const usersService = require('../services/users.service');
const token = jwt.sign({ id: 1, role: 'admin' }, 'test-secret');
describe('POST /users', () => {
test('створює користувача при авторизованому запиті', async () => {
usersService.create.mockResolvedValue({ id: 5, name: 'Alice' });
const res = await request(app)
.post('/users')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Alice', email: 'alice@example.com' });
expect(res.status).toBe(201);
expect(res.body.name).toBe('Alice');
expect(usersService.create).toHaveBeenCalledWith({
name: 'Alice',
email: 'alice@example.com',
});
});
test('повертає 500 коли сервіс кидає помилку', async () => {
usersService.create.mockRejectedValue(new Error('DB connection lost'));
const res = await request(app)
.post('/users')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Bob', email: 'bob@example.com' });
expect(res.status).toBe(500);
expect(res.body).toHaveProperty('error');
});
});Мок замінює реальний виклик до БД, тому тест виконується менш ніж за 10ms і ніколи не торкається бази даних. Другий тест перевіряє що глобальний error middleware ловить async-помилки і повертає коректний 500 замість зависаючого сокета.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.