25. Тестирование API

Тесты — это страховка от поломок. Без тестов каждый деплой — русская рулетка.
Инструменты
Заголовок раздела «Инструменты»npm install -D vitest supertest# vitest — быстрый тест-раннер (от создателей Vite)# supertest — HTTP запросы к Express без запуска сервераСтруктура тестов
Заголовок раздела «Структура тестов»src/├── routes/│ └── users.js├── services/│ └── userService.js└── app.js
tests/ # или __tests__/├── routes/│ └── users.test.js # интеграционные├── services/│ └── userService.test.js # юнит├── setup.js # глобальная настройка└── helpers.js # утилиты для тестовНастройка vitest
Заголовок раздела «Настройка vitest»import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globals: true, // describe, it, expect без импорта environment: 'node', setupFiles: ['./tests/setup.js'], coverage: { provider: 'v8', reporter: ['text', 'html'], exclude: ['node_modules/', 'tests/'], }, },});// tests/setup.js — выполняется перед всеми тестамиimport { beforeAll, afterAll, beforeEach } from 'vitest';
beforeAll(async () => { // Подключение к тестовой БД process.env.NODE_ENV = 'test'; process.env.DATABASE_URL = 'sqlite::memory:';});
beforeEach(async () => { // Очистка БД перед каждым тестом // await db.user.deleteMany();});
afterAll(async () => { // Закрываем соединения // await db.$disconnect();});Юнит-тесты
Заголовок раздела «Юнит-тесты»class UserService { constructor(db) { this.db = db; }
async create({ name, email, password }) { if (!email.includes('@')) { throw new Error('Некорректный email'); } if (password.length < 8) { throw new Error('Пароль слишком короткий'); } return this.db.user.create({ data: { name, email, password } }); }
async findById(id) { const user = await this.db.user.findUnique({ where: { id } }); if (!user) throw new Error('Пользователь не найден'); return user; }}
module.exports = UserService;import { describe, it, expect, vi } from 'vitest';import UserService from '../../src/services/userService';
describe('UserService', () => { // Мок базы данных const mockDb = { user: { create: vi.fn(), findUnique: vi.fn(), }, };
const service = new UserService(mockDb);
describe('create', () => { it('создаёт пользователя с корректными данными', async () => { mockDb.user.create.mockResolvedValue({ id: 1, ...userData });
const user = await service.create(userData);
expect(user.id).toBe(1); expect(user.name).toBe('Яша'); expect(mockDb.user.create).toHaveBeenCalledWith({ data: userData }); });
it('выбрасывает ошибку при некорректном email', async () => { await expect( service.create({ name: 'Яша', email: 'invalid', password: '12345678' }) ).rejects.toThrow('Некорректный email'); });
it('выбрасывает ошибку при коротком пароле', async () => { await expect( ).rejects.toThrow('Пароль слишком короткий'); }); });
describe('findById', () => { it('возвращает пользователя по ID', async () => { mockDb.user.findUnique.mockResolvedValue({ id: 1, name: 'Яша' });
const user = await service.findById(1);
expect(user.name).toBe('Яша'); });
it('выбрасывает ошибку если не найден', async () => { mockDb.user.findUnique.mockResolvedValue(null);
await expect(service.findById(999)).rejects.toThrow('не найден'); }); });});Интеграционные тесты с Supertest
Заголовок раздела «Интеграционные тесты с Supertest»import { describe, it, expect, beforeEach } from 'vitest';import request from 'supertest';import app from '../../src/app';
describe('GET /api/users', () => { it('возвращает список пользователей', async () => { const res = await request(app) .get('/api/users') .expect(200);
expect(res.body).toHaveProperty('data'); expect(Array.isArray(res.body.data)).toBe(true); });
it('поддерживает пагинацию', async () => { const res = await request(app) .get('/api/users?page=1&limit=5') .expect(200);
expect(res.body.pagination.limit).toBe(5); });});
describe('POST /api/users', () => { it('создаёт пользователя', async () => { const res = await request(app) .post('/api/users') .set('Authorization', `Bearer ${testToken}`) .expect(201);
expect(res.body.data.name).toBe('Яша'); expect(res.body.data).not.toHaveProperty('password'); });
it('возвращает 400 при невалидных данных', async () => { const res = await request(app) .post('/api/users') .send({ name: '' }) .set('Authorization', `Bearer ${testToken}`) .expect(400);
expect(res.body).toHaveProperty('error'); });
it('возвращает 401 без авторизации', async () => { await request(app) .post('/api/users') .expect(401); });
it('возвращает 409 при дубликате email', async () => { // Первый раз — успех await request(app) .post('/api/users') .set('Authorization', `Bearer ${testToken}`) .expect(201);
// Второй раз — конфликт await request(app) .post('/api/users') .set('Authorization', `Bearer ${testToken}`) .expect(409); });});
describe('GET /api/users/:id', () => { it('возвращает пользователя', async () => { const res = await request(app).get('/api/users/1').expect(200); expect(res.body.data.id).toBe(1); });
it('возвращает 404 для несуществующего', async () => { await request(app).get('/api/users/99999').expect(404); });});Моки и спаи
Заголовок раздела «Моки и спаи»import { vi, describe, it, expect } from 'vitest';
// Мок модуляvi.mock('../../src/db', () => ({ db: { user: { findMany: vi.fn().mockResolvedValue([{ id: 1, name: 'Яша' }]), create: vi.fn().mockResolvedValue({ id: 2, name: 'Новый' }), }, },}));
// Спай на функциюconst spy = vi.spyOn(console, 'log');someFunction();expect(spy).toHaveBeenCalledWith('ожидаемый вывод');spy.mockRestore();
// Фейковые таймерыvi.useFakeTimers();setTimeout(() => { /* ... */ }, 1000);vi.advanceTimersByTime(1000);vi.useRealTimers();Скрипты в package.json
Заголовок раздела «Скрипты в package.json»{ "scripts": { "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui" }}npm test # запуск тестовnpm run test:watch # перезапуск при измененияхnpm run test:coverage # отчёт покрытияnpx vitest run users.test # конкретный файлПрактика
Заголовок раздела «Практика»- Напиши юнит-тесты для сервиса с моком БД
- Напиши интеграционные тесты для CRUD роутов через supertest
- Протестируй авторизацию: запрос с/без токена, с истёкшим токеном
- Протестируй валидацию: пустое тело, невалидный email, короткий пароль
- Запусти coverage и добейся покрытия > 80%