Testes avançado
Nesta aula, você aprenderá técnicas avançadas de testes em Go, incluindo subtests para organizar e executar testes em subgrupos, setup/teardown para preparar e limpar recursos, helpers para reutilizar lógica de teste e medição de cobertura de código. O conteúdo é prático, com exemplos de código e exercícios para fixação.
Nesta aula, vamos explorar técnicas avançadas de testes na linguagem Go. Testes são fundamentais para garantir a qualidade e a manutenibilidade do software, e o Go oferece um pacote de testes poderoso, porém simples. Vamos além do básico: aprenderemos a organizar testes de forma mais eficiente com subtests, a preparar e limpar ambientes de teste com setup/teardown, a extrair lógica repetida em helpers e a medir a cobertura de código para saber o quanto nossos testes exercitam o código.
Essas técnicas são essenciais para projetos reais, onde a suíte de testes cresce e precisa ser mantida. Dominá-las permitirá que você escreva testes mais claros, robustos e fáceis de manter, além de identificar lacunas na cobertura.
Subtests
Subtests são testes dentro de testes. Eles foram introduzidos no Go 1.7 e permitem agrupar casos de teste relacionados sob um mesmo teste pai. Isso é útil quando você tem várias variações de uma mesma funcionalidade, como diferentes entradas para uma função. Com subtests, você pode executá-los individualmente, com filtros, e obter uma saída mais organizada.
Para criar um subteste, usamos o método t.Run(). Ele recebe um nome (string) e uma função de teste (func(t *testing.T)). O nome do subteste é usado para identificá-lo na saída e pode ser usado para executá-lo seletivamente via flag -run.
Exemplo: vamos testar uma função que soma dois números inteiros.
package main
import (
"testing"
)
func Soma(a, b int) int {
return a + b
}
func TestSoma(t *testing.T) {
casos := []struct {
nome string
a, b int
esperado int
}{
{"números positivos", 2, 3, 5},
{"números negativos", -2, -3, -5},
{"zero e positivo", 0, 5, 5},
{"zero e negativo", 0, -5, -5},
}
for _, caso := range casos {
t.Run(caso.nome, func(t *testing.T) {
resultado := Soma(caso.a, caso.b)
if resultado != caso.esperado {
t.Errorf("Soma(%d, %d) = %d; esperado %d", caso.a, caso.b, resultado, caso.esperado)
}
})
}
}No exemplo acima, cada caso se torna um subteste. Para rodar apenas um subteste, use go test -run 'TestSoma/números_positivos' (note que espaços são convertidos para underscores). Você também pode usar expressões regulares, ex.: -run 'TestSoma/(positivos|negativos)'.
Subtests também permitem execução paralela com t.Parallel() dentro deles, o que pode acelerar testes independentes. No entanto, cuidado com concorrência se houver recursos compartilhados.
Setup/teardown
Em muitos testes, precisamos preparar um ambiente antes de executar as verificações e limpá-lo depois. Isso é conhecido como setup e teardown. Em Go, não há funções especiais de setup/teardown globais, mas podemos usar padrões com t.Cleanup (introduzido no Go 1.14) para registrar funções que serão chamadas quando o teste terminar, seja por sucesso ou falha.
O t.Cleanup é mais flexível que o antigo defer dentro de testes, pois ele é executado mesmo se o teste falhar com t.Fatal ou t.FailNow, e também é chamado para subtests.
Exemplo: suponha que temos um teste que cria um arquivo temporário e precisa removê-lo.
func TestProcessaArquivo(t *testing.T) {
// Setup: cria um arquivo temporário
tmpDir := t.TempDir() // cria diretório temporário e registra limpeza automática
arquivo := filepath.Join(tmpDir, "dados.txt")
err := os.WriteFile(arquivo, []byte("conteúdo"), 0644)
if err != nil {
t.Fatalf("erro ao criar arquivo: %v", err)
}
// Registra teardown
t.Cleanup(func() {
// Remove o arquivo (embora t.TempDir já cuide do diretório)
os.Remove(arquivo)
})
// Executa o teste
resultado := ProcessaArquivo(arquivo)
if resultado != "esperado" {
t.Errorf("resultado inesperado: %s", resultado)
}
}No código acima, t.TempDir() já cria um diretório temporário que será removido automaticamente ao final do teste (isso é um teardown implícito). Mas você pode usar t.Cleanup para outras tarefas, como fechar conexões, remover arquivos, etc.
Outra abordagem é usar funções auxiliares que retornam uma função de cleanup. Por exemplo:
func setupDB(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("erro ao abrir banco: %v", err)
}
t.Cleanup(func() {
db.Close()
})
return db
}Assim, cada teste que precisar de um banco pode chamar setupDB(t) e não se preocupar com a limpeza.
Helpers
Helpers são funções que encapsulam lógica comum de teste para reduzir duplicação e melhorar a legibilidade. Elas seguem a convenção de começar com o nome do teste ou com helper e recebem *testing.T como primeiro argumento. É importante chamar t.Helper() no início da função para que, em caso de falha, a linha de erro seja reportada no local onde o helper foi chamado, e não dentro do helper.
Exemplo: um helper para verificar se um erro é esperado.
func assertError(t *testing.T, err error, msg string) {
t.Helper()
if err == nil {
t.Errorf("esperava erro, mas não houve: %s", msg)
}
}
func TestDivide(t *testing.T) {
_, err := Divide(10, 0)
assertError(t, err, "divisão por zero")
}Também podemos criar helpers para criar dados de teste, comparar resultados, etc. Eles melhoram a manutenção, pois mudanças na lógica de verificação são feitas em um só lugar.
Outro exemplo: helper para criar um usuário de teste.
func criarUsuario(t *testing.T, nome string) *Usuario {
t.Helper()
u, err := NovoUsuario(nome)
if err != nil {
t.Fatalf("erro ao criar usuário: %v", err)
}
return u
}Dentro do helper, usamos t.Fatalf para abortar o teste se algo der errado, pois sem o usuário não há como continuar.
Cobertura
Cobertura de código indica a porcentagem de linhas do seu código que são executadas durante os testes. O Go fornece uma ferramenta integrada via go test -cover. Isso ajuda a identificar partes do código que não estão sendo testadas.
Para obter um relatório detalhado, use go test -coverprofile=coverage.out e depois go tool cover -html=coverage.out para ver em HTML quais linhas estão cobertas (verde) ou não (vermelho).
Exemplo de uso:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# abra coverage.html no navegadorVocê também pode gerar o relatório em texto com go tool cover -func=coverage.out, que mostra a cobertura por função.
É importante notar que alta cobertura não garante que os testes são bons, mas uma cobertura baixa indica áreas de risco. Muitos projetos estabelecem metas de cobertura, por exemplo, 80% ou mais.
Para integração contínua, você pode usar go test -coverprofile=coverage.out -covermode=atomic (para testes paralelos) e depois publicar o relatório em serviços como Codecov ou Coveralls.
Referências
- Usando subtests e sub-benchmarks (Blog oficial do Go)
- Pacote testing - documentação oficial
- Effective Go: Testing
- The cover story (Blog oficial sobre cobertura)
- Especificação da linguagem Go - inicialização
- Go Advanced Tips & Tricks (Medium)
Exercícios
Escreva uma função
Fatorial(n int) (int, error)que retorne o fatorial de n, ou um erro se n for negativo. Crie testes com subtests para os casos: n=0, n=1, n=5, n=10, e n=-1 (espera erro). Use a tabela de casos.✓ Resposta:func Fatorial(n int) (int, error) { if n < 0 { return 0, fmt.Errorf("fatorial de número negativo: %d", n) } resultado := 1 for i := 2; i <= n; i++ { resultado *= i } return resultado, nil } func TestFatorial(t *testing.T) { casos := []struct { nome string n int esperado int esperaErro bool }{ {"zero", 0, 1, false}, {"um", 1, 1, false}, {"cinco", 5, 120, false}, {"dez", 10, 3628800, false}, {"negativo", -1, 0, true}, } for _, caso := range casos { t.Run(caso.nome, func(t *testing.T) { resultado, err := Fatorial(caso.n) if caso.esperaErro { if err == nil { t.Error("esperava erro, mas não houve") } } else { if err != nil { t.Errorf("erro inesperado: %v", err) } if resultado != caso.esperado { t.Errorf("Fatorial(%d) = %d; esperado %d", caso.n, resultado, caso.esperado) } } }) } }Crie um teste para uma função que lê um arquivo e retorna o número de linhas. Use setup/teardown para criar um arquivo temporário com algumas linhas e garantir que ele seja removido após o teste.
✓ Resposta:func ContarLinhas(arquivo string) (int, error) { dados, err := os.ReadFile(arquivo) if err != nil { return 0, err } linhas := strings.Split(string(dados), "\n") return len(linhas), nil } func TestContarLinhas(t *testing.T) { // Setup tmpDir := t.TempDir() arquivo := filepath.Join(tmpDir, "teste.txt") conteudo := "linha1\nlinha2\nlinha3\n" err := os.WriteFile(arquivo, []byte(conteudo), 0644) if err != nil { t.Fatalf("erro ao criar arquivo: %v", err) } // Teardown é automático via t.TempDir, mas podemos registrar explicitamente t.Cleanup(func() { os.Remove(arquivo) }) // Teste linhas, err := ContarLinhas(arquivo) if err != nil { t.Fatalf("erro ao contar linhas: %v", err) } if linhas != 3 { t.Errorf("esperava 3 linhas, obteve %d", linhas) } }Escreva um helper
assertEqualque compare dois valores inteiros e reporte erro com mensagem personalizada. Use-o em um teste para uma funçãoSoma.✓ Resposta:func assertEqual(t *testing.T, got, want int, msg string) { t.Helper() if got != want { t.Errorf("%s: got %d, want %d", msg, got, want) } } func TestSomaComHelper(t *testing.T) { assertEqual(t, Soma(2, 3), 5, "soma de positivos") assertEqual(t, Soma(-1, 1), 0, "soma com negativo") assertEqual(t, Soma(0, 0), 0, "soma de zeros") }Execute a cobertura de testes em um pacote simples (crie um pacote com algumas funções) e explique o resultado. Mostre o comando e um trecho da saída.
✓ Resposta:Comando:go test -cover ./...
Saída típica:
Isso significa que 85.7% das instruções do pacote foram executadas durante os testes. Se houver funções não testadas, a cobertura será menor. Para ver detalhes, useok exemplo/pacote 0.123s coverage: 85.7% of statementsgo tool cover -func=coverage.out.Escreva um teste para uma função que valida um email. Use subtests para casos válidos e inválidos. Inclua um helper para verificar se o resultado é o esperado.
✓ Resposta:func ValidarEmail(email string) bool { // Implementação simples: contém @ e . return strings.Contains(email, "@") && strings.Contains(email, ".") } func TestValidarEmail(t *testing.T) { casos := []struct { nome string email string esperado bool }{ {"válido simples", "user@example.com", true}, {"válido com subdomínio", "user@mail.example.com", true}, {"sem @", "userexample.com", false}, {"sem ponto", "user@example", false}, {"vazio", "", false}, } for _, caso := range casos { t.Run(caso.nome, func(t *testing.T) { resultado := ValidarEmail(caso.email) if resultado != caso.esperado { t.Errorf("ValidarEmail(%q) = %v; esperado %v", caso.email, resultado, caso.esperado) } }) } }
Boas práticas e observações finais
Ao escrever testes avançados, lembre-se de:
- Organize com subtests: sempre que tiver múltiplos casos para uma função, use subtests para tornar a saída clara e permitir execução seletiva.
- Use
t.Helper()em helpers para que mensagens de erro apontem para a linha correta do teste. - Prefira
t.Cleanupadeferem testes, pois ele é executado mesmo em falhas fatais e funciona com subtests. - Monitore a cobertura regularmente, mas não a torne uma meta cega: foque em testar comportamentos críticos.
- Testes paralelos podem melhorar o desempenho, mas use com cuidado se houver estado compartilhado.