Todos os artigos
66 artigos · atualizado semanalmente Veja nossas Ferramentas
Todos os artigos
Comparativos

Teste de integração vs teste unitário: o que cada um pega (e o que não pega)

Cobertura alta, deploy confiante — e ainda assim o bug aparece em produção. Entenda o que separa teste unitário de integração e quando usar cada um.

COVER · Comparativos

O repositório de usuários estava coberto. Noventa e um por cento de coverage, todos os testes verdes, deploy no ar. Aí o time reclamou que o cadastro estava salvando usuário sem enviar o e-mail de confirmação. O bug estava na integração entre o service de usuário e o service de e-mail — dois componentes unitariamente perfeitos que, juntos, nunca haviam sido testados.

Esse é o gap que o debate "teste unitário vs teste de integração" precisa endereçar: não qual é melhor em abstrato, mas qual captura qual categoria de problema.


O que cada tipo de teste enxerga

Um teste unitário isola uma unidade de código — função, método, classe — e verifica seu comportamento com entradas controladas. Tudo que está fora dessa unidade é substituído por mocks ou stubs. O teste é rápido, determinístico e cirúrgico.

Um teste de integração verifica que dois ou mais componentes funcionam corretamente quando acoplados. Pode ser a camada de repositório falando com o banco real, o handler HTTP chamando o service que chama o repositório, ou qualquer combinação de partes que na produção precisam conversar.

A diferença não é de granularidade — é de o que você quer garantir. Teste unitário garante que a lógica interna de uma função está correta. Teste de integração garante que a função se comporta corretamente dentro do sistema.

O bug do e-mail acima não seria capturado por nenhum teste unitário, por melhor que fosse. O service de e-mail tinha seus mocks, o service de usuário tinha os seus. Ninguém testou o ponto de contato — a interface real entre eles.

Custo e velocidade: os números que importam

Quando você está escolhendo onde investir esforço de teste, dois parâmetros importam: quanto custa escrever e quanto custa rodar.

Testes unitários:

  • Escrita: rápida, sem infraestrutura
  • Execução: milissegundos por teste, milhares rodam em segundos
  • Manutenção: proporcional ao número de mocks — quanto mais você mocka, mais frágil fica quando a implementação muda

Testes de integração:

  • Escrita: mais lenta, exige setup (banco, containers, seeds)
  • Execução: segundos a dezenas de segundos por teste, suítes grandes levam minutos
  • Manutenção: mais estável que unitário quando refatorações não alteram comportamento externo

A matemática parece favorecer unitário. Mas tem uma variável escondida: o custo de um bug de integração em produção é ordens de magnitude maior que o custo de um teste de integração que o teria capturado.

Times que só escrevem unitários tendem a ter alta cobertura e ainda assim deploys que quebram fluxos reais. Times que só escrevem integração têm suítes lentas e feedback lento no desenvolvimento. A proporção certa depende do sistema, não de uma regra universal.

A pirâmide de testes — e por que o modelo ficou mais complicado

A pirâmide clássica de Mike Cohn diz: muitos unitários na base, menos de integração no meio, poucos E2E no topo. A lógica era de custo: unitários são baratos, E2E são caros.

O problema é que a pirâmide foi desenhada pensando em sistemas com lógica de negócio rica e complexa. Em sistemas orientados a dados — APIs CRUD, pipelines de processamento, microsserviços que principalmente transformam e persistem dados — a maior parte do comportamento real está nas integrações, não na lógica interna de cada peça.

Para esses sistemas, o modelo do trofeu de Kent C. Dodds faz mais sentido: a maior camada é de testes de integração, com unitários para lógica de domínio complexa e alguns E2E para fluxos críticos. Não é uma revolução — é calibração para o tipo de sistema.

A pergunta que ajuda a decidir: "onde estão os bugs de produção do nosso sistema?" Se a maioria vem de lógica de negócio com muitos branches, unitários pagam mais. Se a maioria vem de integrações entre componentes, banco, filas, APIs externas — integração paga mais.

Quando usar teste unitário

Lógica de domínio com múltiplos caminhos é o candidato natural. Se você tem uma função de cálculo de desconto com oito combinações de tipo de cliente e valor de compra, você quer testar todas as oito em milissegundos — não subindo um banco toda vez.

O post O que testar em testes unitários cobre esse território em detalhe: quando o teste protege comportamento real vs quando vira burocracia de coverage. A conclusão lá é que unitário tem valor máximo quando a função é pura, tem múltiplos caminhos, e o comportamento é estável o suficiente para que o teste não quebre a cada refactor.

Outros bons candidatos para unitário:

  • Parsers e transformações de dados com casos de borda
  • Validações com regras complexas
  • Funções de formatação (datas, moeda, documentos) com inputs não-óbvios
  • Regras de autorização com combinações de permissão

O que não vale testar com unitário: qualquer coisa cuja única "lógica" é chamar outra coisa. Controller que repassa ao service, repository que chama o ORM, adapter que só converte formato — sem lógica, não tem o que testar unitariamente.

Quando usar teste de integração

O repositório é o caso mais óbvio. Mockar o banco num teste de repositório derrota o propósito: você precisa saber se a query está correta, se os índices funcionam, se o mapeamento está certo. Um teste de repositório que não bate no banco é, literalmente, um teste de mock.

# Isso não testa o repositório — testa se você sabe configurar um mock
def test_buscar_usuario_mock():
    repo = UsuarioRepository(session=mock_session)
    mock_session.execute.return_value = MockResult(usuario_fake)
    resultado = repo.buscar_por_email("test@example.com")
    assert resultado.email == "test@example.com"

# Isso testa o repositório
def test_buscar_usuario_banco(db_session):
    repo = UsuarioRepository(session=db_session)
    db_session.add(Usuario(email="test@example.com", nome="Teste"))
    db_session.commit()
    resultado = repo.buscar_por_email("test@example.com")
    assert resultado.email == "test@example.com"

O segundo teste pega problemas reais: query errada, campo mapeado com nome diferente, constraint violada, migração não aplicada. O primeiro não pega nada disso.

Outros casos onde integração é a escolha certa:

  • Handlers de API: testar que o endpoint retorna o status correto, com o payload correto, dado um estado de banco conhecido. Não mockar o service interno — testar o fluxo completo.
  • Processamento de filas: verificar que a mensagem publicada é consumida e processada corretamente end-to-end.
  • Integrações com serviços externos: quando viável, usar a API real em ambiente de sandbox. Quando não, usar um mock de serviço (WireMock, Prism) que se comporta como o serviço real — não um mock de objeto dentro do código.
  • Pipelines de transformação: quando dados passam por múltiplas etapas, testar a pipeline toda é mais útil que testar cada passo isolado.

O erro mais caro: mockar o que deveria ser integrado

Existe um padrão que parece prudente mas é uma armadilha: mockar o banco em testes de repositório, mockar o service de e-mail em testes de notificação, mockar a API externa em testes de payment gateway — e nunca ter um teste que verifique a integração real.

O resultado é uma suíte com 90%+ de coverage onde cada componente está "testado" mas o sistema como um todo nunca foi exercitado. É coverage theater: os números são bons, a confiança é falsa.

A heurística que uso: se dois componentes são separados em código mas acoplados em comportamento de produção, o teste relevante é de integração. Interfaces entre camadas, callbacks, eventos — esses são os pontos onde bugs nascem, e são exatamente os pontos que unitários pulam por design.

Infraestrutura para testes de integração

O maior argumento contra integração é o custo de setup. E ele é real — mas resolvível.

Testcontainers resolveu o problema do banco em testes locais e CI: você sobe um container Postgres (ou MySQL, Redis, o que for) no início da suíte, usa-o nos testes, e descarta no final. Sem estado compartilhado entre runs, sem dependência de banco local configurado.

# Com testcontainers-python
@pytest.fixture(scope="session")
def postgres_container():
    with PostgresContainer("postgres:16") as pg:
        yield pg.get_connection_url()

Em Go, a mesma abordagem com testcontainers-go. Em Java, com a biblioteca Testcontainers original. O custo de startup é real (10–30 segundos para subir o container), mas amortizado sobre a suíte inteira é aceitável.

Para APIs externas, uso o padrão de contract testing: define o contrato da API (o que ela recebe e retorna), testa contra um mock que implementa esse contrato, e tem um teste separado (mais raro) que valida o contrato contra a API real. Assim você não depende de conectividade em cada run mas também não está testando completamente no escuro.

Perguntas frequentes

Qual a diferença prática entre teste de integração e teste E2E?

Teste de integração verifica a integração entre componentes do sistema — geralmente sem interface de usuário, via código. Teste E2E (end-to-end) dirige o sistema completo pela interface — geralmente um browser com Playwright ou Cypress. Integração é mais rápido e mais fácil de debugar quando falha; E2E é mais próximo do comportamento real do usuário mas muito mais lento e frágil. A regra prática: use E2E apenas para fluxos críticos que não podem ser testados de outra forma.

Posso usar banco em memória (SQLite) no lugar do banco de produção nos testes de integração?

Tecnicamente sim, mas com ressalvas. SQLite tem semântica diferente do Postgres em vários pontos: tipo de dado, comportamento de transações, suporte a JSON, upserts. Um teste que passa no SQLite pode falhar com Postgres por diferença de dialeto SQL. Se seu banco de produção é Postgres, teste com Postgres — os Testcontainers tornam isso simples o suficiente para não ser desculpa.

Qual proporção de unitário vs integração devo ter?

Não existe proporção universal. A pergunta útil é: onde está o risco do meu sistema? Em lógica de domínio complexa (cálculos, regras de negócio, validações), unitários pagam mais. Em sistemas orientados a dados (APIs, pipelines, microsserviços), integração paga mais. Um projeto típico acaba com mais integração do que unitário — não por filosofia, mas porque a maioria dos bugs reais acontece nas bordas entre componentes.

Mocks são ruins em testes de integração?

Mocks têm lugar — para serviços externos que você não controla (API de pagamento, serviço de SMS, e-mail transacional). O problema é mockar componentes internos que você poderia testar de verdade. Se você está mockando seu próprio service dentro de um teste de integração, provavelmente está testando a camada errada.

Unitário e integração não concorrem — se completam

A tensão entre os dois tipos não é real. Unitário resolve bem problemas de lógica interna; integração resolve bem problemas de acoplamento e contrato entre componentes. O sistema saudável usa os dois onde cada um tem vantagem.

O que não funciona é usar um como substituto do outro por conveniência — mockar tudo para ter testes rápidos, ou escrever só E2E porque "é mais realista". Os bugs mais custosos de produção que vi foram todos em pontos de integração que estavam cobertos por mocks. Cobertura de código não é cobertura de comportamento real.

RD
Autor
Rafael Duarte
Desenvolvedor backend com passagem por fintech e SaaS B2B — trabalhou em times que escalaram APIs de zero a milhões de requisições. Carrega cicatrizes de produção suficientes para ter opiniões fortes sobre ferramentas, padrões e decisões de arquitetura. Não é acadêmico: leu a RFC do UUID quando precisou escolher entre v4 e v7 para uma tabela de alta escrita.
Ver perfil