Node.js
Testes em Node.js com Jest e Supertest: Guia Prático
10 de junho de 2024·Paulo de Paula
Testes automatizados não são sobre cobertura de 100% — são sobre confiança para refatorar e entregar. Uma suíte bem estruturada pega regressões cedo e documenta o comportamento esperado do sistema.
A pirâmide de testes para APIs
/\
/e2e\ ← poucos, lentos, testam fluxos completos
/------\
/integração\ ← médios, testam camadas reais (Express + DB)
/------------\
/ unitários \ ← muitos, rápidos, testam lógica isolada
/________________\Para uma API Node.js, a regra prática:
- Unitários: lógica de negócio, transformações, validações
- Integração: endpoints reais com banco de teste
- E2E: fluxos críticos contra ambiente staging
Configuração do Jest com TypeScript
npm install -D jest @types/jest ts-jest supertest @types/supertest// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.spec.ts', '**/*.test.ts'],
coverageDirectory: 'coverage',
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/server.ts', // entrypoint, sem lógica
'!src/migrations/**',
],
coverageThresholds: {
global: {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
},
// Roda setup antes de cada arquivo de teste
setupFilesAfterFramework: ['<rootDir>/src/tests/setup.ts'],
};Teste unitário: lógica isolada
// src/services/pedidoService.ts
export class PedidoService {
constructor(private readonly repo: PedidoRepository) {}
async calcularTotal(pedidoId: string): Promise<number> {
const pedido = await this.repo.findById(pedidoId);
if (!pedido) throw new Error('Pedido não encontrado');
return pedido.itens.reduce(
(acc, item) => acc + item.preco * item.quantidade,
0
);
}
}
// src/services/pedidoService.spec.ts
import { PedidoService } from './pedidoService';
describe('PedidoService', () => {
const mockRepo = {
findById: jest.fn(),
};
const service = new PedidoService(mockRepo as any);
beforeEach(() => jest.clearAllMocks());
describe('calcularTotal', () => {
it('soma preço × quantidade de cada item', async () => {
mockRepo.findById.mockResolvedValue({
id: '1',
itens: [
{ preco: 10, quantidade: 2 },
{ preco: 5, quantidade: 3 },
],
});
const total = await service.calcularTotal('1');
expect(total).toBe(35); // 20 + 15
expect(mockRepo.findById).toHaveBeenCalledWith('1');
});
it('lança erro quando pedido não existe', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.calcularTotal('inexistente'))
.rejects.toThrow('Pedido não encontrado');
});
it('retorna 0 para pedido sem itens', async () => {
mockRepo.findById.mockResolvedValue({ id: '1', itens: [] });
const total = await service.calcularTotal('1');
expect(total).toBe(0);
});
});
});Mock vs Spy vs Stub
// Mock: substitui completamente a implementação
const emailService = {
enviar: jest.fn().mockResolvedValue(undefined),
};
// Spy: observa uma função real sem substituí-la
const spy = jest.spyOn(emailService, 'enviar');
await emailService.enviar('teste@email.com', 'Assunto', 'Corpo');
expect(spy).toHaveBeenCalledTimes(1);
// Stub: implementação controlada para um cenário específico
jest.spyOn(Math, 'random').mockReturnValue(0.5); // sempre 0.5
Teste de integração com Supertest
// src/tests/setup.ts
import { prisma } from '../lib/prisma';
beforeAll(async () => {
// Conecta ao banco de teste (DATABASE_URL_TEST no .env.test)
await prisma.$connect();
});
afterAll(async () => {
await prisma.$disconnect();
});
// Limpa tabelas entre testes para isolamento
afterEach(async () => {
await prisma.$transaction([
prisma.comentario.deleteMany(),
prisma.post.deleteMany(),
prisma.perfil.deleteMany(),
prisma.usuario.deleteMany(),
]);
});// src/routes/usuarios.spec.ts
import request from 'supertest';
import { app } from '../app';
import { prisma } from '../lib/prisma';
describe('POST /usuarios', () => {
it('cria usuário com dados válidos', async () => {
const res = await request(app)
.post('/usuarios')
.send({ email: 'teste@email.com', nome: 'Teste', senha: 'Senha@123' })
.expect(201);
expect(res.body).toMatchObject({
email: 'teste@email.com',
nome: 'Teste',
});
expect(res.body).not.toHaveProperty('senha');
expect(res.body).not.toHaveProperty('senhaHash');
// Verifica que foi salvo no banco real
const usuario = await prisma.usuario.findUnique({
where: { email: 'teste@email.com' },
});
expect(usuario).not.toBeNull();
});
it('retorna 409 para email duplicado', async () => {
await prisma.usuario.create({
data: { email: 'existe@email.com', nome: 'Existe', senhaHash: 'x' },
});
await request(app)
.post('/usuarios')
.send({ email: 'existe@email.com', nome: 'Outro', senha: 'Senha@123' })
.expect(409);
});
it('retorna 400 para email inválido', async () => {
const res = await request(app)
.post('/usuarios')
.send({ email: 'nao-e-email', nome: 'Teste', senha: 'Senha@123' })
.expect(400);
expect(res.body.errors).toContainEqual(
expect.objectContaining({ field: 'email' })
);
});
});
describe('GET /usuarios/:id', () => {
it('retorna 401 sem token', async () => {
await request(app).get('/usuarios/qualquer-id').expect(401);
});
it('retorna usuário autenticado', async () => {
const usuario = await prisma.usuario.create({
data: { email: 'auth@email.com', nome: 'Auth', senhaHash: 'x' },
});
const loginRes = await request(app)
.post('/auth/login')
.send({ email: 'auth@email.com', senha: 'senha-correta' });
const { accessToken } = loginRes.body;
const res = await request(app)
.get(`/usuarios/${usuario.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(res.body.id).toBe(usuario.id);
});
});Banco de dados de teste separado
# .env.test
DATABASE_URL="postgresql://dev:dev@localhost:5432/devdb_test"// package.json
{
"scripts": {
"test": "dotenv -e .env.test -- jest",
"test:watch": "dotenv -e .env.test -- jest --watch",
"test:coverage": "dotenv -e .env.test -- jest --coverage",
"test:ci": "dotenv -e .env.test -- jest --ci --coverage --forceExit"
}
}Nunca mocke o banco nos testes de integração. Mocks de banco garantem que o código compila, não que as queries funcionam. Constraints, índices, tipos de coluna, comportamento do ORM — nada disso é testado com mock.
Cobertura e integração com CI/CD
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: devdb_test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://dev:dev@localhost:5432/devdb_test
- run: npm run test:ci
env:
DATABASE_URL: postgresql://dev:dev@localhost:5432/devdb_test
JWT_ACCESS_SECRET: test-secret-access
JWT_REFRESH_SECRET: test-secret-refresh
- uses: codecov/codecov-action@v4
with:
file: ./coverage/coverage-final.jsonTestes que passam no CI mas falham em produção geralmente têm mocks demais. Se o problema for velocidade, use --runInBand para rodar em série e identifique os testes lentos antes de mockar o banco.