Em testes de software, muitas vezes precisamos isolar a unidade que estamos testando de suas dependências — como bancos de dados, APIs externas ou serviços de mensageria. Dublês de teste (mocks, stubs, fakes) são objetos que simulam essas dependências, permitindo que você controle seu comportamento e verifique interações. Em Go, a combinação de interfaces e mocks manuais é uma abordagem poderosa e idiomática, sem a necessidade de bibliotecas externas, embora existam opções como testify/mock. Nesta aula, vamos dominar a criação de mocks manuais, explorar o pacote httptest para testar handlers HTTP e aprender boas práticas para escrever testes eficazes e sustentáveis.

Dominar mocks é essencial para escrever testes unitários confiáveis, rápidos e que não dependem de recursos externos. Além disso, mocks bem projetados ajudam a documentar o contrato esperado entre componentes, facilitando a manutenção do código. Vamos começar entendendo como as interfaces de Go são a base para a testabilidade.

Interfaces para testabilidade

Em Go, as interfaces são contratos que definem um conjunto de métodos. Elas são implementadas implicitamente: qualquer tipo que possua os métodos exigidos satisfaz a interface. Essa característica é fundamental para criar código testável, pois permite que funções e structs dependam de abstrações em vez de implementações concretas. Dessa forma, nos testes, podemos substituir implementações reais por dublês que implementam a mesma interface.

Considere um exemplo simples: um serviço de notificação que envia e-mails. Se a função EnviarNotificacao depende diretamente de um cliente SMTP, fica difícil testá-la sem enviar e-mails reais. Ao definir uma interface Notificador com o método Enviar(destinatario, mensagem string) error, podemos criar um mock que registra as chamadas e retorna valores predefinidos. Isso nos permite testar a lógica de negócio sem efeitos colaterais.

// notificador.go
type Notificador interface {
    Enviar(destinatario, mensagem string) error
}

func EnviarNotificacao(n Notificador, dest, msg string) error {
    // Alguma lógica de negócio...
    return n.Enviar(dest, msg)
}

No teste, podemos criar um mock simples que implementa a interface. Isso nos dá controle total sobre o comportamento e nos permite verificar se a função foi chamada com os argumentos corretos.

Mocks manuais

Mocks manuais são structs que implementam interfaces e registram chamadas de métodos, permitindo que você defina respostas e verifique expectativas. Eles são simples de escrever e não requerem dependências externas. Vamos criar um mock para a interface Notificador e usá-lo em um teste.

O mock terá um campo chamadas para armazenar os argumentos recebidos e um campo erro para simular falhas. Também podemos adicionar um contador para verificar quantas vezes o método foi chamado.

// mock_notificador.go
package main

type MockNotificador struct {
    chamadas []Notificacao
    erro     error
}

type Notificacao struct {
    Destinatario string
    Mensagem     string
}

func (m *MockNotificador) Enviar(destinatario, mensagem string) error {
    m.chamadas = append(m.chamadas, Notificacao{Destinatario: destinatario, Mensagem: mensagem})
    return m.erro
}

func (m *MockNotificador) VerificarChamadas() []Notificacao {
    return m.chamadas
}

Agora, no teste, podemos usar o mock para verificar a interação:

// notificador_test.go
package main

import "testing"

func TestEnviarNotificacao(t *testing.T) {
    mock := &MockNotificador{}
    err := EnviarNotificacao(mock, "ana@example.com", "Olá!")
    if err != nil {
        t.Fatalf("erro inesperado: %v", err)
    }
    chamadas := mock.VerificarChamadas()
    if len(chamadas) != 1 {
        t.Fatalf("esperava 1 chamada, obteve %d", len(chamadas))
    }
    if chamadas[0].Destinatario != "ana@example.com" {
        t.Errorf("destinatário incorreto: %v", chamadas[0].Destinatario)
    }
}

Esse padrão é simples e eficaz. Para cenários mais complexos, você pode adicionar funções de configuração (por exemplo, OnEnviar(...)) para definir comportamentos específicos. A vantagem dos mocks manuais é que você tem controle total e não depende de bibliotecas externas, o que mantém o projeto leve e evita curvas de aprendizado adicionais.

httptest

O pacote net/http/httptest fornece utilitários para testar handlers HTTP e simular servidores. Ele é essencial para testar endpoints sem subir um servidor real. As duas principais funcionalidades são httptest.NewRecorder para capturar respostas e httptest.NewServer para criar um servidor de teste temporário.

Para testar um handler, criamos um httptest.NewRecorder, uma requisição com http.NewRequest e chamamos o handler diretamente. O recorder captura o status code, headers e body da resposta.

// handler.go
package main

import (
    "encoding/json"
    "net/http"
)

func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handler_test.go
package main

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestHealthHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/health", nil)
    rec := httptest.NewRecorder()
    healthHandler(rec, req)

    res := rec.Result()
    defer res.Body.Close()

    if res.StatusCode != http.StatusOK {
        t.Errorf("status esperado %d, obteve %d", http.StatusOK, res.StatusCode)
    }

    var body map[string]string
    if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
        t.Fatalf("erro ao decodificar JSON: %v", err)
    }
    if body["status"] != "ok" {
        t.Errorf("status no body esperado 'ok', obteve %q", body["status"])
    }
}

httptest.NewServer é útil quando você precisa testar clientes HTTP que consomem uma API externa. Ele cria um servidor local que você pode configurar para responder de forma controlada. Depois do teste, você deve fechar o servidor com defer server.Close().

// cliente_test.go
package main

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestClienteConsomeAPI(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"message":"olá"}`))
    }))
    defer server.Close()

    resp, err := http.Get(server.URL)
    if err != nil {
        t.Fatalf("erro ao fazer requisição: %v", err)
    }
    defer resp.Body.Close()

    // Verificações...
}

Essas ferramentas permitem testar tanto a implementação de servidores quanto a lógica de clientes HTTP, garantindo que a comunicação entre componentes funcione como esperado.

Boas práticas

Escrever mocks e testes de qualidade requer atenção a algumas práticas recomendadas. Primeiro, prefira mocks manuais a bibliotecas externas quando a interface for pequena e estável. Isso evita dependências desnecessárias e mantém o código mais claro. Para interfaces grandes, bibliotecas como testify/mock podem reduzir a verbosidade, mas adicione complexidade.

Segundo, mantenha os mocks simples: eles devem simular apenas o comportamento necessário para o teste. Evite criar mocks que reproduzem toda a lógica do componente real, pois isso pode levar a testes frágeis que não refletem o comportamento real. Em vez disso, use stubs (retornam valores fixos) quando você não precisa verificar interações, e mocks (verificam chamadas) quando a interação é importante.

Terceiro, use t.Helper() em funções auxiliares de teste para melhorar as mensagens de erro e o rastreamento de falhas. Isso faz com que, se um teste falhar, a linha reportada seja a do teste, não a da função auxiliar. Além disso, prefira usar subtests com t.Run para organizar casos de teste e melhorar a legibilidade.

func TestEnviarNotificacao(t *testing.T) {
    t.Run("sucesso", func(t *testing.T) {
        mock := &MockNotificador{}
        err := EnviarNotificacao(mock, "ana@example.com", "Olá!")
        if err != nil {
            t.Fatalf("erro inesperado: %v", err)
        }
        if len(mock.VerificarChamadas()) != 1 {
            t.Errorf("esperava 1 chamada, obteve %d", len(mock.VerificarChamadas()))
        }
    })
    t.Run("erro", func(t *testing.T) {
        mock := &MockNotificador{erro: errors.New("falha")}
        err := EnviarNotificacao(mock, "ana@example.com", "Olá!")
        if err == nil {
            t.Error("esperava erro, obteve nil")
        }
    })
}

Por fim, lembre-se de que mocks são apenas uma ferramenta. Eles devem ser usados para isolar unidades, mas não substituem testes de integração que verificam a interação real entre componentes. Equilibre o uso de mocks com testes de integração para garantir a confiabilidade do sistema como um todo.

Referências

Exercícios

  1. Crie uma interface RepositorioUsuario com métodos BuscarPorID(id int) (Usuario, error) e Salvar(u Usuario) error. Implemente um mock manual que registre chamadas e armazene usuários em um mapa.

    ✓ Resposta:
    type Usuario struct {
        ID   int
        Nome string
    }
    
    type RepositorioUsuario interface {
        BuscarPorID(id int) (Usuario, error)
        Salvar(u Usuario) error
    }
    
    type MockRepositorioUsuario struct {
        usuarios map[int]Usuario
        chamadas []string
    }
    
    func NewMockRepositorioUsuario() *MockRepositorioUsuario {
        return &MockRepositorioUsuario{usuarios: make(map[int]Usuario)}
    }
    
    func (m *MockRepositorioUsuario) BuscarPorID(id int) (Usuario, error) {
        m.chamadas = append(m.chamadas, "BuscarPorID")
        u, ok := m.usuarios[id]
        if !ok {
            return Usuario{}, errors.New("não encontrado")
        }
        return u, nil
    }
    
    func (m *MockRepositorioUsuario) Salvar(u Usuario) error {
        m.chamadas = append(m.chamadas, "Salvar")
        m.usuarios[u.ID] = u
        return nil
    }
  2. Escreva um teste para a função BuscarUsuario que depende de RepositorioUsuario, usando o mock do exercício anterior. Verifique se o usuário retornado é o esperado e se o método BuscarPorID foi chamado exatamente uma vez.

    ✓ Resposta:
    func BuscarUsuario(repo RepositorioUsuario, id int) (Usuario, error) {
        return repo.BuscarPorID(id)
    }
    
    func TestBuscarUsuario(t *testing.T) {
        repo := NewMockRepositorioUsuario()
        repo.Salvar(Usuario{ID: 1, Nome: "Ana"})
        u, err := BuscarUsuario(repo, 1)
        if err != nil {
            t.Fatalf("erro inesperado: %v", err)
        }
        if u.Nome != "Ana" {
            t.Errorf("nome esperado 'Ana', obteve %q", u.Nome)
        }
        if len(repo.chamadas) != 1 || repo.chamadas[0] != "BuscarPorID" {
            t.Errorf("chamadas inesperadas: %v", repo.chamadas)
        }
    }
  3. Usando httptest.NewServer, escreva um teste para uma função ClienteBuscaDados que faz uma requisição GET para uma URL e retorna o corpo da resposta. O servidor de teste deve responder com um JSON contendo {"dados": "exemplo"}.

    ✓ Resposta:
    func ClienteBuscaDados(url string) ([]byte, error) {
        resp, err := http.Get(url)
        if err != nil {
            return nil, err
        }
        defer resp.Body.Close()
        return io.ReadAll(resp.Body)
    }
    
    func TestClienteBuscaDados(t *testing.T) {
        server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusOK)
            w.Write([]byte(`{"dados": "exemplo"}`))
        }))
        defer server.Close()
    
        body, err := ClienteBuscaDados(server.URL)
        if err != nil {
            t.Fatalf("erro: %v", err)
        }
        if string(body) != `{"dados": "exemplo"}` {
            t.Errorf("corpo inesperado: %s", body)
        }
    }
  4. Explique a diferença entre stub e mock e dê um exemplo de quando usar cada um em Go.

    ✓ Resposta:

    Um stub é um objeto que fornece respostas predefinidas a chamadas de método, sem verificar interações. É usado quando você apenas precisa que o código execute um caminho específico, sem se importar com as chamadas. Um mock é mais sofisticado: além de fornecer respostas, ele registra as chamadas e permite verificar se os métodos foram invocados com os argumentos corretos. Use stubs para simplificar testes de lógica de negócio quando a interação não é relevante; use mocks quando precisar garantir que uma dependência foi usada corretamente (por exemplo, que um método foi chamado exatamente uma vez com determinados parâmetros). Em Go, um stub pode ser um tipo que implementa a interface e retorna valores fixos, enquanto um mock adiciona funcionalidades de verificação.

  5. Escreva um teste usando httptest.NewRecorder para o handler helloHandler que responde com "Olá, mundo!" e status 200. Inclua a verificação do corpo e do status.

    ✓ Resposta:
    func helloHandler(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("Olá, mundo!"))
    }
    
    func TestHelloHandler(t *testing.T) {
        req := httptest.NewRequest(http.MethodGet, "/hello", nil)
        rec := httptest.NewRecorder()
        helloHandler(rec, req)
    
        res := rec.Result()
        defer res.Body.Close()
    
        if res.StatusCode != http.StatusOK {
            t.Errorf("status esperado %d, obteve %d", http.StatusOK, res.StatusCode)
        }
        body, _ := io.ReadAll(res.Body)
        if string(body) != "Olá, mundo!" {
            t.Errorf("corpo esperado 'Olá, mundo!', obteve %q", string(body))
        }
    }

Observações finais

Nesta aula, você aprendeu a usar mocks manuais e o pacote httptest para criar testes isolados e confiáveis em Go. Lembre-se de que mocks são ferramentas poderosas, mas devem ser usadas com moderação. Sempre que possível, prefira testar com implementações reais (como um banco de dados em memória) para garantir que o código funcione em condições mais realistas. No entanto, para dependências externas lentas ou instáveis, mocks são a melhor escolha. Pratique criando mocks para diferentes interfaces e explorando os recursos de httptest para testar seus handlers HTTP. Com essas técnicas, você elevará a qualidade dos seus testes e, consequentemente, do seu código.