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.
Exercícios
Exercício 1
Quais são as três camadas da pirâmide de testes, em que proporção aproximada elas devem aparecer, e o que caracteriza o antipadrão conhecido como sorvete de testes?
Ver resposta
✓ Resposta: A base são os testes unitários (~70%), que verificam uma função ou módulo isolado e rodam em milissegundos; o meio são os testes de integração (~20%), que verificam contratos entre módulos; o topo são os E2E (~10%), que exercitam o sistema inteiro do ponto de vista do usuário. A proporção reflete custo e velocidade: unitários são baratos de escrever e instantâneos, E2E são caros de manter e levam minutos. O sorvete de testes — ou pirâmide invertida — é ter mais E2E do que unitários. O custo prático é um pipeline lento, frágil e caro: o feedback demora, e cada falha obriga a investigar o sistema inteiro em vez de apontar uma função específica.
Exercício 2
Usando a função calcularFrete do artigo, diga o valor exato retornado pela chamada abaixo e explique por que trocar 600 por 100 muda o resultado em mais do que a proporção da distância.
calcularFrete({ peso: 2, distanciaKm: 600, tipoEntrega: 'padrao' })
Ver resposta
✓ Resposta: Retorna 124.80. Como tipoEntrega não é 'expresso', a taxaBase é 0.08; e como 600 > 500, a taxaDistancia passa a ser 1.3. O cálculo é 2 * 0.08 * 600 * 1.3 = 124.80. Com 100 km o resultado é 16.00, porque aí taxaDistancia vale 1.0: 2 * 0.08 * 100 = 16.00. A distância cresceu 6×, mas o valor cresceu 7,8× — o limiar de 500 km não é proporcional, é um degrau que aplica um multiplicador extra de 30% sobre todo o cálculo.
Exercício 3
No teste "não envia email se a inserção falhar", além de verificar que a promise rejeita, o teste afirma expect(enviarEmail).not.toHaveBeenCalled(). Por que essa asserção negativa é a mais importante ali? E por que o teste de integração mocka o serviço de email, mas usa banco de dados real?
Ver resposta
✓ Resposta: Porque ela verifica um efeito colateral que não pode acontecer. Sem ela, o teste passaria mesmo que criarUsuario disparasse o email de boas-vindas antes de a inserção falhar — o usuário receberia "sua conta foi criada" para uma conta que não existe. Afirmar que algo não foi chamado é o que protege contra esse tipo de ordem errada entre persistência e notificação.
Quanto ao teste de integração: o email é um serviço externo de terceiros, sem valor em ser exercido de verdade e com efeito real no mundo (mensagens enviadas a cada execução do pipeline). Já o banco é o contrato que o teste quer verificar — schema, tipos e a constraint UNIQUE do email. Mockar o banco eliminaria o objeto do teste: o caso que espera 409 para email duplicado só existe porque a constraint real dispara o erro.
Exercício 4
Por que o trecho abaixo é uma fonte clássica de teste flaky, e o que exatamente a versão com waitFor muda? Cite ainda duas outras causas de flakiness tratadas no artigo.
await sleep(1000);
expect(await getStatus()).toBe('completed');
Ver resposta
✓ Resposta: Porque assume que a operação assíncrona termina em exatamente 1 segundo. Em um runner de CI compartilhado e sob carga, ela pode levar mais — e o teste falha sem que nada no código tenha mudado. O inverso também é ruim: se terminar em 50 ms, o teste ainda gasta os 950 ms restantes. O waitFor com { timeout: 10000, interval: 200 } faz polling: verifica a condição a cada 200 ms e segue assim que ela for satisfeita, só falhando se passar de 10 segundos. Fica rápido no caso bom e tolerante no caso lento.
Outras causas citadas: estado compartilhado entre testes, quando um teste altera estado global e não limpa depois de si — resolve-se com isolamento completo via beforeEach/afterEach; e dependência de rede, quando o teste chama APIs externas reais e falha por instabilidade ou downtime — resolve-se mockando as chamadas externas nos unitários e de integração, deixando chamadas reais só para E2E em ambiente controlado. O artigo cita também a dependência da ordem de execução.
Exercício 5
Por que uma cobertura de 90% não significa que 90% dos comportamentos estão testados? Qual é o uso correto dessa métrica? E por que, no pipeline do artigo, o job e2e tem if: github.ref == 'refs/heads/main' em vez de rodar em todo pull request?
Ver resposta
✓ Resposta: Porque cobertura mede linhas executadas ao menos uma vez, não asserções significativas. Um teste que chama a função e não verifica nada cobre todas as linhas dela e não testa comportamento algum. O uso correto é como floor — um piso mínimo abaixo do qual o pipeline falha, configurado em coverageThreshold, com limiares mais altos para módulos críticos (no exemplo, pagamento.js exige 95% de linhas). Como teto a ser maximizado ela vira armadilha: perseguir 100% incentiva exatamente os testes vazios que inflam o número sem aumentar a confiança.
Os E2E ficam restritos a main por fail fast e por custo. São os testes mais lentos e os únicos que dependem do ambiente de staging. Rodá-los em cada PR tornaria o feedback de revisão insuportavelmente lento e colocaria PRs simultâneos disputando o mesmo staging. A cadeia unitarios → integracao → e2e → build-e-deploy, amarrada por needs, garante que o recurso caro só é acionado depois que o barato já aprovou.