Testes de Unidade em Go: Estrutura e Melhores Práticas

Testes em Go: dos conceitos básicos aos padrões avançados

Conteúdo da página

O pacote de testes nativo do Go oferece uma poderosa e minimalista estrutura para escrever testes unitários sem dependências externas. Aqui estão os fundamentos de teste, a estrutura do projeto e padrões avançados para criar aplicações Go confiáveis.

Go Unit Testing é incrível

Por que Testar é Importante em Go

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

Principais benefícios dos testes em Go:

  • Suporte nativo: Nenhuma estrutura externa necessária
  • Execução rápida: Execução de testes concorrentes por padrão
  • Sintaxe simples: Código boilerplate mínimo
  • Ferramentas ricas: Relatórios de cobertura, benchmarks e perfis
  • Amigável para CI/CD: Integração fácil com pipelines automatizados

Estrutura de Projeto para Testes em Go

Os testes em Go vivem junto com o código de produção seguindo uma convenção de nomenclatura clara:

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

Convenções principais:

  • Arquivos de teste terminam com _test.go
  • 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 de 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 de Teste

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) {
    resultado := Add(2, 3)
    esperado := 5
    
    if resultado != esperado {
        t.Errorf("Add(2, 3) = %d; querendo %d", resultado, esperado)
    }
}

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 (mostrado apenas com a flag -v)
  • t.Skip() / t.Skipf(): Pulou o teste
  • t.Parallel(): Executa o teste em paralelo com outros testes paralelos

t.Log é para diagnósticos de teste legíveis para humanos. Em serviços em execução, log/slog e registros amigáveis a JSON geralmente são uma melhor correspondência para agregação e depuração de incidentes. Veja Log Estruturado em Go com slog para Observabilidade e Alertas.

Testes Orientados a Tabela: A Maneira Go

Os testes orientados a tabela são a abordagem idiomática do Go para testar múltiplos cenários. Com generics do Go, você também pode criar auxiliares de teste seguros em tipos que funcionam em diferentes tipos de dados:

func TestCalculate(t *testing.T) {
    testes := []struct {
        nome     string
        a, b     int
        op       string
        esperado int
        querErro 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 testes {
        t.Run(tt.nome, func(t *testing.T) {
            resultado, err := Calculate(tt.a, tt.b, tt.op)
            
            if (err != nil) != tt.querErro {
                t.Errorf("Calculate() erro = %v, querErro %v", err, tt.querErro)
                return
            }
            
            if resultado != tt.esperado {
                t.Errorf("Calculate(%d, %d, %q) = %d; querendo %d", 
                    tt.a, tt.b, tt.op, resultado, tt.esperado)
            }
        })
    }
}

Vantagens:

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

Executando Testes

Comandos Básicos

# Executar testes no diretório atual
go test

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

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

# Executar teste específico
go test -run TestAdd

# Executar testes correspondentes ao padrão
go test -run TestCalculate/adição

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

# Executar testes com tempo limite
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: Executa testes marcados com verificações if testing.Short()
  • -race: Ativa o detector de race conditions (encontra problemas de acesso concorrente)
  • -cpu: Especifica valores de GOMAXPROCS
  • -count n: Executa cada teste n vezes
  • -failfast: Para na primeira falha do teste

Auxiliares de Teste e Setup/Teardown

Funções Auxiliares

Marque funções auxiliares com t.Helper() para melhorar a relatoria de erros:

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

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

Setup e Teardown

func TestMain(m *testing.M) {
    // Código de setup aqui
    setup()
    
    // Executar testes
    codigo := m.Run()
    
    // Código de teardown aqui
    teardown()
    
    os.Exit(codigo)
}

Fixtures de Teste

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("setup do caso de teste")
    return func(t *testing.T) {
        t.Log("teardown do caso de teste")
    }
}

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

Mocking e Injeção de Dependências

Mocking Baseado em Interfaces

Ao testar código que interage com bancos de dados, o uso de interfaces facilita a criação de implementações mock. Se você está trabalhando com PostgreSQL em 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) {
    usuario, err := s.db.GetUser(id)
    if err != nil {
        return "", err
    }
    return usuario.Nome, nil
}

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

func (m *MockDatabase) GetUser(id int) (*User, error) {
    if usuario, ok := m.usuarios[id]; ok {
        return usuario, nil
    }
    return nil, errors.New("usuário não encontrado")
}

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

Bibliotecas de Teste Populares

Testify

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

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

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

// 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: Estrutura de mocking 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: Estrutura de teste estilo BDD

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

Testes de Benchmark

O Go inclui suporte nativo 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.

Melhores Práticas

  1. Escreva testes orientados a tabela: Use o padrão de fatia de structs para múltiplos casos de teste
  2. Use t.Run para subtestes: Melhor organização e pode executar subtestes seletivamente
  3. Teste funções exportadas primeiro: Concentre-se no comportamento da API pública
  4. Mantenha os testes simples: Cada teste deve verificar uma coisa
  5. Use nomes de teste significativos: 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 mocking
  8. Almeje alta cobertura, mas qualidade sobre quantidade: 100% de cobertura não significa livre de bugs
  9. Execute testes com a flag -race: Detecte problemas de concorrência cedo
  10. Use TestMain para setup caro: Evite repetir setup em cada teste

Exemplo: Suite de Testes Completa

package user

import (
    "errors"
    "testing"
)

type User struct {
    ID    int
    Nome  string
    Email string
}

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

// Arquivo de teste: user_test.go
func TestValidateUser(t *testing.T) {
    testes := []struct {
        nome    string
        usuario *User
        querErro bool
        msgErro string
    }{
        {
            nome:    "usuário válido",
            usuario: &User{ID: 1, Nome: "Alice", Email: "alice@exemplo.com"},
            querErro: false,
        },
        {
            nome:    "nome vazio",
            usuario: &User{ID: 1, Nome: "", Email: "alice@exemplo.com"},
            querErro: true,
            msgErro:  "nome não pode estar vazio",
        },
        {
            nome:    "email vazio",
            usuario: &User{ID: 1, Nome: "Alice", Email: ""},
            querErro: true,
            msgErro:  "email não pode estar vazio",
        },
    }

    for _, tt := range testes {
        t.Run(tt.nome, func(t *testing.T) {
            err := ValidateUser(tt.usuario)
            
            if (err != nil) != tt.querErro {
                t.Errorf("ValidateUser() erro = %v, querErro %v", err, tt.querErro)
                return
            }
            
            if err != nil && err.Error() != tt.msgErro {
                t.Errorf("ValidateUser() mensagem de erro = %v, querendo %v", err.Error(), tt.msgErro)
            }
        })
    }
}

Conclusão

A estrutura de testes do Go fornece tudo o que é necessário para testes unitários abrangentes com configuração mínima. Seguindo idiomas do Go como testes orientados a tabela, usando interfaces para mocking e aproveitando ferramentas nativas, você pode criar suítes de teste mantíveis e confiáveis que crescem junto com sua base de código.

Estas 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 em testar entrada/saída e análise de flags.

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

Assinar

Receba novos artigos sobre sistemas, infraestrutura e engenharia de IA.