Go Admin

Dominando Testes em Go: testing Package, Table-Driven Tests e Subtests em Projetos Reais Já leu

O Package Testing em Go: Fundamentos Go oferece uma abordagem minimalista e integrada para testes através do package . 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 e receber um parâmetro . 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. O parâmetro oferece métodos para relatar falhas: , , e . Use quando o teste deve continuar (permitindo múltiplas asserções), e quando a falha indica

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.

Referências


Artigos relacionados