Перейти к содержимому

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.config.js
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();
});
services/userService.js
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;
tests/services/userService.test.js
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 () => {
const userData = { name: 'Яша', email: '[email protected]', password: '12345678' };
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(
service.create({ name: 'Яша', email: '[email protected]', password: '123' })
).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('не найден');
});
});
});
tests/routes/users.test.js
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')
.send({ name: 'Яша', email: '[email protected]', password: 'password123' })
.set('Authorization', `Bearer ${testToken}`)
.expect(201);
expect(res.body.data.name).toBe('Яша');
expect(res.body.data.email).toBe('[email protected]');
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')
.send({ name: 'Яша', email: '[email protected]', password: '12345678' })
.expect(401);
});
it('возвращает 409 при дубликате email', async () => {
// Первый раз — успех
await request(app)
.post('/api/users')
.send({ name: 'A', email: '[email protected]', password: '12345678' })
.set('Authorization', `Bearer ${testToken}`)
.expect(201);
// Второй раз — конфликт
await request(app)
.post('/api/users')
.send({ name: 'B', email: '[email protected]', password: '12345678' })
.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();
{
"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 # конкретный файл
  1. Напиши юнит-тесты для сервиса с моком БД
  2. Напиши интеграционные тесты для CRUD роутов через supertest
  3. Протестируй авторизацию: запрос с/без токена, с истёкшим токеном
  4. Протестируй валидацию: пустое тело, невалидный email, короткий пароль
  5. Запусти coverage и добейся покрытия > 80%