Testes avançado
Nesta aula avançada de testes em Go, você aprenderá a criar subtests para organizar e executar testes de forma granular, gerenciar setup e teardown com t.Cleanup, criar funções helper para reduzir repetição e medir a cobertura de código com as ferramentas nativas do Go.
Nesta aula, vamos explorar técnicas avançadas de testes em Go, fundamentais para escrever suítes de teste robustas e manuteníveis. Você já conhece o básico: funções de teste com `testing.T`, asserções simples e a execução com `go test`. Agora, vamos mergulhar em recursos que permitem estruturar testes mais complexos, como subtests, gerenciamento de ciclo de vida (setup/teardown), funções helper e análise de cobertura de código.
Esses conceitos são essenciais para projetos reais, onde a base de código cresce e os testes precisam ser organizados de forma clara, executados de maneira eficiente e fornecer feedback rápido sobre a saúde do sistema. Ao dominar esses recursos, você será capaz de escrever testes que não apenas verificam a corretude, mas também servem como documentação viva e facilitam a manutenção.
Subtests
Subtests são uma forma de organizar testes dentro de uma função de teste, permitindo que você execute vários casos de teste de forma independente, com nomes descritivos e relatórios granulares. Eles são especialmente úteis para testar funções que têm múltiplos casos de entrada/saída, eliminando a necessidade de criar várias funções de teste separadas.
Para criar um subtest, usamos o método `t.Run()`, que recebe um nome e uma função de teste. O subteste pode ter seu próprio `t` (um `*testing.T`) e pode ser executado seletivamente usando a flag `-run` do `go test`. Por exemplo, `go test -run 'TestFoo/Bar'` executa apenas o subteste chamado "Bar" dentro de `TestFoo`. Isso é extremamente útil para depurar falhas específicas.
Vamos ver um exemplo prático. Suponha que temos uma função `Divide` que divide dois números e retorna um erro se o divisor for zero:
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}Podemos escrever um teste com subtests para cada cenário:
func TestDivide(t *testing.T) {
testCases := []struct {
name string
a, b int
want int
err bool
}{
{"positive", 10, 2, 5, false},
{"negative", -10, 2, -5, false},
{"zero", 10, 0, 0, true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := Divide(tc.a, tc.b)
if tc.err {
if err == nil {
t.Errorf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("Divide(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
}
})
}
}Com subtests, a saída do `go test -v` mostra cada caso individualmente, facilitando a identificação de falhas. Além disso, você pode usar `t.Run` dentro de outros subtests, criando hierarquias, e até mesmo executar subtests em paralelo com `t.Parallel()` dentro do subteste, se necessário.
Setup/teardown
Em muitos testes, você precisa preparar recursos antes de executar os testes (setup) e limpá-los depois (teardown). Em Go, a abordagem tradicional é usar funções `setup` e `teardown` dentro de cada teste, mas isso pode levar a duplicação. A maneira mais elegante é usar `t.Cleanup`, que registra funções que serão chamadas quando o teste terminar, seja por sucesso, falha ou pânico. Isso garante que a limpeza seja executada mesmo se o teste falhar.
`t.Cleanup` é adicionado ao `*testing.T` e é chamado após o teste, na ordem LIFO (último a ser registrado, primeiro a ser executado). Isso é útil para liberar recursos como arquivos, conexões de rede ou bancos de dados.
Vamos supor que temos um teste que precisa criar um arquivo temporário. Podemos usar `t.Cleanup` para removê-lo:
func TestFileProcessing(t *testing.T) {
// Setup: criar arquivo temporário
tmpFile, err := os.CreateTemp("", "test-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
// Registrar limpeza
t.Cleanup(func() {
os.Remove(tmpFile.Name())
tmpFile.Close()
})
// Escrever dados no arquivo
_, err = tmpFile.WriteString("hello")
if err != nil {
t.Fatal(err)
}
// ... teste do processamento
}Se você tiver múltiplos recursos, pode registrar múltiplas funções `Cleanup`. Além disso, `t.Cleanup` pode ser usado dentro de subtests, garantindo que a limpeza seja feita quando o subteste terminar. Outra abordagem comum é criar funções helper que retornam um recurso e registram a limpeza, como veremos na próxima seção.
Helpers
Funções helper são funções auxiliares que encapsulam lógica comum de teste, como criar objetos, configurar estado ou fazer asserções complexas. Elas reduzem duplicação e tornam os testes mais legíveis. Em Go, é comum que funções helper recebam `*testing.T` como primeiro argumento para poderem relatar falhas. É importante usar `t.Helper()` no início da função helper para que, quando ocorrer uma falha, a linha reportada seja a do chamador, não a da própria função helper.
Vamos criar um helper para comparar dois inteiros com uma mensagem personalizada:
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 TestAdd(t *testing.T) {
got := Add(2, 3)
assertEqual(t, got, 5, "Add(2,3)")
}Os helpers também podem ser usados para setup e teardown, retornando um recurso e registrando a limpeza. Por exemplo, um helper para criar um servidor HTTP de teste:
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
t.Cleanup(server.Close)
return server
}
func TestServer(t *testing.T) {
server := newTestServer(t)
resp, err := http.Get(server.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// ... verifique a resposta
}Dessa forma, o helper configura o servidor e garante que ele seja fechado quando o teste terminar, sem poluir o teste com detalhes de limpeza.
Cobertura
A cobertura de código é uma métrica que indica qual porcentagem do código está sendo exercitada pelos testes. O Go fornece ferramentas nativas para calcular e visualizar a cobertura. O comando `go test -cover` exibe a porcentagem de cobertura para o pacote testado. Para um relatório mais detalhado, você pode gerar um arquivo de perfil com `-coverprofile` e usar `go tool cover` para ver em HTML.
Exemplo:
go test -coverprofile=coverage.out
# Para ver o relatório em HTML
go tool cover -html=coverage.outAlém da cobertura geral, você pode usar `-covermode=count` para contar quantas vezes cada instrução é executada, o que ajuda a identificar código que é executado muitas vezes. A cobertura é útil para identificar áreas não testadas, mas não é uma garantia de qualidade: é possível ter 100% de cobertura e ainda assim ter bugs. Portanto, use a cobertura como uma ferramenta de apoio, não como meta absoluta.
Para melhorar a cobertura, você pode usar subtests para testar todos os caminhos e também testar casos de erro. Lembre-se de que a cobertura é calculada por pacote; você pode combinar coberturas de múltiplos pacotes com `-coverpkg=./...`.
Boas Práticas e Observações Finais
Algumas boas práticas para testes avançados em Go:
- Use subtests para testes baseados em tabela, como vimos, para melhorar a legibilidade e o relatório de falhas.
- Sempre use `t.Helper()` em funções helper para que as mensagens de erro apontem para a linha do teste, não para o helper.
- Utilize `t.Cleanup` em vez de `defer` em testes, pois ele é executado mesmo em caso de pânico e é mais integrado ao framework.
- Evite testes que dependem de ordem de execução; cada teste deve ser independente.
- Use `t.Parallel()` com moderação, apenas quando os testes não compartilham recursos que causem corrida.
- Monitore a cobertura, mas não se obceque com 100%; foque nos caminhos críticos.
Essas técnicas vão elevar a qualidade dos seus testes, tornando-os mais confiáveis e fáceis de manter.
Exercícios
- Crie uma função `IsPalindrome(s string) bool` que verifica se uma string é um palíndromo (ignorando maiúsculas e espaços). Escreva um teste com subtests para pelo menos 5 casos, incluindo strings vazias, com espaços e com letras maiúsculas.
- Escreva um teste para uma função `ReadFile(path string) (string, error)` que lê um arquivo. Use `t.Cleanup` para criar um arquivo temporário e removê-lo após o teste. Teste também o caso de arquivo inexistente.
- Crie um helper `assertError(t *testing.T, err error, wantMsg string)` que verifica se o erro não é nulo e se a mensagem contém `wantMsg`. Use `t.Helper()`. Escreva um teste para uma função `ParseInt(s string) (int, error)` que usa o helper para verificar erros.
- Calcule a cobertura do seu pacote com `go test -cover`. Identifique uma função ou trecho que não está coberto e escreva um teste adicional para cobri-lo. Mostre o comando usado e a melhoria na cobertura.
- Escreva um teste para uma função `Sum(nums ...int) int` que usa subtests para testar com nenhum número, com um número e com vários números. Além disso, use `t.Parallel()` nos subtests para executá-los em paralelo, mas explique por que isso é seguro nesse caso.
func IsPalindrome(s string) bool {
s = strings.ToLower(strings.ReplaceAll(s, " ", ""))
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
if runes[i] != runes[j] {
return false
}
}
return true
}
func TestIsPalindrome(t *testing.T) {
testCases := []struct {
name string
input string
want bool
}{
{"empty", "", true},
{"single", "a", true},
{"simple", "racecar", true},
{"spaces", "a man a plan a canal panama", true},
{"uppercase", "Racecar", true},
{"not palindrome", "hello", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := IsPalindrome(tc.input)
if got != tc.want {
t.Errorf("IsPalindrome(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}func TestReadFile(t *testing.T) {
// Setup: criar arquivo temporário
tmpFile, err := os.CreateTemp("", "example-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
content := "hello"
if _, err := tmpFile.WriteString(content); err != nil {
t.Fatal(err)
}
tmpFile.Close()
// Registrar limpeza
t.Cleanup(func() {
os.Remove(tmpFile.Name())
})
// Teste de leitura bem-sucedida
got, err := ReadFile(tmpFile.Name())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != content {
t.Errorf("ReadFile content = %q, want %q", got, content)
}
// Teste de arquivo inexistente
_, err = ReadFile("/tmp/nonexistent-file-12345")
if err == nil {
t.Error("expected error for nonexistent file, got nil")
}
}func assertError(t *testing.T, err error, wantMsg string) {
t.Helper()
if err == nil {
t.Errorf("expected error containing %q, got nil", wantMsg)
return
}
if !strings.Contains(err.Error(), wantMsg) {
t.Errorf("expected error containing %q, got %q", wantMsg, err.Error())
}
}
func TestParseInt(t *testing.T) {
_, err := ParseInt("abc")
assertError(t, err, "invalid syntax")
_, err = ParseInt("123")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}go test -cover. Se a cobertura estiver baixa, identifique funções não testadas. Por exemplo, se houver uma função `IsEven` não testada, adicione um teste para ela. Exemplo de melhoria: de 60% para 85% após adicionar testes para casos de borda.func TestSum(t *testing.T) {
testCases := []struct {
name string
nums []int
want int
}{
{"none", nil, 0},
{"one", []int{5}, 5},
{"several", []int{1,2,3}, 6},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := Sum(tc.nums...)
if got != tc.want {
t.Errorf("Sum(%v) = %d, want %d", tc.nums, got, tc.want)
}
})
}
}É seguro usar `t.Parallel()` porque cada subteste tem sua própria cópia de `tc` (devido à atribuição `tc := tc` antes do `t.Run`), e os subtests não compartilham variáveis mutáveis. Eles apenas leem `tc` e chamam `Sum`, que é pura.