Testes Unitários em Go: Estrutura & Boas Práticas

Testes em Go, do básico aos padrões avançados

Conteúdo da página

O pacote de teste embutido no Go fornece um poderoso e minimalista framework para escrever testes unitários sem dependências externas. Aqui estão os fundamentos dos testes, a estrutura do projeto e padrões avançados para construir aplicações Go confiáveis.

Testes unitários no Go são incríveis

Por que os testes importam no Go

A filosofia do Go enfatiza simplicidade e confiabilidade. A biblioteca padrão inclui o pacote testing, tornando os testes unitários um cidadão de primeira classe no ecossistema Go. Código Go bem testado melhora a manutenibilidade, detecta bugs cedo e fornece documentação por meio de exemplos. Se você é novo no Go, consulte nossa Folha de Dicas do Go para uma referência rápida dos fundamentos da linguagem.

Principais benefícios dos testes no Go:

  • Suporte embutido: Não são necessários frameworks externos
  • Execução rápida: Execução de testes concorrente por padrão
  • Sintaxe simples: Pouca quantidade de código de boilerplate
  • Ferramentas ricas: Relatórios de cobertura, benchmarks e perfilamento
  • Amigável para CI/CD: Integração fácil com pipelines automatizados

Estrutura do Projeto para Testes Go

Os testes Go vivem ao lado do seu código de produção com uma convenção clara de nomes:

myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│   ├── helper.go
│   └── helper_test.go
└── models/
    ├── user.go
    └── user_test.go

Convenções importantes:

  • Os arquivos de teste terminam com _test.go
  • Os testes estão no mesmo pacote que o código (ou usam o sufixo _test para testes de caixa preta)
  • Cada arquivo de origem pode ter um arquivo de teste correspondente

Abordagens de Teste por Pacote

Teste de caixa branca (mesmo pacote):

package calculator

import "testing"
// Pode acessar funções e variáveis não exportadas

Teste de caixa preta (pacote externo):

package calculator_test

import (
    "testing"
    "myproject/calculator"
)
// Pode acessar apenas funções exportadas (recomendado para APIs públicas)

Estrutura Básica dos Testes

Cada função de teste segue este padrão:

package calculator

import "testing"

// A função de teste deve começar com "Test"
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

Métodos de testing.T:

  • t.Error() / t.Errorf(): Marca o teste como falhado, mas continua
  • t.Fatal() / t.Fatalf(): Marca o teste como falhado e para imediatamente
  • t.Log() / t.Logf(): Registra saída (apenas mostrado com a bandeira -v)
  • t.Skip() / t.Skipf(): Pula o teste
  • t.Parallel(): Executa o teste em paralelo com outros testes em paralelo

Testes com Tabela: A Maneira Go

Testes com tabela são a abordagem idiomática do Go para testar múltiplos cenários. Com Generics no Go, você também pode criar ajudantes de teste seguros de tipo que funcionam com diferentes tipos de dados:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"adição", 2, 3, "+", 5, false},
        {"subtração", 5, 3, "-", 2, false},
        {"multiplicação", 4, 3, "*", 12, false},
        {"divisão", 10, 2, "/", 5, false},
        {"divisão por zero", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Calculate(tt.a, tt.b, tt.op)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if result != tt.expected {
                t.Errorf("Calculate(%d, %d, %q) = %d; want %d", 
                    tt.a, tt.b, tt.op, result, tt.expected)
            }
        })
    }
}

Vantagens:

  • Uma única função de teste para múltiplos cenários
  • Fácil adição de novos casos de teste
  • Documentação clara do comportamento esperado
  • Melhor organização e manutenibilidade dos testes

Executando Testes

Comandos Básicos

# Executar testes no diretório atual
go test

# Executar testes com saída detalhada
go test -v

# Executar testes em todos os subdiretórios
go test ./...

# Executar um teste específico
go test -run TestAdd

# Executar testes que correspondem a um padrão
go test -run TestCalculate/addition

# Executar testes em paralelo (padrão é GOMAXPROCS)
go test -parallel 4

# Executar testes com timeout
go test -timeout 30s

Cobertura de Testes

# Executar testes com cobertura
go test -cover

# Gerar perfil de cobertura
go test -coverprofile=coverage.out

# Visualizar cobertura no navegador
go tool cover -html=coverage.out

# Mostrar cobertura por função
go tool cover -func=coverage.out

# Definir modo de cobertura (set, count, atomic)
go test -covermode=count -coverprofile=coverage.out

Flags Úteis

  • -short: Executar testes marcados com if testing.Short()
  • -race: Habilitar detector de corrida (encontra problemas de acesso concorrente)
  • -cpu: Especificar valores de GOMAXPROCS
  • -count n: Executar cada teste n vezes
  • -failfast: Parar na primeira falha de teste

Funções de Ajuda e Configuração/Desmontagem

Funções de Ajuda

Marque funções de ajuda com t.Helper() para melhorar o relatório de erros:

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // Esta linha é reportada como o chamador
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    result := Add(2, 3)
    assertEqual(t, result, 5) // Linha de erro aponta aqui
}

Configuração e Desmontagem

func TestMain(m *testing.M) {
    // Código de configuração aqui
    setup()
    
    // Executar testes
    code := m.Run()
    
    // Código de desmontagem aqui
    teardown()
    
    os.Exit(code)
}

Fixtures de Teste

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("setup test case")
    return func(t *testing.T) {
        t.Log("teardown test case")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTestCase(t)
    defer teardown(t)
    
    // Código de teste aqui
}

Mocking e Injeção de Dependência

Mocking Baseado em Interface

Quando testando código que interage com bancos de dados, usar interfaces facilita a criação de implementações de mock. Se você estiver trabalhando com PostgreSQL no Go, veja nossa comparação de ORMs do Go para escolher a biblioteca de banco de dados certa com boa testabilidade.

// Código de produção
type Database interface {
    GetUser(id int) (*User, error)
}

type UserService struct {
    db Database
}

func (s *UserService) GetUserName(id int) (string, error) {
    user, err := s.db.GetUser(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

// Código de teste
type MockDatabase struct {
    users map[int]*User
}

func (m *MockDatabase) GetUser(id int) (*User, error) {
    if user, ok := m.users[id]; ok {
        return user, nil
    }
    return nil, errors.New("user not found")
}

func TestGetUserName(t *testing.T) {
    mockDB := &MockDatabase{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice"},
        },
    }
    
    service := &UserService{db: mockDB}
    name, err := service.GetUserName(1)
    
    if err != nil {
        t.Fatalf("erro inesperado: %v", err)
    }
    if name != "Alice" {
        t.Errorf("got %s, want Alice", name)
    }
}

Bibliotecas de Teste Populares

Testify

A biblioteca de teste mais popular do Go para afirmações e mocks:

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)
    assert.Equal(t, 5, result, "eles devem ser iguais")
    assert.NotNil(t, result)
}

// Exemplo de mock
type MockDB struct {
    mock.Mock
}

func (m *MockDB) GetUser(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}

Outras Ferramentas

  • gomock: Framework de mock do Google com geração de código
  • httptest: Biblioteca padrão para testar manipuladores HTTP
  • testcontainers-go: Testes de integração com contêineres Docker
  • ginkgo/gomega: Framework de teste estilo BDD

Quando testando integrações com serviços externos, como modelos de IA, você precisará mockar ou stubar essas dependências. Por exemplo, se você estiver usando Ollama no Go, considere criar wrappers de interface para tornar seu código mais testável.

Testes de Benchmark

O Go inclui suporte embutido para benchmarks:

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

// Executar benchmarks
// go test -bench=. -benchmem

A saída mostra iterações por segundo e alocações de memória.

Boas Práticas

  1. Escreva testes com tabela: Use o padrão de slice de structs para múltiplos casos de teste
  2. Use t.Run para subtestes: Melhor organização e pode executar subtestes seletivamente
  3. Teste primeiramente funções exportadas: Foque no comportamento da API pública
  4. Mantenha os testes simples: Cada teste deve verificar uma coisa
  5. Use nomes significativos para testes: Descreva o que está sendo testado e o resultado esperado
  6. Não teste detalhes de implementação: Teste comportamento, não internos
  7. Use interfaces para dependências: Facilita o mock
  8. Aim for high coverage, but quality over quantity: 100% de cobertura não significa livre de bugs
  9. Run tests with -race flag: Detecte problemas de concorrência cedo
  10. Use TestMain para configurações caras: Evite repetir a configuração em cada teste

Exemplo: Suite de Testes Completa

package user

import (
    "errors"
    "testing"
)

type User struct {
    ID    int
    Name  string
    Email string
}

func ValidateUser(u *User) error {
    if u.Name == "" {
        return errors.New("o nome não pode estar vazio")
    }
    if u.Email == "" {
        return errors.New("o e-mail não pode estar vazio")
    }
    return nil
}

// Arquivo de teste: user_test.go
func TestValidateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "usuário válido",
            user:    &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
            wantErr: false,
        },
        {
            name:    "nome vazio",
            user:    &User{ID: 1, Name: "", Email: "alice@example.com"},
            wantErr: true,
            errMsg:  "o nome não pode estar vazio",
        },
        {
            name:    "e-mail vazio",
            user:    &User{ID: 1, Name: "Alice", Email: ""},
            wantErr: true,
            errMsg:  "o e-mail não pode estar vazio",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateUser(tt.user)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if err != nil && err.Error() != tt.errMsg {
                t.Errorf("ValidateUser() error message = %v, want %v", err.Error(), tt.errMsg)
            }
        })
    }
}

Conclusão

O framework de teste do Go fornece tudo o que é necessário para testes unitários abrangentes com mínima configuração. Ao seguir idiomas Go como testes com tabela, usar interfaces para mock, e aproveitar ferramentas embutidas, você pode criar suites de testes mantíveis e confiáveis que crescem com sua base de código.

Essas práticas de teste se aplicam a todos os tipos de aplicações Go, desde serviços web até aplicações CLI construídas com Cobra & Viper. Testar ferramentas de linha de comando requer padrões semelhantes com foco adicional no teste de entrada/saída e análise de bandeiras.

Comece com testes simples, adicione gradualmente cobertura e lembre-se de que o teste é um investimento na qualidade do código e na confiança do desenvolvedor. O foco da comunidade Go em testes torna mais fácil manter projetos a longo prazo e colaborar efetivamente com membros da equipe.