O Package Testing em Go: Fundamentos
Go oferece uma abordagem minimalista e integrada para testes através do package testing. Diferente de frameworks pesados em outras linguagens, Go adota a filosofia Unix: fazer uma coisa e fazer bem. Os testes em Go são funções normais que seguem uma convenção simples: devem estar em arquivos terminados com _test.go e receber um parâmetro *testing.T.
A razão dessa simplicidade é profunda. Go foi desenhado para aplicações de servidor de grande escala, onde confiabilidade é crítica. Os criadores perceberam que testes complexos e difíceis de entender frequentemente não são mantidos. Portanto, a biblioteca padrão fornece exatamente o que você precisa, sem abstrações desnecessárias. Quando você cria um teste em Go, está escrevendo código que será lido dezenas de vezes e modificado constantemente — simplicidade não é um luxo, é um requisito.
package calculator
import "testing"
// Função que vamos testar
func Add(a, b int) int {
return a + b
}
// Teste básico usando testing.T
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d, want %d", result, expected)
}
}
O parâmetro *testing.T oferece métodos para relatar falhas: Error(), Errorf(), Fatal() e Fatalf(). Use Errorf() quando o teste deve continuar (permitindo múltiplas asserções), e Fatalf() quando a falha indica um problema tão grave que continuar não faz sentido. Essa distinção é importante para escrever testes que forneçam informações úteis quando falham.
Table-Driven Tests: Escalabilidade sem Repetição
Table-driven tests resolvem um problema real: testar a mesma função com múltiplas entradas. A abordagem ingênua seria repetir o código de teste várias vezes — ineficiente e difícil de manter. A solução elegante de Go é usar uma slice de structs, onde cada struct contém os dados de entrada e a saída esperada.
Este padrão não é apenas uma convenção cultural em Go — é amplamente considerado a forma correta de escrever testes parametrizados. Grandes projetos como o Kubernetes e Docker usam table-driven tests extensivamente. A razão é simples: você consegue adicionar novos casos de teste sem escrever nenhuma função nova, apenas adicionando uma linha à tabela.
package calculator
import "testing"
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivisionByZero
}
return a / b, nil
}
func TestDivideTableDriven(t *testing.T) {
tests := []struct {
name string
a int
b int
want int
wantErr bool
}{
{
name: "positive numbers",
a: 10,
b: 2,
want: 5,
wantErr: false,
},
{
name: "division by zero",
a: 10,
b: 0,
want: 0,
wantErr: true,
},
{
name: "negative result",
a: -10,
b: 2,
want: -5,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if (err != nil) != tt.wantErr {
t.Errorf("Divide(%d, %d) error = %v, wantErr %v",
tt.a, tt.b, err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Divide(%d, %d) = %d, want %d",
tt.a, tt.b, got, tt.want)
}
})
}
}
Observe o padrão: a slice contém uma struct anônima com campos que descrevem cada caso. O campo name é crucial — permite rodar casos específicos via go test -run TestDivideTableDriven/positive. Os nomes também aparecem no relatório de falhas, tornando imediatamente claro qual cenário quebrou. A iteração usa t.Run() para criar subtestes, que cobriremos em detalhes na próxima seção.
Vantagens Práticas do Padrão
O padrão table-driven torna fácil adicionar casos extremos sem modificar lógica. Se você descobrir um bug através de um relatório de usuário, pode reproduzir adicionando um caso à tabela. Isso cria um registro histórico de bugs que ajuda na revisão de código e documentação.
Subtests: Isolamento e Composição
Subtestes, introduzidos no Go 1.7, são testes aninhados criados via t.Run(). Cada subteste executa de forma isolada com seu próprio *testing.T, permitindo que falhas em um subteste não afetem outros. Isso é particularmente poderoso quando combinado com table-driven tests.
A semântica de execução é importante: se um teste falha, os subtestes subsequentes ainda executam. Isso fornece uma visão completa dos problemas. Você pode também interromper a execução condicionalmente usando t.FailNow() ou t.Fatalf(), o que encerra apenas aquele subteste.
package calculator
import "testing"
func TestCalculatorOperations(t *testing.T) {
// Subtest para validação de entrada
t.Run("input validation", func(t *testing.T) {
tests := []struct {
name string
a int
b int
valid bool
}{
{"valid inputs", 5, 3, true},
{"zero allowed", 0, 5, true},
{"negative allowed", -5, 3, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Validação simplificada
valid := tt.a >= -1000 && tt.b >= -1000
if valid != tt.valid {
t.Errorf("validation failed for (%d, %d)", tt.a, tt.b)
}
})
}
})
// Subtest para operações aritméticas
t.Run("arithmetic operations", func(t *testing.T) {
t.Run("addition", func(t *testing.T) {
if Add(2, 3) != 5 {
t.Error("addition failed")
}
})
t.Run("division", func(t *testing.T) {
result, err := Divide(10, 2)
if err != nil || result != 5 {
t.Error("division failed")
}
})
})
}
Subtestes habilitam uma estrutura hierárquica nos seus testes. Você pode agrupar testes relacionados logicamente e ainda executar seletivamente via flags. Por exemplo, go test -run TestCalculatorOperations/arithmetic executa apenas o grupo de operações aritméticas. Isso é extremamente útil em projetos grandes onde querer iterar rapidamente em uma área específica.
Usando Subtests com Cleanup
Go 1.14 introduziu t.Cleanup(), permitindo registrar funções de limpeza que executam ao final de cada subteste. Isso elimina a necessidade de setUp/tearDown em estilos antigos:
func TestWithCleanup(t *testing.T) {
t.Run("test with setup", func(t *testing.T) {
// Setup
resource := acquireResource()
t.Cleanup(func() {
resource.Close()
})
// Seu teste usa resource aqui
if resource.Value() != expected {
t.Error("resource test failed")
}
})
}
Isso garante limpeza mesmo se o teste falhar com t.Fatal(), uma garantia que setUp/tearDown tradicional não oferecia.
Padrões Avançados e Boas Práticas
Testando Erros e Edge Cases
Um erro comum é testar apenas o caminho feliz. Go incentiva tratamento explícito de erros, portanto seus testes devem fazer o mesmo. Estruture suas structs de teste para permitir testes robustos de casos de erro:
package validator
import (
"errors"
"testing"
)
var ErrInvalidEmail = errors.New("invalid email")
func ValidateEmail(email string) error {
if !contains(email, "@") {
return ErrInvalidEmail
}
return nil
}
func contains(s, substr string) bool {
// implementação
for i := 0; i < len(s)-len(substr)+1; i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
input string
wantErr error
}{
{name: "valid email", input: "user@example.com", wantErr: nil},
{name: "missing @", input: "userexample.com", wantErr: ErrInvalidEmail},
{name: "empty string", input: "", wantErr: ErrInvalidEmail},
{name: "only @", input: "@", wantErr: nil}, // Aceita para fins demo
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateEmail(tt.input)
if err != tt.wantErr {
t.Errorf("ValidateEmail(%q) error = %v, want %v",
tt.input, err, tt.wantErr)
}
})
}
}
A chave aqui é nomear casos de erro de forma descritiva e testar tanto o valor de retorno quanto o erro. Não assuma que "sem erro" significa "funcionou" — verifique os valores reais.
Organizando Testes em Arquivos
A convenção é colocar testes no mesmo package do código que testam, em arquivos _test.go. Para packages grandes, você pode criar múltiplos arquivos de teste: unit_test.go, integration_test.go, benchmark_test.go. Use build tags para testes que devem ser opcionais:
// +build integration
package mypackage
import "testing"
func TestIntegration(t *testing.T) {
// Testes que requerem recursos externos
}
Execute apenas testes de integração com go test -tags=integration. Essa separação mantém feedback rápido em testes unitários enquanto permite testes mais completos sob demanda.
Exemplo Realista Completo
Vamos ver um exemplo de um package mais complexo com testes bem estruturados:
package userstore
import "testing"
type User struct {
ID int
Name string
Email string
}
type UserStore struct {
users map[int]*User
nextID int
}
func NewUserStore() *UserStore {
return &UserStore{users: make(map[int]*User), nextID: 1}
}
func (s *UserStore) Create(name, email string) (*User, error) {
if name == "" || email == "" {
return nil, ErrInvalidInput
}
user := &User{ID: s.nextID, Name: name, Email: email}
s.users[s.nextID] = user
s.nextID++
return user, nil
}
func (s *UserStore) GetByID(id int) (*User, error) {
user, exists := s.users[id]
if !exists {
return nil, ErrNotFound
}
return user, nil
}
var (
ErrInvalidInput = errors.New("invalid input")
ErrNotFound = errors.New("user not found")
)
// Testes
func TestUserStore(t *testing.T) {
t.Run("create", func(t *testing.T) {
tests := []struct {
name string
userName string
email string
wantErr bool
}{
{name: "valid user", userName: "John", email: "john@example.com", wantErr: false},
{name: "empty name", userName: "", email: "john@example.com", wantErr: true},
{name: "empty email", userName: "John", email: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := NewUserStore()
user, err := store.Create(tt.userName, tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("Create() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && user.Name != tt.userName {
t.Errorf("Create() user.Name = %s, want %s", user.Name, tt.userName)
}
})
}
})
t.Run("get by id", func(t *testing.T) {
store := NewUserStore()
created, _ := store.Create("Jane", "jane@example.com")
t.Run("existing user", func(t *testing.T) {
user, err := store.GetByID(created.ID)
if err != nil {
t.Fatalf("GetByID() error = %v, want nil", err)
}
if user.Name != "Jane" {
t.Errorf("GetByID() returned wrong user")
}
})
t.Run("non-existing user", func(t *testing.T) {
_, err := store.GetByID(9999)
if err != ErrNotFound {
t.Errorf("GetByID() error = %v, want ErrNotFound", err)
}
})
})
}
Este exemplo mostra como estruturar testes reais: subtestes agrupam funcionalidades relacionadas, table-driven tests cobrem múltiplos cenários, e cada teste é independente (note como cada caso cria seu próprio NewUserStore()).
Conclusão
Dominando testes em Go, você aprendeu três conceitos complementares que trabalham juntos. O package testing fornece a base minimalista necessária para escrever testes confiáveis e mantíveis. Table-driven tests escalam seus testes sem repetição de código, permitindo cobrir casos extremos facilmente. Subtests organizam testes hierarquicamente, habilitando execução seletiva e isolamento claro de responsabilidades.
A verdadeira força surge quando você combina esses padrões em projetos reais. Um developer experiente em Go reconhece que testes simples e bem-organizados são mais valiosos que frameworks sofisticados. Quando seus testes são fáceis de ler, fácil é adicionar novos casos, e portanto mais código você testa. Isso reduz bugs em produção — o verdadeiro objetivo.