Fundamentos de Programação/02 - Código que Presta/Semana 07 - Testes Automatizados9 min
Teste de Integração
- O que ele pega que o unitário nunca vai pegar?
- Como isolar estado entre testes que tocam banco de verdade?
- Vale usar container/banco real ou banco em memória? O que cada escolha esconde?
- Onde ele deixa de ser integração e passa a ser end-to-end?
Conceito
Teste de integração verifica que duas ou mais partes concordam entre si. E a razão de ele ser indispensável é precisa:
Cobertura de 100% nos dois lados de uma interface não diz nada sobre se os dois lados concordam.
Cada lado foi testado contra a sua própria suposição sobre o outro. O teste de integração é o único lugar onde a suposição encontra a realidade.
O que ele pega que o unitário nunca vai pegar:
- SQL que não corresponde ao schema — coluna renomeada, tipo trocado, constraint nova
- serialização divergente — o campo é
snake_casenum lado ecamelCaseno outro - fiação errada — o objeto certo, injetado no lugar errado
- configuração — variável de ambiente ausente, timeout de 0, pool de 1 conexão
- transação — rollback que não desfaz, deadlock, isolamento insuficiente
- comportamento real de rede — timeout, retry, conexão fechada no meio
Isolar estado entre testes é o problema central, e há quatro estratégias:
| Estratégia | Isolamento | Velocidade |
|---|---|---|
| transação + rollback no fim | total | rápida |
| truncate das tabelas entre testes | total | média |
| schema/banco novo por teste | total | lenta |
| dados únicos por teste (prefixo/UUID) | parcial | rápida |
A primeira é a melhor quando o código sob teste aceita receber uma transação. A última é a única possível quando o código gerencia a própria transação.
Trocar Postgres por SQLite no teste é tentador e contraproducente: as diferenças de
dialeto, de tipo, de comportamento de NULL, de nível de isolamento e de constraint
são a classe de bug que o teste de integração existe para achar.
Você acaba com um teste que valida que o seu SQL funciona no banco que você não usa.
A resposta moderna é container: subir o banco real, na versão real, por teste ou por suíte.
Onde ele deixa de ser integração e passa a ser end-to-end: quando você dirige o sistema inteiro pelo seu ponto de entrada real, com todas as dependências reais. Integração testa uma costura; E2E testa o caminho completo.
Em Go
Go tem uma vantagem estrutural aqui, e ela muda o cálculo: net/http/httptest está na
biblioteca padrão, e sobe um servidor HTTP de verdade, numa porta aleatória, em
microssegundos.
srv := httptest.NewServer(meuHandler)
defer srv.Close()
resp, err := http.Get(srv.URL + "/pedidos/1") // HTTP REAL
Isso exercita roteamento, serialização, status code, headers e o cliente HTTP de verdade — e custa quase nada. Em Go, um teste de integração de HTTP é tão barato que a fronteira entre unitário e integração fica muito mais fluida do que em Java ou Ruby.
Duas ferramentas relacionadas:
httptest.NewRecorder— chama seu handler sem rede. Mais rápido, e não testa o roteador nem o cliente.httptest.NewServer— rede de verdade em loopback. Testa a costura inteira.
E o inverso: httptest.NewServer também serve como dublê de uma dependência HTTP
externa, e é melhor que mockar o cliente, porque exercita serialização real
(4. Mocks e Stubs).
Como separar os testes lentos, dois idiomas:
// 1. flag -short
func TestBanco(t *testing.T) {
if testing.Short() { t.Skip("precisa de banco") }
...
}
// go test -short ./... -> pula
// 2. build tag (o teste nem compila sem a tag)
//go:build integration
// go test -tags=integration ./...
A build tag é mais forte: sem a tag, o arquivo não entra na compilação.
TestMain é onde sobe o container/banco uma vez para o pacote:
func TestMain(m *testing.M) {
encerra := sobeBanco()
code := m.Run()
encerra()
os.Exit(code)
}
Cuidado com paralelismo: go test ./... roda pacotes em paralelo. Se dois pacotes
compartilham o mesmo banco, você tem corrida. go test -p 1 ./... serializa.
Ferramentas de container: testcontainers-go (sobe Docker do próprio teste) e
ory/dockertest. Nenhuma na stdlib.
Respostas às perguntas-guia
1. O que ele pega que o unitário nunca vai pegar?
Conceito: desacordo entre partes — SQL vs schema, serialização, fiação, config, transação, rede real.
Em Go, especificamente: tag de struct errada (json:"user_id" vs userId), erro de
database/sql sobre coluna inexistente (que só aparece em runtime, porque a query é uma
string), e context sem prazo vazando para produção.
2. Como isolar estado entre testes que tocam banco de verdade?
Conceito: transação com rollback (melhor), truncate, schema por teste, ou dados únicos.
Em Go: o padrão é uma interface que aceita tanto *sql.DB quanto *sql.Tx — os dois
têm ExecContext/QueryContext, então uma interface pequena serve aos dois. O teste
abre a transação, passa como dependência e faz defer tx.Rollback().
3. Vale usar container/banco real ou banco em memória? O que cada escolha esconde?
Conceito: banco em memória esconde diferenças de dialeto, tipo, NULL, isolamento e
constraint — a classe de bug que o teste existia para pegar. Container mostra tudo e é
mais lento.
Em Go: testcontainers-go sobe Postgres real em poucos segundos, uma vez por pacote via
TestMain. Para HTTP não há dilema: httptest é o real.
4. Onde ele deixa de ser integração e passa a ser end-to-end?
Conceito: quando você dirige o sistema inteiro pelo ponto de entrada real, com todas as dependências reais.
Em Go, a fronteira prática: httptest.NewServer(seuRouter) com banco de teste é
integração. Subir o binário compilado, com config de produção e serviços externos reais,
é E2E.
Trade-offs
Do conceito:
- Integração compra confiança nas costuras; cobra tempo de execução, infraestrutura e instabilidade (flakiness).
- Isolamento por transação é rápido e não testa o
COMMIT. Isolamento por truncate testa o commit e é mais lento. - Container compra fidelidade e cobra Docker no CI e no laptop de todo mundo.
Em Go:
httptestna stdlib torna integração de HTTP quase gratuita — a pirâmide de Go é legitimamente mais achatada por isso (3. Pirâmide de Testes).- Build tag compra separação real e cobra que você lembre da tag (e o CI, também).
- Pacotes em paralelo é ótimo para velocidade e é uma armadilha com banco compartilhado.
Exemplo prático
Um handler HTTP testado nos três níveis, com o httptest fazendo o trabalho:
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
)
// ==================== código sob teste ====================
type Pedido struct {
ID int `json:"id"`
Total int64 `json:"total"`
Nome string `json:"nome_cliente"` // tag divergente do nome do campo: erro clássico
}
var ErrNaoEncontrado = errors.New("não encontrado")
type Repo interface{ Busca(id int) (Pedido, error) }
// "banco" com lock: substitui o real no exemplo, mas com a MESMA interface
type repoMem struct {
mu sync.Mutex
dados map[int]Pedido
}
func (r *repoMem) Busca(id int) (Pedido, error) {
r.mu.Lock()
defer r.mu.Unlock()
p, ok := r.dados[id]
if !ok {
return Pedido{}, ErrNaoEncontrado
}
return p, nil
}
func handler(repo Repo) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var id int
if _, err := fmt.Sscanf(r.URL.Path, "/pedidos/%d", &id); err != nil {
http.Error(w, "id inválido", http.StatusBadRequest)
return
}
p, err := repo.Busca(id)
if errors.Is(err, ErrNaoEncontrado) {
http.Error(w, "não encontrado", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "erro interno", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(p)
}
}
func repoDeTeste() *repoMem {
return &repoMem{dados: map[int]Pedido{
1: {ID: 1, Total: 9900, Nome: "ana"},
}}
}
// ==================== NÍVEL 1: handler sem rede (httptest.NewRecorder) ====================
// Rápido. Não testa roteamento nem cliente HTTP.
func TestHandler_SemRede(t *testing.T) {
casos := []struct {
nome, path string
wantStatus int
}{
{"existe", "/pedidos/1", http.StatusOK},
{"não existe", "/pedidos/99", http.StatusNotFound},
{"id inválido", "/pedidos/abc", http.StatusBadRequest},
}
h := handler(repoDeTeste())
for _, c := range casos {
t.Run(c.nome, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, c.path, nil)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != c.wantStatus {
t.Errorf("status = %d; want %d (corpo: %q)", rec.Code, c.wantStatus, rec.Body)
}
})
}
}
// ==================== NÍVEL 2: HTTP de verdade (httptest.NewServer) ====================
// Exercita servidor, rede em loopback, cliente HTTP e serialização REAIS.
func TestHandler_HTTPReal(t *testing.T) {
srv := httptest.NewServer(handler(repoDeTeste()))
t.Cleanup(srv.Close)
resp, err := http.Get(srv.URL + "/pedidos/1")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q; want application/json", ct)
}
corpo, _ := io.ReadAll(resp.Body)
// A verificação que o teste unitário NÃO faria: o JSON no fio.
// É aqui que tag de struct errada aparece.
var bruto map[string]any
if err := json.Unmarshal(corpo, &bruto); err != nil {
t.Fatalf("JSON inválido: %v (%s)", err, corpo)
}
for _, chave := range []string{"id", "total", "nome_cliente"} {
if _, ok := bruto[chave]; !ok {
t.Errorf("campo %q ausente no JSON; veio %v", chave, bruto)
}
}
if _, existe := bruto["Nome"]; existe {
t.Error("campo serializado como \"Nome\": a tag json não foi respeitada")
}
}
// ==================== NÍVEL 3: httptest como DUBLÊ de dependência externa ====================
// Melhor que mockar o cliente: exercita serialização e HTTP de verdade.
type ClienteCambio struct {
base string
http *http.Client
}
func (c ClienteCambio) Cotacao(moeda string) (float64, error) {
resp, err := c.http.Get(c.base + "/cotacao/" + moeda)
if err != nil {
return 0, fmt.Errorf("cotação %s: %w", moeda, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("cotação %s: status %d", moeda, resp.StatusCode)
}
var r struct{ Valor float64 `json:"valor"` }
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return 0, fmt.Errorf("cotação %s: %w", moeda, err)
}
return r.Valor, nil
}
func TestClienteCambio(t *testing.T) {
casos := []struct {
nome string
status int
corpo string
want float64
wantErr bool
}{
{"ok", 200, `{"valor":5.42}`, 5.42, false},
{"500 do servidor", 500, `erro`, 0, true},
{"JSON quebrado", 200, `{valor:`, 0, true},
}
for _, c := range casos {
t.Run(c.nome, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(c.status)
fmt.Fprint(w, c.corpo)
}))
t.Cleanup(srv.Close)
got, err := ClienteCambio{base: srv.URL, http: srv.Client()}.Cotacao("USD")
if (err != nil) != c.wantErr {
t.Fatalf("err = %v; wantErr = %v", err, c.wantErr)
}
if got != c.want {
t.Errorf("Cotacao = %v; want %v", got, c.want)
}
})
}
}
// ==================== separar o que precisa de infra ====================
func TestPrecisaDeBanco(t *testing.T) {
if testing.Short() {
t.Skip("precisa de banco: rode sem -short")
}
// aqui entraria testcontainers + transação + rollback
t.Log("rodaria contra banco real")
}
O TestHandler_HTTPReal é o que vale destacar: ele verifica o JSON no fio, procurando
a chave nome_cliente. Um teste unitário do handler compararia structs Go e passaria
felizmente mesmo com a tag json errada — o bug só existe na fronteira, e só a integração
olha para lá.
Relacionado
- 1. Teste Unitário — o que ele cobre, e o que ele não cobre
- 3. Pirâmide de Testes — quanto de cada, e por que em Go a proporção é diferente
- 4. Mocks e Stubs — por que
httptesté melhor que mockar o cliente - 6. Transações e ACID — semana 9, o rollback que isola os testes
Parte de Semana 07 - Testes Automatizados · 00 - MOC Fundamentos de Programação