Mocking interfaces com gomock ou testify

1. Por que mockar interfaces em Golang?

Testes unitários em Go precisam ser rápidos, previsíveis e isolados. Quando seu código depende de serviços externos — banco de dados, APIs HTTP, cache Redis ou envio de emails — você não quer que seus testes dependam desses sistemas estarem disponíveis. Mockar interfaces resolve esse problema.

A filosofia de composição do Go, baseada em interfaces pequenas e coesas, torna o mocking natural. Ao definir uma interface como contrato, você pode substituir implementações reais por mocks durante os testes. Os benefícios são claros:

  • Velocidade: testes que não tocam em I/O rodam em milissegundos
  • Previsibilidade: você controla exatamente o que cada dependência retorna
  • Sem efeitos colaterais: nenhum dado real é alterado, nenhum email é enviado

2. Fundamentos: interfaces e injeção de dependência

Antes de mockar, precisamos de interfaces bem definidas e injeção de dependência. Veja um exemplo mínimo de repositório de usuários:

type User struct {
    ID   int
    Name string
    Email string
}

type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.FindByID(id)
}

A injeção via construtor (NewUserService) permite que passemos qualquer implementação de UserRepository — real ou mock.

3. Mocking com gomock (geração de código)

O gomock gera código automaticamente a partir de suas interfaces. Instalação:

go install github.com/golang/mock/mockgen@latest

Gere o mock para nossa interface:

mockgen -source=repository.go -destination=mocks/mock_repository.go -package=mocks

Agora, um teste prático simulando um serviço de envio de email:

// email.go
type EmailSender interface {
    Send(to, subject, body string) error
}

type NotificationService struct {
    sender EmailSender
}

func NewNotificationService(sender EmailSender) *NotificationService {
    return &NotificationService{sender: sender}
}

func (s *NotificationService) NotifyUser(user *User, message string) error {
    return s.sender.Send(user.Email, "Notificação", message)
}

Teste com gomock:

// email_test.go
func TestNotifyUser_Success(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockSender := mocks.NewMockEmailSender(ctrl)
    mockSender.EXPECT().
        Send("user@example.com", "Notificação", "Bem-vindo!").
        Return(nil).
        Times(1)

    service := NewNotificationService(mockSender)
    user := &User{Email: "user@example.com"}

    err := service.NotifyUser(user, "Bem-vindo!")
    assert.NoError(t, err)
}

EXPECT() define o que esperamos, Return() especifica o retorno, Times() controla quantas vezes a chamada deve ocorrer.

4. Testify/mock: mocking manual sem geração

O testify oferece uma abordagem mais manual, sem geração de código. Você cria um struct que embute mock.Mock e implementa a interface:

type MockCache struct {
    mock.Mock
}

func (m *MockCache) Get(key string) (string, error) {
    args := m.Called(key)
    return args.String(0), args.Error(1)
}

func (m *MockCache) Set(key, value string) error {
    args := m.Called(key, value)
    return args.Error(0)
}

Teste mockando um cache Redis:

func TestGetFromCache(t *testing.T) {
    mockCache := new(MockCache)
    mockCache.On("Get", "user:123").Return("cached_value", nil)
    mockCache.On("Set", "user:123", "new_value").Return(nil)

    service := NewCacheService(mockCache)
    result, err := service.GetOrSet("user:123", "new_value")

    assert.NoError(t, err)
    assert.Equal(t, "cached_value", result)
    mockCache.AssertExpectations(t)
}

On() define o comportamento esperado, AssertExpectations(t) verifica se todas as chamadas ocorreram conforme planejado.

5. Configurando expectativas e asserções

Ambas as bibliotecas permitem controle fino sobre as chamadas.

Gomock — exemplo com logger e chamadas sequenciais:

func TestLogger_SequentialCalls(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockLogger := mocks.NewMockLogger(ctrl)
    gomock.InOrder(
        mockLogger.EXPECT().Info("iniciando processo").Return(),
        mockLogger.EXPECT().Debug("etapa 1 concluída").Return(),
        mockLogger.EXPECT().Info("processo finalizado").Return(),
    )

    processor := NewProcessor(mockLogger)
    processor.Run()
}

Testify — verificando argumentos específicos:

func TestLogger_WithArgs(t *testing.T) {
    mockLogger := new(MockLogger)
    mockLogger.On("Info", mock.MatchedBy(func(msg string) bool {
        return strings.Contains(msg, "error")
    })).Return()

    service := NewService(mockLogger)
    service.HandleError(errors.New("conexão falhou"))
    mockLogger.AssertCalled(t, "Info", mock.AnythingOfType("string"))
}

6. Tratamento de erros e cenários negativos

Simular falhas é essencial para testar caminhos alternativos:

// Gomock
mockDB.EXPECT().Query("SELECT ...").Return(nil, errors.New("conexão perdida"))

// Testify
mockDB.On("Query", "SELECT ...").Return(nil, errors.New("conexão perdida"))

Teste de fallback com retry:

func TestRepository_RetryOnFailure(t *testing.T) {
    mockDB := new(MockDatabase)
    mockDB.On("FindByID", 1).Return(nil, errors.New("timeout")).Once()
    mockDB.On("FindByID", 1).Return(&User{ID: 1, Name: "João"}, nil).Once()

    repo := NewUserRepository(mockDB)
    user, err := repo.FindByIDWithRetry(1)

    assert.NoError(t, err)
    assert.Equal(t, "João", user.Name)
    mockDB.AssertExpectations(t)
}

7. Boas práticas e armadilhas comuns

  • Evite mocks desnecessários: para dados simples, use stubs (implementações fixas) ou fakes (implementações funcionais leves)
  • Mantenha mocks atualizados: quando a interface muda, os mocks precisam ser regenerados (gomock) ou atualizados (testify)
  • Nunca mocke structs concretos: apenas interfaces. Mockar tipos concretos quebra o propósito do desacoplamento
  • Automatize com go:generate:
//go:generate mockgen -source=repository.go -destination=mocks/mock_repository.go -package=mocks
type UserRepository interface { ... }

Execute go generate ./... para regenerar todos os mocks de uma vez.

8. Comparação: gomock vs testify – quando usar cada um?

Aspecto Gomock Testify
Geração de código Automática (mockgen) Manual
Curva de aprendizado Média (sintaxe EXPECT()) Baixa (métodos On/Return)
Projetos grandes Excelente (muitas interfaces) Pode ficar verboso
Times pequenos Overhead desnecessário Ideal
Performance Ligeiramente mais rápido Similar
Manutenção Regenerar mocks ao mudar interface Atualizar manualmente

Quando usar gomock: projetos com dezenas de interfaces, times maiores, necessidade de geração automática e controle fino de ordem de chamadas.

Quando usar testify: protótipos, times pequenos, simplicidade, ou quando você prefere código explícito sem geração.

Refatorando um teste de gomock para testify:

// Gomock
mockSender.EXPECT().Send("a@b.com", "subject", "body").Return(nil)

// Testify
mockSender.On("Send", "a@b.com", "subject", "body").Return(nil)

A diferença é sutil, mas o testify elimina a necessidade do ctrl e do defer ctrl.Finish().

Conclusão

Mockar interfaces em Go é uma prática essencial para testes unitários robustos. Tanto gomock quanto testify cumprem bem esse papel — a escolha depende do contexto do seu projeto. Comece com testify para simplicidade, migre para gomock quando a escala exigir automação. Em ambos os casos, lembre-se: mocks são ferramentas para testar comportamento, não implementação.

Referências