Skip to main content

Як протестувати додаток Express.js?

Тестування додатку Express.js означає написання автоматизованих перевірок для маршрутів, middleware та обробників за допомогою Supertest, який симулює HTTP-запити до живого екземпляра додатку без запуску реального сервера на порту.

Теорія

Коротко

  • Supertest виступає фіктивним HTTP-клієнтом: піднімає сервер в пам'яті, надсилає запити і дозволяє перевіряти статус-коди, заголовки та тіло відповіді
  • Розділяй app.js (без listen()) і server.js (запускає сервер) - це єдина структурна зміна, яка робить все інше можливим
  • Юніт-тести мокають залежності; інтеграційні тести запускають весь стек через реальні middleware-ланцюжки
  • Supertest підходить для будь-якого тесту маршруту; мокай базу даних після того, як переконався що HTTP-прошарок працює коректно
  • Async-обробники без глобального error middleware викликають тихі падіння тестів

Базовий приклад

js
// 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

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

js
// Неправильно - тест завершується до отримання відповіді 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

js
// 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 аутентифікації

js
// Неправильно - 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.

Приклади

Базовий: тестування публічного маршруту

js
// 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 разом з маршрутом

js
// 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.

Просунутий: мок сервісного шару, авторизація та обробка помилок

js
// Мокаємо сервіс до імпорту додатку 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 замість зависаючого сокета.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?