How to Mock Interfaces in Go

Create a struct that implements the interface methods and inject it to replace the real dependency during testing.

The problem with testing real dependencies

You are writing a service that processes payment records. The service calls an external API, writes to a database, and sends a confirmation email. You want to test the business logic without actually charging a credit card, mutating production data, or spamming your inbox. In many languages, you reach for a heavy mocking framework that uses reflection or bytecode manipulation to swap out dependencies. Go takes a different path. You do not need a framework to fake a dependency. You just need a struct that satisfies the same interface.

What mocking actually means in Go

Go interfaces are implicit. There is no implements keyword. There is no registration step. If a type has the exact methods an interface requires, it satisfies that interface. The compiler checks the method signatures and lets you pass the type anywhere the interface is expected. Mocking is simply the act of writing a lightweight struct that implements those methods and returns predetermined values or records calls. You hand the mock to your code through dependency injection, and your code treats it exactly like the real thing.

Think of it like a test pilot training on a flight simulator. The controls, the dashboard, and the physics model match the real aircraft. The simulator does not fly, but it responds to inputs in a predictable way. Your code calls the methods. The mock returns what you told it to return. You verify the outcome.

A minimal mock from scratch

Here is the simplest interface you can mock: a service that greets a user.

// Greeter defines the contract for greeting users.
type Greeter interface {
    Greet(name string) (string, error)
}

// RealGreeter implements Greeter by formatting a string.
type RealGreeter struct{}

func (g RealGreeter) Greet(name string) (string, error) {
    return fmt.Sprintf("Hello, %s!", name), nil
}

// MockGreeter returns a fixed string and tracks calls.
type MockGreeter struct {
    callCount int
    fixedMsg  string
}

func (m *MockGreeter) Greet(name string) (string, error) {
    m.callCount++
    return m.fixedMsg, nil
}

The mock struct holds state (callCount and fixedMsg) so you can assert behavior after the call. The receiver is a pointer because the mock needs to mutate its own fields. The real implementation uses a value receiver because it does not need to change. Go allows both to satisfy the same interface as long as the method signatures match.

How the compiler validates your fake

When you pass &MockGreeter{} to a function expecting Greeter, the compiler performs a structural check. It looks at the method set of *MockGreeter and compares it to Greeter. If every method in the interface exists on the type with the exact same parameter types and return types, the assignment compiles. If you miss a method or swap a parameter type, the build fails immediately.

The compiler rejects the program with *MockGreeter does not implement Greeter (missing Greet method) if you forget to define the method. It complains with cannot use m (variable of type *MockGreeter) as Greeter value in argument: *MockGreeter does not implement Greeter (wrong type for method Greet) if the signature drifts. This compile-time guarantee is why Go mocks feel safe. You cannot accidentally pass a type that lacks the required behavior.

The community follows a simple convention here: accept interfaces, return structs. Your public functions take the interface as a parameter. They return concrete structs or errors. This keeps your API flexible while keeping your exported types stable.

Mocking in a realistic service layer

Real code rarely deals with single-method interfaces. You usually have a repository or a client that handles I/O. Here is a service that depends on a user repository.

// UserRepository defines storage operations for users.
type UserRepository interface {
    FindByID(ctx context.Context, id int64) (User, error)
    Save(ctx context.Context, u User) error
}

// UserService contains business logic and depends on UserRepository.
type UserService struct {
    repo UserRepository
}

// NewUserService wires the dependency into the service.
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// GetUser fetches a user and applies a business rule.
func (s *UserService) GetUser(ctx context.Context, id int64) (User, error) {
    u, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return User{}, err
    }
    if u.IsActive {
        return u, nil
    }
    return User{}, fmt.Errorf("user %d is inactive", id)
}

Notice how context.Context is the first parameter, conventionally named ctx. Functions that accept a context should respect cancellation and deadlines. The service does not create the repository itself. It receives it through the constructor. This pattern makes testing trivial.

Here is the mock and a table-driven test that uses it.

// MockRepo tracks calls and returns predetermined data.
type MockRepo struct {
    findCalled bool
    findResult User
    findErr    error
}

func (m *MockRepo) FindByID(ctx context.Context, id int64) (User, error) {
    m.findCalled = true
    return m.findResult, m.findErr
}

func (m *MockRepo) Save(ctx context.Context, u User) error {
    return nil
}

func TestUserService_GetUser(t *testing.T) {
    tests := []struct {
        name       string
        repoResult User
        repoErr    error
        wantErr    bool
    }{
        {"active user", User{IsActive: true}, nil, false},
        {"inactive user", User{IsActive: false}, nil, true},
        {"db error", User{}, errors.New("timeout"), true},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            mock := &MockRepo{findResult: tt.repoResult, findErr: tt.repoErr}
            svc := NewUserService(mock)
            _, err := svc.GetUser(context.Background(), 1)
            if (err != nil) != tt.wantErr {
                t.Fatalf("GetUser() error = %v, wantErr %v", err, tt.wantErr)
            }
            if !mock.findCalled {
                t.Fatal("FindByID was not called")
            }
        })
    }
}

The mock implements both methods required by UserRepository. The Save method does nothing because the test only exercises GetUser. The test verifies that the business rule triggers correctly and that the repository was actually called. Table-driven tests keep the structure flat and readable. Go's standard library testing package handles the rest.

Do not overcomplicate the mock. Return what you need. Track what you need. Leave the rest alone.

Common pitfalls and compiler traps

Mocking in Go is straightforward, but a few patterns trip people up. The most common is receiver mismatch. If your interface expects a value receiver method, but your mock defines it with a pointer receiver, the compiler will reject the assignment. The method sets do not align. Fix it by matching the receiver type or adjusting the interface definition.

Another trap is ignoring errors in the mock. If your real implementation returns an error, your mock should be able to return one too. Silently swallowing errors hides bugs. The community accepts the if err != nil { return err } boilerplate because it makes the unhappy path visible. Your mock should respect the same contract.

Goroutine leaks happen when a mock spawns a background task that waits on a channel that never closes. Always provide a cancellation path. Pass the context through. Check ctx.Err() before blocking. The worst goroutine bug is the one that never logs.

You might also run into the undefined: pkg error if you forget to import a package, or imported and not used if you leave a dead import. The compiler catches these immediately. Trust the toolchain. Run gofmt on save. Do not argue about indentation. Let the tool decide.

When to mock and when to skip it

Use a hand-rolled mock when the interface is small and the test only needs to verify a single path. Use mockgen when the interface has many methods and you want the compiler to generate the stub automatically. Use a real test database when the business logic depends on SQL constraints, indexes, or transaction boundaries. Use table-driven tests with mocks when you need to cover multiple input combinations without duplicating setup code. Use integration tests against a local container when you want to verify the full stack without network dependencies. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next