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 navegador

Você 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

Exercícios

  1. 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)
                    }
                }
            })
        }
    }
  2. 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)
        }
    }
  3. Escreva um helper assertEqual que compare dois valores inteiros e reporte erro com mensagem personalizada. Use-o em um teste para uma função Soma.

    ✓ 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")
    }
  4. 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:
    ok      exemplo/pacote    0.123s    coverage: 85.7% of statements
    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, use go tool cover -func=coverage.out.
  5. 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.Cleanup a defer em 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.