Fundamentos de Testes de Integração em Node.js
Testes de integração validam o comportamento de múltiplos componentes trabalhando juntos, diferentemente de testes unitários que isolam funções individuais. Em Node.js, especialmente com aplicações Express, você precisará testar rotas HTTP, middlewares e banco de dados em conjunto. Esse tipo de teste é crítico porque revela problemas que não aparecem em testes unitários: falhas de comunicação entre camadas, erros de transação, timeouts e inconsistências de estado.
A estratégia que usaremos combina três pilares: Supertest para simular requisições HTTP, um banco de dados real (não mock) para garantir comportamento autêntico, e fixtures para preparar dados consistentes entre testes. Essa abordagem detecta problemas reais de produção e oferece confiança muito maior do que mockar tudo.
Supertest: Testando Rotas HTTP
Supertest é a ferramenta padrão para testar aplicações Express/Node.js. Ele permite fazer requisições HTTP sem precisar de um servidor real na porta, capturando resposta completa com status, headers e body. A instalação é simples: npm install --save-dev supertest. Vamos a um exemplo prático real.
Exemplo Básico com Express
// src/app.js
const express = require('express');
const app = express();
app.use(express.json());
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Nome e email são obrigatórios' });
}
// Aqui virá a lógica do banco
res.status(201).json({ id: 1, name, email });
});
app.get('/users/:id', (req, res) => {
res.json({ id: req.params.id, name: 'João Silva', email: 'joao@test.com' });
});
module.exports = app;
// test/users.test.js
const request = require('supertest');
const app = require('../src/app');
describe('POST /users', () => {
it('deve criar um usuário com dados válidos', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Ana', email: 'ana@test.com' });
expect(res.status).toBe(201);
expect(res.body).toHaveProperty('id');
expect(res.body.email).toBe('ana@test.com');
});
it('deve retornar 400 quando email está faltando', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Carlos' });
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
});
describe('GET /users/:id', () => {
it('deve retornar um usuário pelo ID', async () => {
const res = await request(app).get('/users/123');
expect(res.status).toBe(200);
expect(res.body.id).toBe('123');
});
});
Repare que usamos async/await — Supertest retorna Promises. Cada teste faz uma requisição, valida status e resposta. A função send() envia JSON, expect() vem do Jest (framework padrão).
Banco Real vs. Mocks: A Integração Verdadeira
Mockar o banco de dados é tentador, mas mascara bugs. Um banco real revela race conditions, problemas de índice, locks e constraints que nunca apareceriam com mocks. A melhor prática é usar um banco de teste separado (não o de produção).
Setup com SQLite ou PostgreSQL
Para desenvolvimento rápido, SQLite é ideal. Para ambientes mais robustos, PostgreSQL é preferível. Vamos usar SQLite aqui por simplicidade:
// src/database.js
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
let db;
function initDatabase(filePath = ':memory:') {
db = new sqlite3.Database(filePath);
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, (err) => {
if (err) reject(err);
else resolve();
});
});
});
}
function getDatabase() {
return db;
}
module.exports = { initDatabase, getDatabase };
// src/app.js (versão 2 com banco real)
const express = require('express');
const { getDatabase } = require('./database');
const app = express();
app.use(express.json());
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Nome e email obrigatórios' });
}
const db = getDatabase();
db.run(
'INSERT INTO users (name, email) VALUES (?, ?)',
[name, email],
function(err) {
if (err) {
return res.status(409).json({ error: 'Email já existe' });
}
res.status(201).json({ id: this.lastID, name, email });
}
);
});
app.get('/users/:id', (req, res) => {
const db = getDatabase();
db.get('SELECT * FROM users WHERE id = ?', [req.params.id], (err, row) => {
if (err || !row) {
return res.status(404).json({ error: 'Usuário não encontrado' });
}
res.json(row);
});
});
module.exports = app;
Fixtures: Dados Consistentes e Reutilizáveis
Fixtures são conjuntos de dados predefinidos que você carrega antes de cada teste. Elas garantem que os testes rodem sempre com o mesmo estado inicial, eliminando flakiness. A estratégia é: limpar o banco, inserir dados conhecidos, executar o teste, limpar novamente.
Implementação Prática
// test/fixtures.js
const { getDatabase } = require('../src/database');
async function seedDatabase() {
const db = getDatabase();
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run('DELETE FROM users', (err) => {
if (err) reject(err);
});
db.run(
'INSERT INTO users (id, name, email) VALUES (?, ?, ?)',
[1, 'Maria Silva', 'maria@test.com'],
(err) => {
if (err) reject(err);
}
);
db.run(
'INSERT INTO users (id, name, email) VALUES (?, ?, ?)',
[2, 'João Santos', 'joao@test.com'],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
});
}
async function cleanDatabase() {
const db = getDatabase();
return new Promise((resolve, reject) => {
db.run('DELETE FROM users', (err) => {
if (err) reject(err);
else resolve();
});
});
}
module.exports = { seedDatabase, cleanDatabase };
// test/integration.test.js
const request = require('supertest');
const app = require('../src/app');
const { initDatabase } = require('../src/database');
const { seedDatabase, cleanDatabase } = require('./fixtures');
describe('Testes de Integração com Banco Real', () => {
beforeAll(async () => {
await initDatabase(':memory:'); // banco em memória para testes
});
beforeEach(async () => {
await seedDatabase();
});
afterEach(async () => {
await cleanDatabase();
});
it('deve listar usuário existente', async () => {
const res = await request(app).get('/users/1');
expect(res.status).toBe(200);
expect(res.body.name).toBe('Maria Silva');
expect(res.body.email).toBe('maria@test.com');
});
it('deve retornar 404 para usuário inexistente', async () => {
const res = await request(app).get('/users/999');
expect(res.status).toBe(404);
});
it('deve criar usuário novo sem conflito', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Pedro', email: 'pedro@test.com' });
expect(res.status).toBe(201);
expect(res.body.id).toBeDefined();
});
it('deve rejeitar email duplicado', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Outro', email: 'maria@test.com' }); // email já existe
expect(res.status).toBe(409);
});
});
Os hooks beforeEach e afterEach garantem isolamento: cada teste começa limpo. Isso é fundamental para evitar testes que passam juntos mas falham isolados.
Conclusão
Aprendemos que testes de integração exigem um banco real para revelar problemas autênticos de aplicação. Supertest simplifica testes HTTP com sintaxe limpa e promises. Fixtures providenciam dados consistentes, eliminando flakiness e tornando testes repetíveis. A combinação desses três elementos — Supertest + banco real + fixtures — é o padrão ouro em Node.js profissional. Comece pequeno, adicione testes incrementalmente conforme sua aplicação cresce, e sempre priorize testes que refletem o comportamento real.