trilha

Fundamentos de Programação/02 - Código que Presta/Semana 07 - Testes Automatizados9 min

Teste de Integração

Perguntas-guia
  • 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_case num lado e camelCase no 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.

Banco em memória esconde exatamente o bug que você queria pegar

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:

  • httptest na 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


Parte de Semana 07 - Testes Automatizados · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas