Testes de integração vs. testes unitários: quando usar cada um

1. Fundamentos e definições essenciais

1.1. O que são testes unitários: escopo, isolamento e granularidade

Testes unitários verificam a menor unidade isolável do código — geralmente uma função, método ou classe — sem depender de sistemas externos como bancos de dados, APIs ou sistemas de arquivos. O isolamento é alcançado através de mocks, stubs ou injeção de dependências, garantindo que o teste falhe apenas por um erro na lógica da unidade testada, não por problemas de infraestrutura.

// Exemplo de teste unitário puro (função de validação)
function validarEmail(email) {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

// Teste unitário (Jest)
test('validarEmail retorna true para email válido', () => {
  expect(validarEmail('usuario@exemplo.com')).toBe(true);
});

test('validarEmail retorna false para email sem @', () => {
  expect(validarEmail('usuarioexemplo.com')).toBe(false);
});

1.2. O que são testes de integração: contexto real, dependências e acoplamento

Testes de integração validam a interação entre múltiplos componentes reais — módulos, serviços, bancos de dados, filas de mensagens ou APIs externas. Eles verificam se as partes funcionam corretamente quando conectadas, expondo problemas de contrato, serialização, transações ou concorrência que testes unitários não capturam.

// Exemplo de teste de integração com banco real (pytest + banco PostgreSQL)
def test_criar_usuario_com_integracao():
    # Configura banco de teste (ex: via Testcontainers)
    with PostgreSQLContainer("postgres:15") as postgres:
        db = Database(postgres.get_connection_url())
        db.inicializar_tabelas()

        # Executa fluxo real
        usuario = UsuarioService(db).criar("joao@email.com", "João")

        # Verifica persistência real
        assert db.buscar_por_email("joao@email.com").nome == "João"

1.3. A pirâmide de testes e o lugar de cada tipo no ciclo de qualidade

A pirâmide de testes proposta por Mike Cohn estabelece três camadas: base larga de testes unitários (rápidos, baratos), camada intermediária de testes de integração (mais lentos, mais caros) e topo com testes end-to-end (lentos, frágeis). Essa estrutura orienta investimento proporcional ao retorno: muitos unitários, poucos E2E.

2. Quando priorizar testes unitários

2.1. Lógica de negócio pura e regras de domínio sem I/O

Sempre que uma função contiver apenas transformações de dados, cálculos ou validações sem efeitos colaterais, o teste unitário é a escolha ideal. Exemplos: cálculo de impostos, validação de CPF, regras de elegibilidade.

// Função pura de negócio
function calcularDesconto(valor, tipoCliente) {
  if (tipoCliente === 'VIP') return valor * 0.9;
  if (valor > 1000) return valor * 0.95;
  return valor;
}

test('cliente VIP recebe 10% de desconto', () => {
  expect(calcularDesconto(100, 'VIP')).toBe(90);
});

2.2. Componentes com alta coesão e baixo acoplamento (ex: funções puras, validações)

Componentes que dependem apenas de suas entradas e não de estado externo são candidatos naturais a testes unitários. Quanto menor o acoplamento, mais fácil isolar e testar rapidamente.

2.3. Ciclos de feedback rápidos em TDD e refatoração contínua

No TDD (Test-Driven Development), escreve-se o teste unitário antes da implementação. A velocidade de execução (milissegundos) permite feedback imediato durante refatorações, garantindo que mudanças não quebrem regras existentes.

3. Quando optar por testes de integração

3.1. Fluxos que cruzam fronteiras de sistema (banco de dados, APIs, filas)

Sempre que o código interage com um sistema externo real, o teste de integração é necessário para validar o comportamento real. Um teste unitário com mock de banco não detecta problemas de SQL, índices ou concorrência.

// Teste de integração com API externa real (supertest + Express)
test('POST /api/usuarios persiste e retorna 201', async () => {
  const resposta = await request(app)
    .post('/api/usuarios')
    .send({ email: 'teste@teste.com', nome: 'Teste' });

  expect(resposta.status).toBe(201);

  // Verifica diretamente no banco de teste
  const usuarioNoBanco = await db.query(
    'SELECT * FROM usuarios WHERE email = $1', ['teste@teste.com']
  );
  expect(usuarioNoBanco.rows.length).toBe(1);
});

3.2. Contratos entre serviços e comportamento de middlewares/ORM

Testes de integração validam se as interfaces entre serviços (REST, GraphQL, gRPC) estão corretas e se o ORM gera as queries esperadas. Eles capturam incompatibilidades de versão ou mudanças de schema.

3.3. Cenários de estado compartilhado e transações distribuídas

Fluxos que envolvem múltiplas operações atômicas (rollback, commit, saga) exigem testes de integração para validar consistência em cenários de falha parcial.

4. Armadilhas comuns e más práticas

4.1. Teste unitário que depende de rede ou banco (falso unitário)

Muitos desenvolvedores chamam de "teste unitário" algo que acessa banco real ou faz chamadas HTTP. Isso quebra o isolamento, torna o teste lento e frágil. A regra é clara: se há I/O real, é teste de integração.

4.2. Teste de integração frágil por acoplamento excessivo a detalhes de implementação

Testes de integração devem validar comportamento observável (entrada/saída), não implementação interna. Verificar se um método específico foi chamado (em vez de verificar o resultado) cria testes frágeis que quebram com refatorações inocentes.

4.3. Cobertura ilusória: muitos testes unitários que não validam integração real

É comum ter 95% de cobertura unitária mas o sistema quebrar em produção porque a integração entre os componentes nunca foi testada. A métrica de cobertura deve considerar ambos os tipos.

5. Estratégia prática de decisão

5.1. Critério de decisão baseado em risco e custo de manutenção

Para cada cenário, avalie: o custo de um bug aqui é alto? A integração é complexa? Se sim, prefira teste de integração. Se a lógica é simples e isolada, teste unitário.

5.2. Regra de ouro: teste unitário para lógica, teste de integração para fluxo

  • Lógica de negócio → unitário
  • Fluxo entre componentes → integração
  • Fluxo completo do usuário → E2E (poucos)

5.3. Exemplo prático de divisão em um microsserviço real

// Sistema de pedidos: divisão de testes

// TESTE UNITÁRIO — regra de negócio pura
test('pedido com valor > 5000 requer aprovação gerencial', () => {
  const pedido = new Pedido({ valor: 6000, cliente: 'comum' });
  expect(pedido.requerAprovacao()).toBe(true);
});

// TESTE DE INTEGRAÇÃO — fluxo com banco e fila
test('ao criar pedido, salva no banco e publica evento na fila', async () => {
  // Setup: banco PostgreSQL + RabbitMQ reais (via Testcontainers)
  const pedido = await PedidoService.criar({ clienteId: 1, itens: [...] });

  // Assert: verifica banco
  const salvo = await db.query('SELECT * FROM pedidos WHERE id = $1', [pedido.id]);
  expect(salvo.rows.length).toBe(1);

  // Assert: verifica fila
  const mensagem = await fila.receber('pedidos.criados');
  expect(mensagem.conteudo.pedidoId).toBe(pedido.id);
});

6. Ferramentas e configuração para cada tipo

6.1. Frameworks e runners: Jest, JUnit, pytest vs. supertest, Testcontainers

Tipo Linguagem Ferramentas
Unitário JavaScript Jest, Vitest
Unitário Java JUnit 5, Mockito
Unitário Python pytest, unittest.mock
Integração JavaScript supertest, Testcontainers
Integração Java Spring Boot Test, Testcontainers
Integração Python pytest-django, testcontainers-python

6.2. Isolamento com mocks/stubs (unitário) vs. ambientes controlados (integração)

Para testes unitários, use mocks para substituir dependências. Para testes de integração, use contêineres descartáveis (Testcontainers) ou bancos de dados em memória (H2, SQLite) que imitam o ambiente real.

6.3. Estratégias de seed de dados e limpeza de estado em testes de integração

// Configuração de seed e cleanup (pytest)
@pytest.fixture
def banco_de_teste():
    db = criar_banco_teste()
    db.executar_migracoes()
    db.seed_dados_iniciais()
    yield db
    db.limpar_todos_os_dados()  # Garante isolamento entre testes

7. Métricas e governança da suíte de testes

7.1. Proporção ideal entre unitários e integração (80/20 vs. 70/30)

A proporção clássica é 80% unitários, 15% integração, 5% E2E. Em sistemas com muitos microsserviços, a proporção pode ser 70/25/5, pois há mais integrações a validar.

7.2. Monitoramento de tempo de execução e flakiness

Testes de integração devem executar em menos de 5 minutos (suíte completa). Testes flaky (que falham intermitentemente) devem ser identificados e corrigidos ou removidos imediatamente.

7.3. Revisão periódica: quando migrar um teste de integração para unitário (e vice-versa)

Revise trimestralmente: se um teste de integração testa apenas lógica pura (sem dependências reais), migre para unitário. Se um teste unitário usa mocks complexos demais, considere convertê-lo para integração com um contêiner leve.


Referências