DevOps

Testes Automatizados no Pipeline: Qualidade sem Atrito Já leu

13 min de leitura

Testes Automatizados no Pipeline: Qualidade sem Atrito
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, vel

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.

Comentários

Mais em DevOps

EKS: Kubernetes Gerenciado na AWS
EKS: Kubernetes Gerenciado na AWS

Operar o plano de controle do Kubernetes — manter o etcd replicado, atualizar...

Compliance e Auditoria: LGPD, SOC2 e Evidências Automatizadas
Compliance e Auditoria: LGPD, SOC2 e Evidências Automatizadas

Existe uma diferença fundamental entre uma organização que é segura e uma org...

Permissões, Usuários e Grupos no Linux
Permissões, Usuários e Grupos no Linux

Em um servidor de produ&ccedil;&atilde;o, m&uacute;ltiplos servi&ccedil;os ro...