trilha

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

Teste Unitário

Perguntas-guia
  • O que é a "unidade" — a classe, o método, ou o comportamento?
  • Por que testar comportamento sobrevive a refatoração e testar implementação não?
  • O que faz um teste ser rápido e determinístico?
  • Cobertura de 100% prova o quê, exatamente?

Conceito

O que é a "unidade"? Não é a classe, nem o método. A definição útil é: a menor porção de comportamento que você pode verificar independentemente.

Isso importa porque a escolha errada estraga tudo. Se você define unidade como "método", você acaba com um teste por método — e testes que conhecem a estrutura interna do código.

Testar comportamento contra testar implementação

Esta é a distinção que separa uma suíte que ajuda de uma que atrapalha.

O teste decisivo: você refatora sem mudar comportamento. Os testes quebram?

  • Não quebram → eles testam comportamento. Eles são sua rede de segurança.
  • Quebram → eles testavam implementação. Eles são um segundo lugar para editar sempre que você mexe no primeiro.

"O refactor quebrou meus testes" quase nunca significa que o refactor estava errado. Significa que os testes estavam.

Propriedades de um bom teste unitário — e cada uma tem uma razão prática:

Propriedade Por quê
rápido você roda a cada salvamento; 10s de suíte mata o hábito
determinístico teste que falha 1 em 20 vezes é pior que teste nenhum — treina você a ignorar vermelho
isolado não depende de ordem nem de outro teste; roda sozinho e em paralelo
falha por um motivo quando quebra, a mensagem já é o diagnóstico

Nome do teste descreve comportamento, não método. TestCalculaTotal_ComDescontoDeVolume_AplicaDezPorCento diz o que o sistema faz. TestCalculaTotal2 diz que alguém já tinha usado o nome.

Arrange-Act-Assert: monte o cenário, execute uma ação, verifique. Se há dois "act", há dois testes.

Sobre cobertura, honestamente: 100% de cobertura prova que toda linha executou — e nada sobre as asserções. Uma suíte sem nenhum assert atinge 100% de cobertura.

O sinal útil da cobertura é o inverso: 0% num caminho crítico é informação real e acionável. Use cobertura para achar o que ninguém testou, nunca como meta.

Em Go

Não existe biblioteca de asserção na stdlib, e é deliberado. O idioma é:

if got != want {
	t.Errorf("Soma(2,3) = %d; want %d", got, want)
}

Verboso, e com uma vantagem: a mensagem de erro é escrita por você, então ela diz o que importa. A comunidade se divide entre isso e testify; a stdlib nunca vai ter asserções.

Table-driven test é o idioma de Go. Não é um estilo entre outros — é como a própria stdlib é testada:

casos := []struct {
	nome     string
	in, want int
}{
	{"zero", 0, 0},
	{"positivo", 2, 4},
}
for _, c := range casos {
	t.Run(c.nome, func(t *testing.T) { ... })
}

t.Run cria subtestes: cada caso falha com nome próprio, e você pode rodar um só com go test -run 'TestDobro/positivo'.

As ferramentas que valem conhecer:

Recurso Para quê
t.Errorf registra falha e continua o teste
t.Fatalf registra falha e para (use quando o resto não faz sentido)
t.Helper() marca a função como auxiliar: a falha aponta a linha de quem chamou
t.Cleanup(fn) teardown, executado em LIFO — substitui defer em helpers
t.Parallel() roda o subteste em paralelo com os outros marcados
t.TempDir() diretório temporário, limpo automaticamente
TestMain setup/teardown do pacote inteiro

Não existe setUp/tearDown. O idioma é um helper que devolve o objeto e a limpeza, ou usa t.Cleanup internamente.

Pacote de teste interno vs externo: um arquivo foo_test.go pode declarar package foo (vê tudo, inclusive o não exportado) ou package foo_test (só a API pública). O segundo é uma forma de forçar seus testes a exercitarem a API que os clientes usam — e é um bom antídoto contra testar implementação.

Cobertura: go test -cover, e o que realmente ajuda:

go test -coverprofile=c.out ./... && go tool cover -html=c.out

Isso pinta o código de verde e vermelho no navegador — e o vermelho é onde está a informação.

Respostas às perguntas-guia

1. O que é a "unidade" — a classe, o método, ou o comportamento?

Conceito: o comportamento. Definir unidade como método produz testes acoplados à estrutura.

Em Go: a unidade natural é a função exportada do pacote, e o pacote é a fronteira. Testar via package foo_test te obriga a tratar o pacote como a unidade, o que quase sempre é o nível certo.

2. Por que testar comportamento sobrevive a refatoração e testar implementação não?

Conceito: porque refatoração muda estrutura e preserva comportamento, por definição. Um teste amarrado à estrutura quebra; um amarrado ao comportamento não percebe.

Em Go: o sintoma concreto de teste acoplado é o mock que verifica quantas vezes um colaborador foi chamado (4. Mocks e Stubs). Isso é uma cópia da implementação escrita em outro arquivo.

3. O que faz um teste ser rápido e determinístico?

Conceito: não tocar em rede, disco, relógio nem aleatoriedade — ou injetar todos eles.

Em Go, as três fontes de não-determinismo mais comuns e suas correções:

  • tempo: injete func() time.Time em vez de chamar time.Now() direto
  • ordem de map: range sobre map é randomizado de propósito (6. Hash Map) — ordene as chaves antes de comparar
  • goroutines: use -race (o detector de corrida de Go é excelente e roda em CI de graça)

4. Cobertura de 100% prova o quê, exatamente?

Conceito: que toda linha executou. Nada sobre asserções. Suíte sem asserção nenhuma atinge 100%.

Em Go, dá para demonstrar em 5 linhas: um teste que só chama a função, sem verificar nada, dá cobertura total daquela função. Use go tool cover -html para achar o zero, não para perseguir o cem.

Trade-offs

Do conceito:

  • Teste unitário compra feedback rápido e diagnóstico preciso; cobra que você projete o código para ser testável (o que é bom) e não pega erro de integração (2. Teste de Integração).
  • Testar via API pública compra resistência a refatoração; cobra que caminhos internos raros fiquem difíceis de alcançar.
  • Cobertura como métrica compra visibilidade e vira meta perversa quando entra no bônus.

Em Go:

  • Sem biblioteca de asserção: mensagens melhores, mais código. testify inverte a troca.
  • Table-driven compra densidade e cobra que todos os casos caibam na mesma forma — quando não cabem, forçar a tabela piora.
  • package foo_test compra disciplina de API e cobra acesso ao interno.

Exemplo prático

package main

import (
	"errors"
	"fmt"
	"slices"
	"strings"
	"testing"
)

// ==================== código sob teste ====================
type Centavos int64

var ErrItensVazios = errors.New("sem itens")

func CalculaTotal(precos []Centavos) (Centavos, error) {
	if len(precos) == 0 {
		return 0, ErrItensVazios
	}
	var soma Centavos
	for _, p := range precos {
		soma += p
	}
	if soma > 10000 {
		soma = soma * 90 / 100 // desconto de volume
	}
	return soma, nil
}

func Normaliza(s string) string {
	return strings.Join(strings.Fields(strings.ToLower(s)), " ")
}

// ==================== table-driven: o idioma de Go ====================
func TestCalculaTotal(t *testing.T) {
	casos := []struct {
		nome    string
		precos  []Centavos
		want    Centavos
		wantErr error
	}{
		{"sem itens devolve erro", nil, 0, ErrItensVazios},
		{"um item", []Centavos{500}, 500, nil},
		{"soma simples sem desconto", []Centavos{500, 1500}, 2000, nil},
		{"no limite não desconta", []Centavos{10000}, 10000, nil},
		{"acima do limite desconta 10%", []Centavos{10001}, 9000, nil},
		{"desconto sobre a soma, não por item", []Centavos{6000, 6000}, 10800, nil},
	}

	for _, c := range casos {
		t.Run(c.nome, func(t *testing.T) {
			t.Parallel() // subtestes independentes rodam em paralelo
			got, err := CalculaTotal(c.precos)
			if !errors.Is(err, c.wantErr) {
				t.Fatalf("erro = %v; want %v", err, c.wantErr)
			}
			if got != c.want {
				t.Errorf("CalculaTotal(%v) = %d; want %d", c.precos, got, c.want)
			}
		})
	}
}

// ==================== t.Helper: a falha aponta a linha de quem chamou ====================
func exigeNormaliza(t *testing.T, in, want string) {
	t.Helper() // sem isto, a falha apontaria ESTA linha, não a do caso
	if got := Normaliza(in); got != want {
		t.Errorf("Normaliza(%q) = %q; want %q", in, got, want)
	}
}

func TestNormaliza(t *testing.T) {
	exigeNormaliza(t, "  Olá   MUNDO ", "olá mundo")
	exigeNormaliza(t, "\tnormalizado", "já normalizado")
	exigeNormaliza(t, "", "")
}

// ==================== teste que ilustra o problema da cobertura ====================
// Este teste dá 100% de cobertura de CalculaTotal e não verifica NADA.
func TestCoberturaSemAsserção(t *testing.T) {
	_, _ = CalculaTotal([]Centavos{20000})
	// nenhum assert. Cobertura: 100%. Valor: zero.
}

// ==================== determinismo: ordene as chaves do map ====================
func chavesOrdenadas(m map[string]int) []string {
	ks := make([]string, 0, len(m))
	for k := range m { // ordem RANDOMIZADA em Go
		ks = append(ks, k)
	}
	slices.Sort(ks) // determinismo restaurado
	return ks
}

func TestOrdemDeMapÉDeterminística(t *testing.T) {
	m := map[string]int{"c": 3, "a": 1, "b": 2}
	want := []string{"a", "b", "c"}
	for i := 0; i < 50; i++ { // 50 vezes: se dependesse da ordem do map, falharia
		if got := chavesOrdenadas(m); !slices.Equal(got, want) {
			t.Fatalf("iteração %d: %v; want %v", i, got, want)
		}
	}
}

// ==================== t.Cleanup em vez de tearDown ====================
func criaAmbiente(t *testing.T) string {
	dir := t.TempDir() // limpo automaticamente
	t.Cleanup(func() { fmt.Print("") })
	return dir
}

func TestUsaAmbiente(t *testing.T) {
	if criaAmbiente(t) == "" {
		t.Fatal("TempDir vazio")
	}
}

Rode com go test -v ./... para ver os subtestes nomeados, e com go test -run 'TestCalculaTotal/acima' ./... para rodar um caso só. O TestCoberturaSemAsserção está ali de propósito: ele passa, dá cobertura total, e não prova nada.

Relacionado


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

Buscar

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