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.out

Alé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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

✓ Resposta:
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)
            }
        })
    }
}

✓ Resposta:
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")
    }
}

✓ Resposta:
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)
    }
}

✓ Resposta:Comando: 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.

✓ Resposta:
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.

Referências