ENTENDA A IDEIA
A ideia central
Você escreveu uma função que calcula frete. Testou com um total qualquer, viu o número sair, seguiu para a próxima tarefa. Duas semanas depois alguém muda a comparação de >= para > e o cliente no limite passa a pagar frete indevido. O teste que existia não percebeu nada, porque ele exercitava um total longe da fronteira. O problema não foi a mudança — mudanças vão acontecer. O problema foi o teste não contar a história da regra.
ENTENDA A IDEIA
Table-driven tests existem para fazer essa história ficar visível. Em vez de espalhar if got != want em cinco funções quase iguais, você reúne entradas, resultados esperados e o motivo de cada caso em um único slice de structs. O laço percorre a tabela e executa a mesma asserção para cada linha. O que sobra para revisar é a lista de casos: ela precisa responder, sozinha, quais comportamentos o contrato protege.
ENTENDA A IDEIA
O risco de uma tabela fraca é o mesmo de uma função sem teste: ela executa, fica verde e dá uma falsa sensação de segurança. Uma tabela só vale quando escolhe entradas capazes de distinguir o contrato correto de uma mutação plausível. Se você só copia variações do mesmo caminho feliz, qualquer defeito pequeno passa batido. Por isso o critério de uma boa linha não é quantidade, é poder de discriminação: qual mudança essa entrada detectaria?
Este mini aplica uma inversão que muda o jogo. Em vez de implementar a função de produção, você implementa o teste. O avaliador recebe sua suíte e a executa contra duas implementações do mesmo contrato: uma conhecida como correta (good) e outra com um defeito deliberado (broken). A aprovação exige duas evidências — nenhuma acusação falsa contra good e ao menos uma acusação verdadeira contra broken. Um teste vazio passa no primeiro critério e falha no segundo; um teste que sempre falha faz o oposto. Sobrará apenas a suíte que realmente discrimina.
A assinatura usa TestingT, uma interface mínima com Helper, Errorf e Run. O corpo continua parecendo um teste Go de verdade: nome começando em Test, tabela de casos, subtestes e mensagens com got e want. A interface existe apenas porque o avaliador concatena seu teste com o harness e roda go run, em vez de orquestrar um subprocesso de go test. Em um repositório normal, você troca TestingT por *testing.T e arquiva a função em um _test.go.
A bancada abaixo resume o mecanismo em quatro passos. O primeiro frame estabelece a tabela. O segundo mostra a implementação boa aprovando cada comparação. O terceiro injeta a mutação e mostra a mesma tabela acusando o caso decisivo. O quarto reúne as duas evidências: passa-na-boa e falha-na-quebrada. Cores não decoram; elas informam o resultado de cada comparação.
EXPERIMENTE
Experimente com o código
No programa abaixo, preveja a saída das três linhas antes de rodar. Depois mude a condição de shipping para total > 10000 e identifique qual entrada denuncia a regressão. Acrescente o total zero e pergunte se ele revela um comportamento novo ou só repete "abaixo". Por fim, troque o Println por uma mensagem que inclua nome do caso, entrada, obtido e esperado; compare quanto tempo você leva para diagnosticar a falha com cada versão da mensagem.
Não acrescente casos por quantidade. Faça uma previsão antes de cada alteração: qual mutação esse novo caso mataria? Se você não sabe responder, ele provavelmente não aumenta a confiança.
CÓDIGO ANOTADO
Código anotado
O programa abaixo é um teste de mesa executável. Ele não substitui go test; torna visível o fluxo que o pacote testing coordena e prepara o raciocínio que você vai aplicar no exercício.
package main
import "fmt"
// testCase é uma linha da tabela: nome, entrada e esperado.
type testCase struct {
name string
input, want int
} CÓDIGO ANOTADO
A função shipping codifica a regra: frete zero a partir de 10000 centavos, limite inclusivo. É exatamente o tipo de fronteira que separa um bom caso de teste de um caso inútil.
// shipping devolve o frete; zero a partir do limite inclusivo.
func shipping(total int) int {
if total >= 10000 {
return 0
}
return 1500
} CÓDIGO ANOTADO
A tabela dá nome ao motivo de cada entrada. O caso no limite é o que distingue >= de >; abaixo e acima servem para confirmar que a regra não vaza para os vizinhos.
func main() {
cases := []testCase{
{"abaixo", 9999, 1500},
{"no limite", 10000, 0},
{"acima", 10001, 0},
} CÓDIGO ANOTADO
A execução compara valores reais. Em um _test.go, a impressão vira t.Errorf; a estrutura da decisão permanece idêntica.
for _, tc := range cases {
got := shipping(tc.input)
ok := got == tc.want
fmt.Printf("%s: pass=%t got=%d want=%d\n",
tc.name, ok, got, tc.want)
}
} PASSO A PASSO
Veja a ideia em movimento
Avance pelos passos e observe como o estado muda a cada decisão.
DESAFIO
Escreva o teste de Normalize
Escreva TestNormalize(t TestingT, implementation Implementation). Você recebe a função de produção como argumento e precisa testá-la, sem implementá-la. O contrato é normalizar um nome para minúsculas e sem espaços nas bordas (TrimSpace + ToLower). Monte uma tabela de casos, execute cada linha com t.Run e compare o resultado real com want.
O avaliador roda seu teste em duas implementações do mesmo contrato. No caso 1, a versão good (com TrimSpace) deve terminar sem nenhum Errorf. No caso 2, a versão broken (que esquece o TrimSpace) deve provocar ao menos um Errorf. Exemplo de raciocínio: a entrada " ANA " vira "ana" na versão boa e " ana " na versão quebrada. Não tente descobrir qual versão recebeu; observe somente o comportamento.
Restrições: execução determinística, sem rede, sem relógio e mensagens que incluam got e want. O teste deve continuar válido se a implementação interna for reescrita sem mudar o contrato.
Dica 1
Inclua uma entrada com espaços nas bordas: é onde o contrato e a mutação divergem.
Dica 2
Monte uma tabela e compare got com want dentro de t.Run.
A solução aparece depois do acerto ou de 3 tentativas.
Solução e explicação
package main
// TestNormalize verifica normalização (minúsculas + sem espaços nas bordas)
// com uma tabela. O caso "com espaços" é o que distingue good de broken.
func TestNormalize(t TestingT, implementation Implementation) {
t.Helper()
cases := []struct {
name, input, want string
}{
{"com espaços nas bordas", " ANA ", "ana"},
{"entrada já limpa", "carlos", "carlos"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t TestingT) {
if got := implementation(tc.input); got != tc.want {
t.Errorf("got %q; want %q", got, tc.want)
}
})
}
}
Ver harness de testes somente leitura
package main
import (
"encoding/json"
"fmt"
"strings"
)
// Implementation é o contrato testado: normaliza um nome.
type Implementation func(string) string
// TestingT reproduz a parte de *testing.T usada pelo teste (Helper, Errorf, Run).
type TestingT interface {
Helper()
Errorf(string, ...any)
Run(string, func(TestingT)) bool
}
// recorder grava se alguma assertion chamou Errorf; Run propaga a falha do filho.
type recorder struct{ failed bool }
func (r *recorder) Helper() {}
func (r *recorder) Errorf(string, ...any) { r.failed = true }
func (r *recorder) Run(_ string, fn func(TestingT)) bool {
child := &recorder{}
fn(child)
r.failed = r.failed || child.failed
return !child.failed
}
// good implementa o contrato completo: minúsculas e sem espaços nas bordas.
func good(v string) string { return strings.ToLower(strings.TrimSpace(v)) }
// broken esquece o TrimSpace: uma mutação plausível de revisão.
func broken(v string) string { return strings.ToLower(v) }
type caseResult struct {
Case int `json:"case"`
Pass bool `json:"pass"`
Input string `json:"input"`
Want string `json:"want"`
Got string `json:"got"`
}
func emit(v caseResult) {
line, _ := json.Marshal(v)
fmt.Printf("RESULT %s\n", line)
}
func main() {
goodRun := &recorder{}
TestNormalize(goodRun, good)
brokenRun := &recorder{}
TestNormalize(brokenRun, broken)
emit(caseResult{Case: 1, Pass: !goodRun.failed, Input: "known-good", Want: "nenhum Errorf", Got: fmt.Sprintf("failed=%t", goodRun.failed)})
emit(caseResult{Case: 2, Pass: brokenRun.failed, Input: "known-broken", Want: "ao menos um Errorf", Got: fmt.Sprintf("failed=%t", brokenRun.failed)})
}
DESAFIO
Feche a fronteira de Shipping
Escreva TestShipping(t TestingT, implementation Implementation). O contrato é frete zero a partir de 10000 centavos (limite inclusivo) e 1500 abaixo. Você recebe a função de produção e precisa testá-la, sem implementá-la.
O avaliador roda seu teste em duas implementações. No caso 1, a versão good (limite inclusivo >=) deve terminar sem Errorf. No caso 2, a versão broken (limite exclusivo >) deve provocar ao menos um Errorf. A divergência acontece exatamente em 10000. Inclua o limite e ao menos um vizinho de cada lado.
Restrições: execução determinística, sem rede, sem relógio e mensagens com got e want. O teste deve continuar válido se a implementação interna for reescrita sem mudar o contrato.
Dica 1
A divergência está exatamente no limite 10000 (inclusivo vs exclusivo).
Dica 2
Inclua o limite e um vizinho de cada lado, nomeando cada linha.
A solução aparece depois do acerto ou de 3 tentativas.
Solução e explicação
package main
// TestShipping verifica a regra de frete. O caso "no limite" (10000)
// é a fronteira que distingue good (inclusivo) de broken (exclusivo).
func TestShipping(t TestingT, implementation Implementation) {
t.Helper()
cases := []struct {
name string
input, want int
}{
{"abaixo do limite", 9999, 1500},
{"no limite", 10000, 0},
{"acima do limite", 10001, 0},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t TestingT) {
if got := implementation(tc.input); got != tc.want {
t.Errorf("got %d; want %d", got, tc.want)
}
})
}
}
Ver harness de testes somente leitura
package main
import (
"encoding/json"
"fmt"
)
// Implementation é o contrato testado: frete sobre um total em centavos.
type Implementation func(int) int
// TestingT reproduz a parte de *testing.T usada pelo teste (Helper, Errorf, Run).
type TestingT interface {
Helper()
Errorf(string, ...any)
Run(string, func(TestingT)) bool
}
// recorder grava se alguma assertion chamou Errorf; Run propaga a falha do filho.
type recorder struct{ failed bool }
func (r *recorder) Helper() {}
func (r *recorder) Errorf(string, ...any) { r.failed = true }
func (r *recorder) Run(_ string, fn func(TestingT)) bool {
child := &recorder{}
fn(child)
r.failed = r.failed || child.failed
return !child.failed
}
func choose(condition bool, yes, no int) int {
if condition {
return yes
}
return no
}
// good cobra frete zero a partir de 10000 (limite inclusivo).
func good(total int) int { return choose(total >= 10000, 0, 1500) }
// broken usa limite exclusivo: a fronteira 10000 diverge.
func broken(total int) int { return choose(total > 10000, 0, 1500) }
type caseResult struct {
Case int `json:"case"`
Pass bool `json:"pass"`
Input string `json:"input"`
Want string `json:"want"`
Got string `json:"got"`
}
func emit(v caseResult) {
line, _ := json.Marshal(v)
fmt.Printf("RESULT %s\n", line)
}
func main() {
goodRun := &recorder{}
TestShipping(goodRun, good)
brokenRun := &recorder{}
TestShipping(brokenRun, broken)
emit(caseResult{Case: 1, Pass: !goodRun.failed, Input: "known-good", Want: "nenhum Errorf", Got: fmt.Sprintf("failed=%t", goodRun.failed)})
emit(caseResult{Case: 2, Pass: brokenRun.failed, Input: "known-broken", Want: "ao menos um Errorf", Got: fmt.Sprintf("failed=%t", brokenRun.failed)})
}
VOCÊ CHEGOU AO RESUMO
O que você leva desta aula
- Uma tabela vale pelas entradas que escolhe; cada linha deve detectar uma mutação diferente.
- O caso de fronteira (limite inclusivo vs exclusivo) costuma ser o detector mais barato.
t.Rundá nome a cada caso e permite selecionar um cenário comgo test -run.- Asserções comparam comportamento real; executar código sem verificar resultado não protege nada.
- O harness invertido aprova só a suíte que passa em
goode falha embroken.
Na próxima aula, você amplia esse instrumento: os mesmos casos ganham endereço e isolamento com subtestes nomeados.