Seu teste unitário passou, o deploy foi feito, e mesmo assim a rota /checkout quebrou em produção. Isso acontece porque unit test isolado não pega o que acontece quando middleware, banco e rota conversam entre si. É exatamente esse buraco que teste de integração fecha.
Índice
- Por que testes de integração importam
- Configurando Jest e Supertest
- Estrutura básica de um teste de integração
- Testando rotas autenticadas
- Banco de dados: setup e teardown
- Erros comuns que quebram o CI
- Boas práticas para suites rápidas e estáveis
- FAQ
- Próximos passos
Por que testes de integração importam
Teste unitário verifica uma função isolada. Teste de integração verifica o comportamento real da API: request entra, passa por middleware, toca o banco, retorna resposta. É a camada que mais se aproxima do que o usuário vive.
Tem gente que acha isso redundante — "se cada função tem teste unitário, a integração é consequência". Não é. Um serviço pode estar perfeito isoladamente e quebrar porque o middleware de autenticação está rodando antes do parser de body, ou porque a transação do banco não fecha corretamente entre requests. Esse tipo de bug só aparece quando as peças se encontram.
Configurando Jest e Supertest
Instalação básica, sem segredo:
npm install --save-dev jest supertest
O ponto de atenção está em como você exporta o app. Supertest precisa de uma instância do Express (ou Fastify, com adaptador) sem que o servidor já esteja escutando em uma porta.
// ❌ app.js — servidor já sobe ao importar
const app = express();
app.listen(3000);
module.exports = app;
// ✓ app.js — separa app de servidor
const app = express();
module.exports = app;
// server.js — só aqui o listen acontece
const app = require('./app');
app.listen(3000);
Com essa separação, o teste importa app.js direto e o Supertest cria as conexões dele mesmo, sem disputar porta com nada.
Estrutura básica de um teste de integração
Um teste de rota simples já mostra o padrão que você vai repetir centenas de vezes:
const request = require('supertest');
const app = require('../app');
describe('GET /users/:id', () => {
it('retorna 200 e os dados do usuário', async () => {
const res = await request(app).get('/users/1');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('id', 1);
expect(res.body).toHaveProperty('email');
});
it('retorna 404 para usuário inexistente', async () => {
const res = await request(app).get('/users/99999');
expect(res.status).toBe(404);
});
});
Repare que cada it testa um comportamento, não uma função. É a diferença de mentalidade entre unit e integração: aqui você valida contrato de API, status code, shape do payload — não a implementação interna.
💡 Dica: sempre teste o caminho feliz e pelo menos um caminho de erro por rota. Cobertura de erro é o que geralmente falta em suites que "passam" mas não protegem nada.
Testando rotas autenticadas
Rota protegida por JWT ou sessão exige um passo a mais: gerar um token válido antes de bater na rota.
const jwt = require('jsonwebtoken');
function gerarToken(userId) {
return jwt.sign({ id: userId }, process.env.JWT_SECRET, { expiresIn: '1h' });
}
describe('GET /orders (autenticado)', () => {
it('retorna 401 sem token', async () => {
const res = await request(app).get('/orders');
expect(res.status).toBe(401);
});
it('retorna 200 com token válido', async () => {
const token = gerarToken(1);
const res = await request(app)
.get('/orders')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
});
});
Aqui mora um erro clássico: usar o mesmo JWT_SECRET do .env de desenvolvimento no ambiente de teste. Se alguém trocar o secret em produção sem atualizar o .env.test, os testes continuam passando e mentindo sobre a segurança real da aplicação. Separe as envs.
Banco de dados: setup e teardown
Isso apareceu num PR meu em 2022: os testes passavam sozinhos, mas falhavam em bloco no CI. O motivo era simples — um teste criava um usuário e nunca limpava, e o próximo teste dependia de a tabela estar vazia. O revisor só comentou: "por quê?". Boa pergunta, e a resposta era falta de isolamento.
// ❌ sem isolamento entre testes
beforeAll(async () => {
await db.connect();
});
// nenhum teardown, dados vazam de um teste pro outro
// ✓ isolamento real por teste
beforeEach(async () => {
await db.migrate.latest();
});
afterEach(async () => {
await db.migrate.rollback();
});
afterAll(async () => {
await db.destroy();
});
Para suites maiores, migrar e reverter o schema inteiro a cada teste fica lento. Uma alternativa mais rápida é envolver cada teste em uma transação que sempre sofre rollback no final — o banco nunca vê o dado persistido de fato.
Erros comuns que quebram o CI
| Erro | Sintoma | Correção |
|---|---|---|
| Testes dependem de ordem de execução | Passa local, falha no CI em paralelo | Isolar estado por teste, nunca compartilhar fixture mutável |
Porta fixa no app.listen durante teste | EADDRINUSE intermitente | Separar app.js de server.js |
| Mock de banco genérico demais | Teste "verde" mas bug real passa batido | Usar banco real (ou in-memory) para integração, mock só em unit |
| Timeout curto demais no Jest | Falha aleatória em CI mais lento | Ajustar testTimeout para operações de I/O |
| Variáveis de ambiente do dev vazando pro teste | Teste usa secret ou URL errada | .env.test dedicado, carregado via dotenv-cli |
Ambiente local não reproduz esse tipo de falha porque geralmente roda os testes sequencialmente e sozinho. CI roda em paralelo, com recursos mais escassos — é onde a falta de isolamento aparece.
Boas práticas para suites rápidas e estáveis
Algumas decisões pagam dividendo conforme a suite cresce:
- Banco de teste dedicado, nunca aponte para o banco de dev por engano.
jest.config.jscomtestEnvironment: 'node'explícito — evita surpresa se alguém instalar um preset de frontend no projeto sem querer.- Agrupe testes por recurso (
users.test.js,orders.test.js), não por camada técnica. - Rode a suite de integração em um worker separado da suite unitária no CI, já que o custo de I/O é bem diferente.
// jest.config.js
module.exports = {
testEnvironment: 'node',
testTimeout: 10000,
setupFilesAfterEnv: ['./tests/setup.js'],
};
Por que isso importa? Porque uma suite lenta é uma suite que os devs param de rodar localmente — e aí o CI vira a primeira e única linha de defesa.
FAQ
Preciso de um banco real ou posso usar SQLite in-memory? Depende do quanto seu banco de produção usa recursos específicos (JSONB, full-text search, triggers). Se usa, teste contra o mesmo motor em Docker. SQLite in-memory é rápido, mas mascara diferenças de comportamento entre bancos.
Supertest funciona com Fastify?
Sim, mas a chamada muda um pouco: em vez de passar a instância do Express direto, você usa app.server após o fastify.ready(), ou a lib fastify-supertest, dependendo da versão.
Vale a pena mockar chamadas HTTP externas nos testes de integração?
Sim, sempre. Integração aqui significa integração entre as camadas da sua aplicação, não com serviços de terceiros. Use nock ou msw para interceptar chamadas externas e manter o teste determinístico.
Como evito que os testes demorem demais no CI? Rode em paralelo por arquivo (Jest já faz isso por padrão), use transação com rollback em vez de migração completa por teste, e separe suites lentas (integração) das rápidas (unit) em jobs distintos.
Testes de integração substituem testes end-to-end? Não. Integração cobre a API sem navegador; e2e cobre o fluxo completo incluindo frontend. São camadas complementares, não substitutas.
Próximos passos
Comece pelas rotas mais críticas — autenticação, pagamento, qualquer coisa que mexa em dinheiro ou dado sensível. Depois expanda para o resto do CRUD. E antes de escrever mais um teste unitário para uma função que só orquestra chamadas, pergunte: isso não seria melhor coberto por um teste de integração?
npx jest --coverage --testPathPattern=integration
Rode esse comando, olhe o relatório de cobertura, e ataque primeiro as rotas com 0%.