Escrevendo Código Go Testável Sem Mockar Tudo
Projetos Go frequentemente acabam com mais arquivos de mock do que código real. Cada interface ganha um mock gerado, testes ficam fortemente acoplados a detalhes de implementação, e refatoração quebra dezenas de arquivos de teste.
Existe uma forma melhor. Aqui está como escrever código testável sem recorrer a mocks por padrão.
O Problema da Explosão de Mocks
Projeto Go enterprise típico:
service/
user_service.go
user_service_test.go
mock_user_repository.go
mock_notification_service.go
mock_cache.go
mock_logger.go
mock_metrics.go
Cada dependência é mockada. Testes verificam interações com mocks ao invés de comportamento. E quando você muda como algo funciona internamente, todas aquelas expectativas de mock quebram.
Estratégia 1: Implementações Reais Primeiro
Antes de mockar, pergunte: posso usar a coisa real?
SQLite para Testes de Banco
// test_helpers.go
func TestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
// Roda migrations
if err := migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() {
db.Close()
})
return db
}
// user_repository_test.go
func TestUserRepository_Create(t *testing.T) {
db := TestDB(t)
repo := NewUserRepository(db)
user := &User{Name: "test", Email: "test@example.com"}
err := repo.Create(context.Background(), user)
require.NoError(t, err)
assert.NotEmpty(t, user.ID)
// Verifica que foi realmente persistido
found, err := repo.GetByID(context.Background(), user.ID)
require.NoError(t, err)
assert.Equal(t, user.Name, found.Name)
}
Benefícios:
- Testa queries SQL reais
- Pega problemas de schema
- Sem manutenção de mock
- Rápido o suficiente para testes unitários (SQLite em memória)
Redis com miniredis
import "github.com/alicebob/miniredis/v2"
func TestCache(t *testing.T) *redis.Client {
t.Helper()
s := miniredis.RunT(t)
return redis.NewClient(&redis.Options{
Addr: s.Addr(),
})
}
func TestUserCache_Get(t *testing.T) {
client := TestCache(t)
cache := NewUserCache(client)
// Testa operações Redis reais
user := &User{ID: "123", Name: "test"}
err := cache.Set(context.Background(), user)
require.NoError(t, err)
found, err := cache.Get(context.Background(), "123")
require.NoError(t, err)
assert.Equal(t, user.Name, found.Name)
}
HTTP com httptest
func TestPaymentClient_Charge(t *testing.T) {
// Servidor HTTP real, não mocks
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verifica request
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/v1/charges", r.URL.Path)
var req ChargeRequest
json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, 1000, req.Amount)
// Retorna resposta
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ChargeResponse{
ID: "ch_123",
Status: "succeeded",
})
}))
defer server.Close()
client := NewPaymentClient(server.URL)
resp, err := client.Charge(context.Background(), 1000)
require.NoError(t, err)
assert.Equal(t, "succeeded", resp.Status)
}
Estratégia 2: Interfaces Pequenas
Interfaces grandes levam a mocks grandes. Em vez disso, defina a menor interface que você precisa:
// RUIM: Interface enorme, mock enorme
type UserRepository interface {
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
GetByID(ctx context.Context, id string) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
List(ctx context.Context, filter Filter) ([]*User, error)
Count(ctx context.Context) (int, error)
// ... mais 20 métodos
}
// BOM: Define interface onde é usada
// No pacote auth:
type UserGetter interface {
GetByEmail(ctx context.Context, email string) (*User, error)
}
func NewAuthService(users UserGetter) *AuthService {
return &AuthService{users: users}
}
// Agora testes só precisam implementar um método
type stubUserGetter struct {
user *User
err error
}
func (s *stubUserGetter) GetByEmail(ctx context.Context, email string) (*User, error) {
return s.user, s.err
}
Isso é chamado de "Princípio de Segregação de Interface" mas em Go acontece naturalmente quando você define interfaces no ponto de uso.
Estratégia 3: Functional Options para Dependências
Ao invés de dependências de interface, às vezes uma função é suficiente:
type OrderService struct {
generateID func() string
now func() time.Time
notify func(ctx context.Context, userID, message string) error
}
type OrderOption func(*OrderService)
func WithIDGenerator(fn func() string) OrderOption {
return func(s *OrderService) {
s.generateID = fn
}
}
func WithClock(fn func() time.Time) OrderOption {
return func(s *OrderService) {
s.now = fn
}
}
func NewOrderService(opts ...OrderOption) *OrderService {
s := &OrderService{
generateID: uuid.NewString,
now: time.Now,
notify: defaultNotify,
}
for _, opt := range opts {
opt(s)
}
return s
}
// Testes podem injetar funções simples
func TestOrderService_Create(t *testing.T) {
fixedTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
notified := false
svc := NewOrderService(
WithIDGenerator(func() string { return "order-123" }),
WithClock(func() time.Time { return fixedTime }),
WithNotifier(func(ctx context.Context, userID, msg string) error {
notified = true
return nil
}),
)
order, err := svc.Create(context.Background(), CreateOrderRequest{})
require.NoError(t, err)
assert.Equal(t, "order-123", order.ID)
assert.Equal(t, fixedTime, order.CreatedAt)
assert.True(t, notified)
}
Estratégia 4: Test Fixtures ao Invés de Factories
Ao invés de construir dados de teste inline, use fixtures:
// testdata/fixtures.go
package testdata
var (
ValidUser = &User{
ID: "user-123",
Name: "Test User",
Email: "test@example.com",
}
AdminUser = &User{
ID: "admin-456",
Name: "Admin",
Email: "admin@example.com",
Role: RoleAdmin,
}
ExpiredSubscription = &Subscription{
ID: "sub-789",
UserID: "user-123",
ExpiresAt: time.Now().Add(-24 * time.Hour),
}
)
// Builder para objetos complexos
type UserBuilder struct {
user *User
}
func NewUserBuilder() *UserBuilder {
return &UserBuilder{
user: &User{
ID: uuid.NewString(),
Name: "Test User",
Email: "test@example.com",
Role: RoleUser,
},
}
}
func (b *UserBuilder) WithName(name string) *UserBuilder {
b.user.Name = name
return b
}
func (b *UserBuilder) WithRole(role Role) *UserBuilder {
b.user.Role = role
return b
}
func (b *UserBuilder) Build() *User {
return b.user
}
Uso:
func TestPermissions(t *testing.T) {
admin := testdata.NewUserBuilder().
WithRole(RoleAdmin).
Build()
regular := testdata.NewUserBuilder().
WithRole(RoleUser).
Build()
assert.True(t, CanDeleteUsers(admin))
assert.False(t, CanDeleteUsers(regular))
}
Estratégia 5: Table-Driven Tests Sem Mocks
Table-driven tests funcionam muito bem com stubs:
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{"valid email", "test@example.com", false},
{"missing @", "testexample.com", true},
{"missing domain", "test@", true},
{"empty", "", true},
{"unicode", "tëst@example.com", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateEmail(tt.email)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
// Para casos mais complexos com dependências
func TestOrderProcessor(t *testing.T) {
tests := []struct {
name string
order Order
inventory map[string]int // item -> quantidade disponível
expectedError string
}{
{
name: "sufficient inventory",
order: Order{Items: []Item{{SKU: "A", Qty: 2}}},
inventory: map[string]int{"A": 10},
},
{
name: "insufficient inventory",
order: Order{Items: []Item{{SKU: "A", Qty: 10}}},
inventory: map[string]int{"A": 5},
expectedError: "insufficient inventory",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Stub simples, não um mock
inventoryCheck := func(sku string, qty int) bool {
return tt.inventory[sku] >= qty
}
processor := NewOrderProcessor(inventoryCheck)
err := processor.Process(tt.order)
if tt.expectedError != "" {
assert.ErrorContains(t, err, tt.expectedError)
} else {
assert.NoError(t, err)
}
})
}
}
Estratégia 6: Testes de Integração com Docker
Para coisas que são difíceis de simular, use a coisa real com testcontainers:
import "github.com/testcontainers/testcontainers-go"
func TestWithPostgres(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
postgres, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:15",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
},
Started: true,
})
require.NoError(t, err)
defer postgres.Terminate(ctx)
host, _ := postgres.Host(ctx)
port, _ := postgres.MappedPort(ctx, "5432")
db, err := sql.Open("postgres", fmt.Sprintf(
"postgres://postgres:test@%s:%s/testdb?sslmode=disable",
host, port.Port(),
))
require.NoError(t, err)
// Agora testa com Postgres real
repo := NewUserRepository(db)
// ...
}
Quando Mocks São Realmente Úteis
Mocks não são do mal—apenas usados demais. Eles são bons para:
- Serviços externos que você não controla (APIs de terceiros)
- Verificar interações quando a interação É o comportamento
- Simular falhas que são difíceis de disparar naturalmente
// Bom uso de mock: verificar que um email foi enviado
func TestOrderService_SendsConfirmation(t *testing.T) {
var sentTo string
var sentSubject string
emailer := &stubEmailer{
sendFunc: func(to, subject, body string) error {
sentTo = to
sentSubject = subject
return nil
},
}
svc := NewOrderService(emailer)
svc.Complete(context.Background(), order)
assert.Equal(t, "customer@example.com", sentTo)
assert.Contains(t, sentSubject, "Order Confirmation")
}
Pontos-Chave
-
Implementações reais primeiro. SQLite, miniredis, httptest—use-os antes de recorrer a mocks.
-
Interfaces pequenas. Defina interfaces onde são usadas, com apenas os métodos necessários.
-
Funções ao invés de interfaces quando você só precisa de um método.
-
Fixtures ao invés de factories para dados de teste consistentes.
-
Table-driven tests reduzem duplicação e tornam edge cases óbvios.
-
Testes de integração com containers para coisas que não podem ser simuladas.
-
Mocke apenas o que você não controla ou quando verificar interações é o requisito real.
O objetivo não é zero mocks—é testes que verificam comportamento, não detalhes de implementação. Quando testes quebram apenas porque comportamento mudou (não porque você refatorou internals), você sabe que está no caminho certo.