Testes Automatizados no Pipeline: Qualidade sem Atrito

Testes Automatizados no Pipeline: Qualidade sem Atrito

A pirâmide de testes aplicada ao pipeline: a proporção 70/20/10 e o antipadrão do sorvete invertido, unitários com mocks e stubs, integração contra banco real pelo bloco services, E2E com Playwright, o combate a testes flaky com waiters em vez de delays fixos, e a cobertura como piso de bloqueio — nunca como meta.
DevOps

18 min de leitura

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.

A pirâmide de testes é uma recomendação de proporção, não uma regra sobre o que testar. Invertê-la — muitos testes de ponta a ponta, poucos unitários — produz uma suíte lenta e instável que as pessoas aprendem a ignorar, e um teste ignorado é pior que ausente, porque dá a impressão de cobertura. Vale medir cobertura, desde que fique claro que ela conta linhas executadas, não comportamentos verificados.

Fontes e leituras recomendadas

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

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.

Comentários

Mais em DevOps

Geradores de Senhas Seguras: Implementações em 5 Linguagens de Programação
Geradores de Senhas Seguras: Implementações em 5 Linguagens de Programação

Quinze implementações de gerador de senha em Go, Rust, JavaScript, PHP e…

Azure para Quem Já Conhece AWS
Azure para Quem Já Conhece AWS

Um mapa de tradução para quem já domina a AWS: a hierarquia de tenant…

Capstone: Pipeline Completo de CI/CD
Capstone: Pipeline Completo de CI/CD

O pipeline tratado como produto de engenharia, em que o desenvolvedor é o…