Skip to content
gohacks.dev
Testing

gomock and mockery Compared for Mocking in Go Projects

Compare gomock and mockery for Go mocks: generated code, expectation style, matchers, failure messages and when a hand-written double beats both of them.

10 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Most Go teams that use a mocking tool use one of two: gomock, now maintained by Uber as go.uber.org/mock, or mockery, which pairs with testify. Both read an interface and write a type that satisfies it. They differ in how a test talks to that type, and that difference decides which one feels natural in a given codebase.

Tested with Go 1.25.6, go.uber.org/mock v0.6.0 (mockgen run), macOS on Apple Silicon (arm64); mockery described from its documentation, not run

This article generated a gomock mock and ran tests against it on Go 1.25.6. mockery needed a newer toolchain than that machine had, so I describe it from its documentation and mark where I did not run it. If you are deciding whether you need either, read mocking with small interfaces first, and fakes, stubs and mocks for the vocabulary.

The code under test

A service that depends on a three-method store, and upgrades a user at most once:

users.goGo
package main

import (
	"context"
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("user not found")

type User struct {
	ID    int
	Email string
	Plan  string
}

type Store interface {
	Get(ctx context.Context, id int) (User, error)
	Save(ctx context.Context, u User) error
	Delete(ctx context.Context, id int) error
}

type Service struct{ store Store }

func NewService(s Store) *Service { return &Service{store: s} }

// Upgrade moves a user to the pro plan and saves it once.
func (s *Service) Upgrade(ctx context.Context, id int) error {
	u, err := s.store.Get(ctx, id)
	if err != nil {
		return fmt.Errorf("upgrade %d: %w", id, err)
	}
	if u.Plan == "pro" {
		return nil
	}
	u.Plan = "pro"
	return s.store.Save(ctx, u)
}

func main() {}

Store is declared in the package that uses it. That matters for both tools, because each generates from an interface and the generated file lives next to it.

How gomock works

mockgen has two modes. Source mode reads a Go file, and reflect mode builds a program that loads the package and inspects the type. Source mode is the quicker one to wire into go:generate:

Shell
go run go.uber.org/mock/[email protected] -source=users.go -destination=store_mock_test.go -package=main

That wrote an 84-line file with a MockStore struct, a MockStoreMockRecorder, and one method pair per interface method. The mock calls into a gomock.Controller, and the recorder is what EXPECT() returns. The file starts with a "Code generated" header, so linters and go vet treat it as generated.

I placed the output in a _test.go file so it is compiled only into test binaries. Putting generated mocks in a normal file is also common, but then the mock package ships in the production build and drags go.uber.org/mock into the module's non-test dependencies.

This is the test file:

users_test.goGo
package main

import (
	"context"
	"errors"
	"os"
	"testing"

	"go.uber.org/mock/gomock"
)

func TestUpgrade(t *testing.T) {
	ctx := context.Background()

	t.Run("saves the pro plan once", func(t *testing.T) {
		ctrl := gomock.NewController(t)
		store := NewMockStore(ctrl)
		store.EXPECT().Get(ctx, 7).Return(User{ID: 7, Plan: "free"}, nil)
		store.EXPECT().Save(ctx, User{ID: 7, Plan: "pro"}).Return(nil).Times(1)

		if err := NewService(store).Upgrade(ctx, 7); err != nil {
			t.Fatal(err)
		}
	})

	t.Run("already pro never saves", func(t *testing.T) {
		ctrl := gomock.NewController(t)
		store := NewMockStore(ctrl)
		store.EXPECT().Get(gomock.Any(), 7).Return(User{ID: 7, Plan: "pro"}, nil)

		if err := NewService(store).Upgrade(ctx, 7); err != nil {
			t.Fatal(err)
		}
	})

	t.Run("not found is wrapped", func(t *testing.T) {
		ctrl := gomock.NewController(t)
		store := NewMockStore(ctrl)
		store.EXPECT().Get(gomock.Any(), 9).Return(User{}, ErrNotFound)

		err := NewService(store).Upgrade(ctx, 9)
		if !errors.Is(err, ErrNotFound) {
			t.Fatalf("got %v", err)
		}
	})

	t.Run("matcher and DoAndReturn", func(t *testing.T) {
		ctrl := gomock.NewController(t)
		store := NewMockStore(ctrl)
		store.EXPECT().Get(gomock.Any(), gomock.Any()).DoAndReturn(
			func(_ context.Context, id int) (User, error) {
				return User{ID: id, Plan: "free"}, nil
			})
		store.EXPECT().Save(gomock.Any(), gomock.Cond(func(u User) bool { return u.Plan == "pro" })).Return(nil)

		if err := NewService(store).Upgrade(ctx, 21); err != nil {
			t.Fatal(err)
		}
	})
}

// Runs only when DEMO is set, to record what a violated expectation prints.
func TestDemoMissingSave(t *testing.T) {
	if os.Getenv("DEMO") == "" {
		t.Skip("demo only")
	}
	ctrl := gomock.NewController(t)
	store := NewMockStore(ctrl)
	store.EXPECT().Get(gomock.Any(), 7).Return(User{ID: 7, Plan: "free"}, nil)
	store.EXPECT().Save(gomock.Any(), User{ID: 7, Plan: "pro"}).Return(nil)
	// The service is never called, so both expectations are unmet.
}

func TestDemoUnexpectedCall(t *testing.T) {
	if os.Getenv("DEMO") == "" {
		t.Skip("demo only")
	}
	ctrl := gomock.NewController(t)
	store := NewMockStore(ctrl)
	store.EXPECT().Get(gomock.Any(), 7).Return(User{ID: 7, Plan: "free"}, nil)
	_ = NewService(store).Upgrade(context.Background(), 7)
}

The generated mock and the test run together. Mocks need the generated file in the lab, so it is part of the listing:

store_mock_test.goGo
// Code generated by MockGen. DO NOT EDIT.
// Source: users.go
//
// Generated by this command:
//
//	mockgen -source=users.go -destination=store_mock_test.go -package=main
//

// Package main is a generated GoMock package.
package main

import (
	context "context"
	reflect "reflect"

	gomock "go.uber.org/mock/gomock"
)

// MockStore is a mock of Store interface.
type MockStore struct {
	ctrl     *gomock.Controller
	recorder *MockStoreMockRecorder
	isgomock struct{}
}

// MockStoreMockRecorder is the mock recorder for MockStore.
type MockStoreMockRecorder struct {
	mock *MockStore
}

// NewMockStore creates a new mock instance.
func NewMockStore(ctrl *gomock.Controller) *MockStore {
	mock := &MockStore{ctrl: ctrl}
	mock.recorder = &MockStoreMockRecorder{mock}
	return mock
}

// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockStore) EXPECT() *MockStoreMockRecorder {
	return m.recorder
}

// Delete mocks base method.
func (m *MockStore) Delete(ctx context.Context, id int) error {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Delete", ctx, id)
	ret0, _ := ret[0].(error)
	return ret0
}

// Delete indicates an expected call of Delete.
func (mr *MockStoreMockRecorder) Delete(ctx, id any) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockStore)(nil).Delete), ctx, id)
}

// Get mocks base method.
func (m *MockStore) Get(ctx context.Context, id int) (User, error) {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Get", ctx, id)
	ret0, _ := ret[0].(User)
	ret1, _ := ret[1].(error)
	return ret0, ret1
}

// Get indicates an expected call of Get.
func (mr *MockStoreMockRecorder) Get(ctx, id any) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStore)(nil).Get), ctx, id)
}

// Save mocks base method.
func (m *MockStore) Save(ctx context.Context, u User) error {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Save", ctx, u)
	ret0, _ := ret[0].(error)
	return ret0
}

// Save indicates an expected call of Save.
func (mr *MockStoreMockRecorder) Save(ctx, u any) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockStore)(nil).Save), ctx, u)
}

And the run:

Shell
go test -count=1 -v -run 'TestUpgrade' . 2>&1 | grep -vE '^(=== RUN|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//'
echo "--- violated expectations ---"
DEMO=1 go test -count=1 -run 'TestDemo' . 2>&1 | sed -E 's/\([0-9.]+s\)//; s#/[^ ]*/##; /^FAIL/d; /^exit status/d' | head -30
Text
--- PASS: TestUpgrade
    --- PASS: TestUpgrade/saves_the_pro_plan_once
    --- PASS: TestUpgrade/already_pro_never_saves
    --- PASS: TestUpgrade/not_found_is_wrapped
    --- PASS: TestUpgrade/matcher_and_DoAndReturn
--- violated expectations ---
--- FAIL: TestDemoMissingSave 
    controller.go:97: missing call(s) to *main.MockStore.Get(is anything, is equal to 7 (int)) users_test.go:69
    controller.go:97: missing call(s) to *main.MockStore.Save(is anything, is equal to {7  pro} (main.User)) users_test.go:70
    controller.go:97: aborting test due to missing call(s)
--- FAIL: TestDemoUnexpectedCall 
    users.go:37: Unexpected call to *main.MockStore.Save([context.Background {7  pro}]) at users.go:37 because: there are no expected calls of the method "Save" for that receiver

Four things are worth reading in that output. The first is the strictness. store.EXPECT().Save(...).Times(1) says exactly one call, with exactly that User. A second call, or a call with Plan: "free", fails. The second is that a method with no expectation fails the moment it is called. In the "already pro" subtest nothing expects Save, so if the service saved anyway the test would stop with the unexpected-call message shown in the recorded run.

The third is the pair of failure messages. A missing call names the method, shows the matchers (is anything, is equal to {7 pro}) and the test file line where the expectation was set. An unexpected call prints the actual arguments and the line in the production code that made it. Both point at a place you can open.

The fourth is how much control the recorder gives. gomock.Any() ignores an argument, gomock.Cond takes a predicate, DoAndReturn computes a result from the real arguments, and Times, MinTimes, AnyTimes and InOrder shape the call counts and ordering. The matcher and DoAndReturn subtest uses the first three.

How mockery works

mockery reads a .mockery.yml file listing packages and interfaces, then generates a type per interface. A minimal config from its documentation looks like this (not run here, the toolchain it needed was newer than this machine's):

YAML
packages:
  example.com/app:
    interfaces:
      Store:

You run mockery and it writes mocks_test.go or a mocks/ directory according to the config. Version 3 changed the layout and options compared with version 2, so check which one a repository pins before copying an example.

The generated type embeds mock.Mock from github.com/stretchr/testify/mock, and a test sets expectations with .EXPECT() helpers or the older .On("Get", ...) strings:

Go
// from mockery's documentation; not run in this article
store := NewMockStore(t)
store.EXPECT().Get(mock.Anything, 7).Return(User{ID: 7, Plan: "free"}, nil)

Two differences show immediately. Mockery's constructor takes the *testing.T, registers a cleanup, and asserts that every expectation was met when the test ends, so there is no controller to pass around. And the expectations come from testify, so a team already using assert and require has one vocabulary instead of two.

The part that surprises people is leniency. A testify mock by default does not require an expectation to be called an exact number of times unless you say so with .Once() or .Times(n). gomock expects one call by default and fails on a second. Neither default is wrong, but they produce different tests from the same intent, and a team should pick one on purpose.

Where they differ in practice

Questiongomockmockery
Configurationcommand-line flags per go:generate lineone YAML file for the repository
Expectation styleEXPECT().Method(args).Return(...)EXPECT().Method(args).Return(...) or .On("Method")
Default call countexactly onceany number unless .Once() or .Times()
MatchersAny, Eq, Cond, Not, custom Matchermock.Anything, MatchedBy, AnythingOfType
Failure outputmethod, matchers and expectation linemethod, arguments and a diff of the mismatch
Extra dependencygo.uber.org/mocktestify
OrderingInOrder, AfterNotBefore on newer versions

The table has more to do with taste and tooling than capability. Both cover generics, embedded interfaces and variadic methods in their current versions; check the release notes if your interface uses an unusual construct, because that is where each has had bugs.

Choosing between them

If your repository already has testify in every test, mockery fits with the least friction, and its single config file means a new interface is a one-line change. If you want the strict "this exact sequence of calls" model, with a controller that fails the test for unexpected calls by default, gomock is closer to that.

Two cautions apply whichever you choose.

Mock at the boundary you own. A mock of Store verifies that Service calls Get then Save. It cannot verify that your SQL is right. Pair mocks with a real database test for the adapter, as in testing with a real Postgres. Heavy mocking of internal collaborators turns tests into a copy of the implementation, and a refactor then breaks tests without any behaviour changing.

Generated mocks go stale. Add //go:generate lines or a make generate target, and run it in CI with git diff --exit-code. A mock that no longer matches the interface fails to compile, which is the good case; a mock whose behaviour drifted from a real implementation fails nowhere.

When neither is the answer

For a one-method interface, a stub with a function field is five lines and needs no generator, as the small-interface article showed. For code with state, a fake (an in-memory implementation) tests behaviour rather than call sequences. Reach for gomock or mockery when the interface is wide, when the exact conversation with the dependency is the thing under test, or when the team has agreed on a tool and wants consistent tests. Otherwise, hand-written doubles are easier to read and survive tool upgrades.

The gomock repository and package docs are at github.com/uber-go/mock and pkg.go.dev/go.uber.org/mock/gomock. mockery's are at vektra.github.io/mockery.

Also inTooling
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.