Um pipeline de CI/CD sem testes automatizados é apenas um script de deploy com etapas extras. A automação do deploy resolve a questão da velocidade de entrega, mas não resolve a questão da confiança. E sem confiança, velocidade é perigosa.
Os testes automatizados são o mecanismo que transforma o pipeline de uma sequência de comandos em um sistema de garantia de qualidade. Cada commit que passa pelos testes carrega consigo uma afirmação objetiva: dentro dos limites do que foi testado, este código funciona. Essa afirmação é o que permite que uma equipe faça deploy múltiplas vezes ao dia sem ansiedade.
O problema é que testes mal escritos, mal organizados ou mal posicionados no pipeline não apenas deixam de dar essa confiança — eles ativamente atrapalham. Testes lentos tornam o pipeline insuportável. Testes flaky criam ruído que treina a equipe a ignorar falhas. Testes que testam a implementação em vez do comportamento quebram a cada refatoração e desincentivam melhorias no código.
Este artigo trata de como construir uma suite de testes que seja rápida, confiável e que realmente dê confiança para fazer deploy.
A Pirâmide de Testes
O modelo mais consolidado para pensar sobre testes automatizados é a Pirâmide de Testes, proposta por Mike Cohn e popularizada por Martin Fowler. A pirâmide descreve três camadas de testes com características distintas:
/\
/ \
/ E2E \ ← Poucos, lentos, caros, alta confiança de ponta a ponta
/────────\
/ \
/ Integração \ ← Médios, moderados, verificam contratos entre módulos
/──────────────\
/ \
/ Unitários \ ← Muitos, rápidos, baratos, feedback imediato
/────────────────────\
A proporção recomendada é aproximadamente 70% de testes unitários, 20% de testes de integração e 10% de testes end-to-end. Essa distribuição reflete o custo e a velocidade de cada camada: testes unitários são baratos de escrever e executam em milissegundos, enquanto testes E2E são caros de manter e podem levar minutos.
Inverter essa pirâmide — ter mais testes E2E que unitários — é um antipadrão chamado de pirâmide invertida ou sorvete de testes. O resultado é um pipeline lento, frágil e caro de manter.
Testes Unitários: Velocidade e Precisão
Testes unitários verificam o comportamento de uma função ou módulo em completo isolamento. Dependências externas — banco de dados, APIs, sistema de arquivos — são substituídas por mocks ou stubs.
Exemplo de um módulo de cálculo de frete:
// src/services/frete.js
const calcularFrete = ({ peso, distanciaKm, tipoEntrega }) => {
if (peso <= 0) throw new Error('Peso deve ser positivo');
if (distanciaKm <= 0) throw new Error('Distância deve ser positiva');
const taxaBase = tipoEntrega === 'expresso' ? 0.15 : 0.08;
const taxaDistancia = distanciaKm > 500 ? 1.3 : 1.0;
return parseFloat((peso * taxaBase * distanciaKm * taxaDistancia).toFixed(2));
};
module.exports = { calcularFrete };
Testes unitários com Jest:
// tests/unit/frete.test.js
const { calcularFrete } = require('../../src/services/frete');
describe('calcularFrete', () => {
describe('entrega padrão', () => {
it('calcula corretamente para curta distância', () => {
const resultado = calcularFrete({
peso: 2,
distanciaKm: 100,
tipoEntrega: 'padrao'
});
expect(resultado).toBe(16.00);
});
it('aplica taxa adicional para distâncias acima de 500km', () => {
const resultado = calcularFrete({
peso: 2,
distanciaKm: 600,
tipoEntrega: 'padrao'
});
// 2 * 0.08 * 600 * 1.3 = 124.80
expect(resultado).toBe(124.80);
});
});
describe('entrega expressa', () => {
it('aplica taxa maior que a entrega padrão', () => {
const padrao = calcularFrete({ peso: 1, distanciaKm: 100, tipoEntrega: 'padrao' });
const expresso = calcularFrete({ peso: 1, distanciaKm: 100, tipoEntrega: 'expresso' });
expect(expresso).toBeGreaterThan(padrao);
});
});
describe('validações', () => {
it('lança erro para peso negativo', () => {
expect(() => calcularFrete({ peso: -1, distanciaKm: 100, tipoEntrega: 'padrao' }))
.toThrow('Peso deve ser positivo');
});
it('lança erro para distância zero', () => {
expect(() => calcularFrete({ peso: 1, distanciaKm: 0, tipoEntrega: 'padrao' }))
.toThrow('Distância deve ser positiva');
});
});
});
Boas práticas para testes unitários:
- Cada teste verifica uma única coisa — o nome deve descrever exatamente o que está sendo verificado
- O padrão Arrange-Act-Assert organiza o teste em três seções: preparação do estado, execução da ação e verificação do resultado
- Testes não devem depender da ordem de execução — cada um deve ser completamente independente
- Nomes de testes são documentação — devem ser legíveis por não-desenvolvedores
Mocks e Stubs: Isolando Dependências
Quando o código a ser testado depende de serviços externos, substitui-se esses serviços por implementações controladas:
// src/services/notificacao.js
const enviarEmail = async (destinatario, assunto, corpo) => {
// integração real com serviço de email
};
// src/services/usuario.js
const { enviarEmail } = require('./notificacao');
const db = require('../db/connection');
const criarUsuario = async (dados) => {
const usuario = await db.query(
'INSERT INTO usuarios (nome, email) VALUES ($1, $2) RETURNING *',
[dados.nome, dados.email]
);
await enviarEmail(
dados.email,
'Bem-vindo!',
`Olá ${dados.nome}, sua conta foi criada.`
);
return usuario.rows[0];
};
Testando criarUsuario sem banco de dados real e sem enviar emails:
// tests/unit/usuario.test.js
jest.mock('../../src/db/connection');
jest.mock('../../src/services/notificacao');
const db = require('../../src/db/connection');
const { enviarEmail } = require('../../src/services/notificacao');
const { criarUsuario } = require('../../src/services/usuario');
describe('criarUsuario', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('insere o usuário no banco e envia email de boas-vindas', async () => {
// Arrange
const dadosUsuario = { nome: 'Ana Silva', email: 'ana@exemplo.com' };
const usuarioCriado = { id: 1, ...dadosUsuario };
db.query.mockResolvedValue({ rows: [usuarioCriado] });
enviarEmail.mockResolvedValue(undefined);
// Act
const resultado = await criarUsuario(dadosUsuario);
// Assert
expect(resultado).toEqual(usuarioCriado);
expect(db.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO usuarios'),
[dadosUsuario.nome, dadosUsuario.email]
);
expect(enviarEmail).toHaveBeenCalledWith(
dadosUsuario.email,
'Bem-vindo!',
expect.stringContaining(dadosUsuario.nome)
);
});
it('não envia email se a inserção falhar', async () => {
// Arrange
db.query.mockRejectedValue(new Error('Conexão recusada'));
// Act & Assert
await expect(criarUsuario({ nome: 'Erro', email: 'erro@exemplo.com' }))
.rejects.toThrow('Conexão recusada');
expect(enviarEmail).not.toHaveBeenCalled();
});
});
Testes de Integração: Verificando Contratos
Testes de integração verificam que módulos diferentes funcionam corretamente em conjunto. Em vez de mockar o banco de dados, utiliza-se um banco de dados real — geralmente um container Docker criado especificamente para os testes.
No pipeline do GitHub Actions, o bloco services cria containers de suporte automaticamente:
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test123
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
O teste de integração para a rota de criação de usuário:
// tests/integration/usuarios.api.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../../src/db/connection');
// Mock apenas do serviço de email — o banco é real
jest.mock('../../src/services/notificacao');
beforeAll(async () => {
await db.query(`
CREATE TABLE IF NOT EXISTS usuarios (
id SERIAL PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
criado_em TIMESTAMPTZ DEFAULT NOW()
)
`);
});
afterEach(async () => {
await db.query('DELETE FROM usuarios');
});
afterAll(async () => {
await db.end();
});
describe('POST /usuarios', () => {
it('cria um usuário com dados válidos e retorna 201', async () => {
const response = await request(app)
.post('/usuarios')
.send({ nome: 'Carlos Mendes', email: 'carlos@exemplo.com' });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(Number),
nome: 'Carlos Mendes',
email: 'carlos@exemplo.com'
});
// Verifica persistência real no banco
const { rows } = await db.query(
'SELECT * FROM usuarios WHERE email = $1',
['carlos@exemplo.com']
);
expect(rows).toHaveLength(1);
expect(rows[0].nome).toBe('Carlos Mendes');
});
it('retorna 409 para email duplicado', async () => {
await db.query(
'INSERT INTO usuarios (nome, email) VALUES ($1, $2)',
['Existente', 'carlos@exemplo.com']
);
const response = await request(app)
.post('/usuarios')
.send({ nome: 'Outro', email: 'carlos@exemplo.com' });
expect(response.status).toBe(409);
expect(response.body.erro).toMatch(/email já cadastrado/i);
});
it('retorna 400 para email inválido', async () => {
const response = await request(app)
.post('/usuarios')
.send({ nome: 'Teste', email: 'email-invalido' });
expect(response.status).toBe(400);
});
});
Testes End-to-End: O Olhar do Usuário
Testes E2E simulam o comportamento real de um usuário interagindo com o sistema completo. São os testes mais caros de manter e mais lentos de executar, mas são os únicos que validam que o sistema funciona de ponta a ponta.
Para APIs, o Playwright ou o próprio Supertest com o ambiente completo de staging servem bem. Para sistemas com interface web, o Playwright é a ferramenta mais completa disponível:
// tests/e2e/cadastro.test.js
const { test, expect } = require('@playwright/test');
test.describe('Fluxo de Cadastro', () => {
test('usuário consegue criar conta e acessar dashboard', async ({ page }) => {
// Navega para a página de cadastro
await page.goto('https://staging.minha-app.com/cadastro');
// Preenche o formulário
await page.fill('[data-testid="campo-nome"]', 'Maria Souza');
await page.fill('[data-testid="campo-email"]', `teste_${Date.now()}@exemplo.com`);
await page.fill('[data-testid="campo-senha"]', 'Senha@Segura123');
await page.fill('[data-testid="campo-confirmar-senha"]', 'Senha@Segura123');
// Submete
await page.click('[data-testid="botao-cadastrar"]');
// Verifica redirecionamento para o dashboard
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.locator('[data-testid="mensagem-boas-vindas"]'))
.toContainText('Maria Souza');
});
test('exibe erro para email já cadastrado', async ({ page }) => {
await page.goto('https://staging.minha-app.com/cadastro');
await page.fill('[data-testid="campo-email"]', 'existente@exemplo.com');
await page.fill('[data-testid="campo-nome"]', 'Qualquer Nome');
await page.fill('[data-testid="campo-senha"]', 'Senha@Segura123');
await page.fill('[data-testid="campo-confirmar-senha"]', 'Senha@Segura123');
await page.click('[data-testid="botao-cadastrar"]');
await expect(page.locator('[data-testid="mensagem-erro"]'))
.toContainText('email já cadastrado');
});
});
Lidando com Testes Flaky
Testes flaky — que falham de forma intermitente sem que nada no código tenha mudado — são um dos maiores problemas de qualidade em pipelines de CI. Quando a equipe começa a aceitar falhas como "normais", o pipeline perde sua função de guardião da qualidade.
As causas mais comuns de testes flaky e suas soluções:
Dependência de tempo — testes que assumem que uma operação assíncrona termina em X milissegundos falham quando o sistema está sob carga. A solução é usar waiters e polling em vez de delays fixos:
// Problemático — assume que a operação termina em 1 segundo
await sleep(1000);
expect(await getStatus()).toBe('completed');
// Correto — aguarda ativamente a condição
await waitFor(async () => {
const status = await getStatus();
return status === 'completed';
}, { timeout: 10000, interval: 200 });
Estado compartilhado entre testes — testes que modificam estado global e não fazem limpeza após si mesmos interferem nos testes seguintes. A solução é garantir isolamento completo com beforeEach e afterEach.
Dependências de rede — testes que fazem chamadas reais a APIs externas falham quando a rede está instável ou o serviço tem downtime. A solução é mockar todas as chamadas externas em testes unitários e de integração, reservando chamadas reais apenas para testes E2E em ambiente controlado.
Ordem de execução — testes que assumem uma ordem específica de execução. A solução é garantir que cada teste funcione independentemente da ordem.
Cobertura de Testes: Métrica e Armadilha
A cobertura de testes mede a porcentagem do código que é executada durante os testes. É uma métrica útil como sinal de alerta, mas perigosa como objetivo.
Uma cobertura de 90% não significa que 90% dos comportamentos estão testados — significa que 90% das linhas foram executadas ao menos uma vez. É possível ter alta cobertura com testes que não fazem nenhuma asserção significativa.
O uso correto da cobertura é como um floor — um piso mínimo abaixo do qual o pipeline falha — não como um teto a ser maximizado:
// jest.config.js
module.exports = {
coverageThreshold: {
global: {
lines: 80,
functions: 80,
branches: 75,
statements: 80
},
// Módulos críticos têm limiares mais altos
'./src/services/pagamento.js': {
lines: 95,
branches: 90
}
}
};
Organizando Testes no Pipeline para Máxima Eficiência
A ordem dos jobs no pipeline deve refletir o princípio de fail fast — falhar o mais rápido possível no ponto mais barato. Testes unitários devem rodar antes dos testes de integração, que devem rodar antes dos testes E2E:
jobs:
unitarios:
# Mais rápidos — rodam em segundos
# Sem dependências de serviços externos
runs-on: ubuntu-latest
integracao:
needs: unitarios
# Mais lentos — requerem banco de dados e outros serviços
# Só rodam se os unitários passarem
services:
postgres: ...
e2e:
needs: integracao
# Os mais lentos — rodam contra o ambiente de staging
# Só rodam em pushes para main, não em PRs
if: github.ref == 'refs/heads/main'
build-e-deploy:
needs: e2e
# Só chega aqui se todos os testes passaram
Essa organização garante que o feedback mais rápido chega primeiro e que os recursos mais caros — containers de integração, ambiente de staging para E2E — são usados apenas quando o código já passou pelas verificações mais básicas.
O Que Vem a Seguir
No próximo artigo será abordado o gerenciamento de variáveis de ambiente e secrets em pipelines — um tema que toca diretamente em segurança. Serão cobertos os diferentes tipos de segredos, como o GitHub os gerencia, como injetá-los com segurança nos containers e como evitar as armadilhas mais comuns que levam à exposição acidental de credenciais.
Referências para Aprofundamento
Livros e artigos fundamentais - The Practical Test Pyramid — Martin Fowler — Artigo extenso e definitivo de Martin Fowler sobre a pirâmide de testes, com exemplos em múltiplas linguagens e discussão sobre quando cada camada faz sentido. - Unit Testing — Vladimir Khorikov — Livro aprofundado sobre princípios de testes unitários de qualidade. Cobre a diferença entre testar comportamento e testar implementação.
Documentação de ferramentas - Jest Documentation — jestjs.io — Documentação oficial do Jest, o framework de testes mais usado no ecossistema Node.js. Cobre mocks, cobertura e configuração avançada. - Supertest — GitHub — Biblioteca para testes de APIs HTTP em Node.js. Integra diretamente com Express sem precisar subir um servidor real. - Playwright Documentation — playwright.dev — Documentação oficial do Playwright para testes E2E. Cobre instalação, escrita de testes e integração com CI.
Testes flaky - Eradicating Non-Determinism in Tests — Martin Fowler — Artigo clássico sobre as causas e soluções para testes não-determinísticos. Leitura essencial para equipes com problemas de testes flaky.