trilha

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

Pirâmide de Testes

Perguntas-guia
  • Por que a base é larga: custo de execução ou custo de manutenção?
  • O que é o antipadrão do "cone de sorvete" (tudo end-to-end)?
  • Se você só pudesse ter uma camada, qual escolheria para o seu projeto atual?
  • Onde a pirâmide falha como metáfora?

Conceito

A pirâmide diz: muitos testes unitários, menos de integração, poucos end-to-end.

Por que a base é larga — e a resposta popular ("porque unitário é rápido") é só metade:

Razão O que significa
velocidade ciclo de feedback. Suíte de 3 segundos você roda a cada salvamento; de 8 minutos, você roda no CI e descobre 40 minutos depois
localização do defeito um unitário que falha aponta uma função. Um E2E que falha aponta "algo no sistema"
custo de manutenção teste E2E quebra por motivos alheios ao seu código: rede, dado de fixture, timing, ordem

A segunda é a mais subestimada. O valor de um teste não está só em detectar a falha — está em dizer onde ela está. Uma suíte que só te diz "quebrou" transfere para você o trabalho que ela deveria ter feito.

O antipadrão: o cone de sorvete. Poucos unitários, muitos E2E. Sintomas, e todos aparecem juntos:

  • suíte lenta → ninguém roda localmente
  • instável (flaky) → falhas são ignoradas por hábito
  • falha não localiza → cada vermelho custa uma investigação
  • e a consequência final: a suíte deixa de ser confiável, e a equipe para de confiar nela — momento em que ela passa a custar sem entregar

A pirâmide também é criticada, com razão. Duas alternativas conhecidas:

  • Testing Trophy (Kent C. Dodds) — a maior fatia é integração, porque em aplicação web ela dá a melhor confiança por unidade de custo
  • Honeycomb (Spotify) — foco em testes de contrato entre serviços, em vez de unidade

E as críticas são justas: a pirâmide não diz quais comportamentos testar, e supõe que "unitário é sempre barato" — falso quando a unidade exige montagem pesada.

A pergunta melhor que "qual proporção":

Para cada camada: que classe de bug ela pega que a camada de baixo não pega?

Se a resposta é "nenhuma", a camada é redundante. Se é "acordo entre partes", é integração. Se é "o sistema todo em pé", é E2E. A proporção sai disso, não de um desenho.

Em Go

Go achata a pirâmide, e por razões concretas — não por preferência cultural:

Fator Efeito
compilação rápida o ciclo é curto mesmo com muitos testes
net/http/httptest na stdlib teste de integração HTTP roda em microssegundos
nenhum container de framework para subir não existe o "boot do Spring" de 20s
binário único, sem servidor de aplicação E2E é subir um processo
go test cacheia por pacote pacote inalterado não roda de novo

O resultado: em Go, um teste de integração pode ser mais rápido que um teste unitário de Java. Isso muda a economia — a fatia do meio fica legitimamente maior, e o Testing Trophy descreve um projeto Go melhor que a pirâmide clássica.

Números que ajudam a calibrar: a stdlib de Go testa net/http com centenas de testes que sobem servidores reais. Se a biblioteca padrão trata isso como rotina, o seu projeto também pode.

Ferramentas de execução que importam para a forma da pirâmide:

Comando Efeito
go test ./... roda pacotes em paralelo
go test -short ./... pula os que você marcou como lentos
go test -tags=integration ./... inclui os que só compilam com a tag
go test -race ./... detector de corrida — no CI, sempre
go test -count=1 ./... ignora o cache (útil para caçar teste instável)
go test -count=10 -run TestX roda 10 vezes: é assim que se prova que não é flaky

O -race merece destaque: é um detector de data race de qualidade industrial, embutido, que roda com um comando. Ele pega uma classe de bug que nenhuma camada da pirâmide pega por design.

Respostas às perguntas-guia

1. Por que a base é larga: custo de execução ou custo de manutenção?

Conceito: os dois, mais um terceiro que é o mais importante — localização do defeito. Unitário diz onde; E2E diz que quebrou.

Em Go: o custo de execução é muito menor que a média, então manutenção e localização dominam a decisão. É por isso que a proporção idiomática em Go é diferente.

2. O que é o antipadrão do "cone de sorvete" (tudo end-to-end)?

Conceito: poucos unitários e muitos E2E: lento, instável, não localiza, e termina em suíte que ninguém confia.

Em Go: o sintoma específico é a suíte que só passa com -p 1 e falha aleatoriamente no CI — sinal de estado compartilhado entre testes, que é o que E2E acumula.

3. Se você só pudesse ter uma camada, qual escolheria para o seu projeto atual?

Conceito: depende de onde estão seus bugs. Para API HTTP com banco, a fatia de integração dá a melhor confiança por custo.

Em Go, concretamente: httptest.NewServer(seuRouter) + banco de teste, exercitando as rotas principais. Isso pega roteamento, serialização, SQL e status code de uma vez, e custa milissegundos. Se eu pudesse ter só uma camada num serviço Go, seria essa.

4. Onde a pirâmide falha como metáfora?

Conceito: ela não diz o que testar, supõe que unitário é sempre barato, e sugere que proporção é o objetivo — quando o objetivo é cobertura de classes de risco.

Em Go: falha também porque httptest desloca a fronteira: metade dos testes que a pirâmide classificaria como "integração lenta" em Go são rápidos. A metáfora foi desenhada para um mundo onde subir o contexto da aplicação custava dezenas de segundos.

Trade-offs

Do conceito:

  • Muitos unitários compram feedback e localização; cobram acoplamento ao desenho interno e não pegam desacordo entre partes.
  • Muitos de integração compram confiança realista; cobram infraestrutura e tempo.
  • E2E compra a única prova de que o sistema funciona de ponta a ponta; cobra instabilidade e diagnósticos ruins. Poucos e bem escolhidos.

Em Go:

  • httptest barato empurra para o meio da pirâmide — e o risco é negligenciar o unitário da regra de negócio pura, que é onde a lógica difícil mora.
  • Cache de teste por pacote acelera muito e esconde teste instável (use -count=1 para verificar).
  • Pacotes em paralelo é ganho grande e exige que os testes sejam de fato isolados.

Exemplo prático

A mesma regra, verificada nas três camadas — e o que cada camada pega:

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// ==================== a regra: pura, sem I/O ====================
type Centavos int64

func Frete(pesoGramas int, uf string) (Centavos, error) {
	if pesoGramas <= 0 {
		return 0, fmt.Errorf("peso %d inválido", pesoGramas)
	}
	base := Centavos(1500)
	if pesoGramas > 5000 {
		base += Centavos((pesoGramas - 5000) / 1000 * 200)
	}
	switch uf {
	case "SP", "RJ", "MG":
		return base, nil
	case "AM", "AC", "RR":
		return base * 2, nil
	default:
		return base * 3 / 2, nil
	}
}

// ==================== a fronteira HTTP ====================
type respostaFrete struct {
	Valor Centavos `json:"valor_centavos"`
}

func handlerFrete(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Peso int    `json:"peso_gramas"`
		UF   string `json:"uf"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "json inválido", http.StatusBadRequest)
		return
	}
	v, err := Frete(req.Peso, strings.ToUpper(req.UF))
	if err != nil {
		http.Error(w, err.Error(), http.StatusUnprocessableEntity)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(respostaFrete{Valor: v})
}

// ============ CAMADA 1: unitário. Pega erro de REGRA. ============
// Barato, rápido, e localiza o defeito numa função. É onde a lógica difícil
// deve ser exercitada exaustivamente.
func TestFrete_Regra(t *testing.T) {
	casos := []struct {
		peso int
		uf   string
		want Centavos
	}{
		{1000, "SP", 1500},
		{1000, "AM", 3000},
		{1000, "BA", 2250},
		{8000, "SP", 2100},  // 1500 + 3*200
		{8000, "AM", 4200},
	}
	for _, c := range casos {
		t.Run(fmt.Sprintf("%dg/%s", c.peso, c.uf), func(t *testing.T) {
			got, err := Frete(c.peso, c.uf)
			if err != nil {
				t.Fatalf("err = %v", err)
			}
			if got != c.want {
				t.Errorf("Frete(%d,%q) = %d; want %d", c.peso, c.uf, got, c.want)
			}
		})
	}
	if _, err := Frete(0, "SP"); err == nil {
		t.Error("peso 0 deveria dar erro")
	}
}

// ============ CAMADA 2: integração. Pega erro de FRONTEIRA. ============
// A regra pode estar perfeita e isto falhar: tag json errada, status code
// errado, uf não normalizada, corpo inválido tratado como 500.
func TestFrete_HTTP(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(handlerFrete))
	t.Cleanup(srv.Close)

	casos := []struct {
		nome, corpo string
		wantStatus  int
		wantValor   Centavos
	}{
		{"ok", `{"peso_gramas":1000,"uf":"SP"}`, 200, 1500},
		{"uf minúscula é normalizada", `{"peso_gramas":1000,"uf":"sp"}`, 200, 1500},
		{"peso inválido é 422", `{"peso_gramas":0,"uf":"SP"}`, 422, 0},
		{"json quebrado é 400", `{peso`, 400, 0},
	}
	for _, c := range casos {
		t.Run(c.nome, func(t *testing.T) {
			resp, err := http.Post(srv.URL, "application/json", strings.NewReader(c.corpo))
			if err != nil {
				t.Fatalf("POST: %v", err)
			}
			defer resp.Body.Close()
			if resp.StatusCode != c.wantStatus {
				t.Fatalf("status = %d; want %d", resp.StatusCode, c.wantStatus)
			}
			if c.wantStatus != 200 {
				return
			}
			// verifica o JSON NO FIO: o nome da chave é contrato público
			var bruto map[string]any
			if err := json.NewDecoder(resp.Body).Decode(&bruto); err != nil {
				t.Fatalf("json: %v", err)
			}
			v, ok := bruto["valor_centavos"]
			if !ok {
				t.Fatalf("chave valor_centavos ausente; veio %v", bruto)
			}
			if Centavos(v.(float64)) != c.wantValor {
				t.Errorf("valor = %v; want %d", v, c.wantValor)
			}
		})
	}
}

// ============ CAMADA 3: E2E. Pega erro de FIAÇÃO. ============
// Poucos, e só os caminhos que precisam provar que o sistema está de pé.
// Aqui: o roteador registra a rota no caminho certo?
func TestFrete_E2E_RotaRegistrada(t *testing.T) {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/v1/frete", handlerFrete) // roteamento de verdade
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)

	// caminho certo: 200
	resp, _ := http.Post(srv.URL+"/api/v1/frete", "application/json",
		strings.NewReader(`{"peso_gramas":1000,"uf":"SP"}`))
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("rota correta deu %d", resp.StatusCode)
	}

	// caminho errado: 404. Nenhum unitário pegaria uma rota registrada errada.
	resp2, _ := http.Post(srv.URL+"/frete", "application/json",
		strings.NewReader(`{"peso_gramas":1000,"uf":"SP"}`))
	defer resp2.Body.Close()
	if resp2.StatusCode != 404 {
		t.Errorf("rota inexistente deu %d; want 404", resp2.StatusCode)
	}

	// método errado: 405. Também invisível ao unitário.
	resp3, _ := http.Get(srv.URL + "/api/v1/frete")
	defer resp3.Body.Close()
	if resp3.StatusCode != http.StatusMethodNotAllowed {
		t.Errorf("GET deu %d; want 405", resp3.StatusCode)
	}
}

Leia as três camadas pelo que só elas pegam: a 1 pega 8000g/SP = 2100 errado; a 2 pega a chave JSON renomeada e o 422 virando 500; a 3 pega a rota registrada como /api/frete em vez de /api/v1/frete. Nenhuma substitui a outra — e é essa pergunta, não a proporção, que define a forma da sua pirâmide.

Relacionado


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

Buscar

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