Fundamentos de Programação/02 - Código que Presta/Semana 07 - Testes Automatizados8 min
Teste Unitário
- 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.
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.Timeem vez de chamartime.Now()direto - ordem de map:
rangesobre 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.
testifyinverte 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_testcompra 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, "JÁ\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
- 2. Teste de Integração — o que o unitário nunca vai pegar
- 4. Mocks e Stubs — como isolar sem acoplar o teste à implementação
- 5. TDD — escrever o teste primeiro muda o design
- 4. Refatorações Nomeadas — semana 6, teste é o que autoriza dizer "sem mudar comportamento"
Parte de Semana 07 - Testes Automatizados · 00 - MOC Fundamentos de Programação