Mocking Libraries — Middle¶
This file covers two libraries used heavily in the Go ecosystem outside the gomock world: github.com/stretchr/testify/mock (reflection-based, hand-written) and github.com/vektra/mockery (code generator that emits testify-style mocks). After reading this you should be able to read and contribute to any testify-mock-based codebase and decide whether mockery's codegen is worth adopting for your project.
1. The testify ecosystem in three sentences¶
stretchr/testify is a broad assertion and mocking library. The assert and require packages provide one-line assertions; the mock package provides a generic mock object you embed into your own struct; the suite package provides xUnit-style test suites. Most large Go codebases use testify for one or more of these. The mock package is reflection-based and uses runtime type assertions, which has both ergonomic and safety implications discussed below.
2. Writing a hand-rolled testify mock¶
The mock.Mock type provides call recording, expectation matching, and return-value playback. You embed it into a struct and implement the target interface:
package usermocks
import (
"context"
"github.com/stretchr/testify/mock"
"example.com/myapp/internal/user"
)
type UserRepo struct {
mock.Mock
}
func (r *UserRepo) Get(ctx context.Context, id string) (*user.User, error) {
args := r.Called(ctx, id)
var u *user.User
if v := args.Get(0); v != nil {
u = v.(*user.User)
}
return u, args.Error(1)
}
func (r *UserRepo) Save(ctx context.Context, u *user.User) error {
return r.Called(ctx, u).Error(0)
}
func (r *UserRepo) Delete(ctx context.Context, id string) error {
return r.Called(ctx, id).Error(0)
}
Every method does the same thing:
- Call
r.Called(args...)which records the call and returns amock.Arguments. - Pull return values out via
args.Get(0).(T),args.Error(N), etc. - Return them.
This pattern is fully manual. The compiler does not know that "this is a mock for user.UserRepo" — only that UserRepo happens to implement the same methods. If you rename a method on the production interface, the mock continues to compile but the test silently breaks at runtime when the production code calls a method that the mock implements as Called("OldName").
3. Setting expectations and asserting¶
In a test:
package user_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"example.com/myapp/internal/user"
"example.com/myapp/internal/user/usermocks"
)
func TestService_Get(t *testing.T) {
repo := new(usermocks.UserRepo)
repo.On("Get", mock.Anything, "u1").
Return(&user.User{ID: "u1", Name: "Alice"}, nil)
svc := user.NewService(repo)
got, err := svc.Get(context.Background(), "u1")
assert.NoError(t, err)
assert.Equal(t, "Alice", got.Name)
repo.AssertExpectations(t)
}
Key points:
repo.On("Get", mock.Anything, "u1")declares an expectation: whenCalled("Get", anything, "u1")runs, return the configured values. The method name is a string. Typos here are runtime errors.mock.Anythingis the equivalent ofgomock.Any(). Other matchers:mock.AnythingOfType("*user.User"),mock.MatchedBy(func(x T) bool).repo.AssertExpectations(t)verifies that everyOn(...)was consumed. Without this call, the test passes even if no expected call occurred.
Compare this to gomock: gomock fails the test automatically at the controller's cleanup. testify defers the responsibility to the test author. Forgetting AssertExpectations is one of the most common testify pitfalls.
4. Default leniency¶
testify's mock is lenient by default:
repo := new(usermocks.UserRepo)
// No On(...).
got, err := repo.Get(context.Background(), "u1")
// PANIC: mock: I don't know what to return because the method call was unexpected.
Actually no — the behavior depends on whether any On calls exist. If there are no On calls at all, Called(...) panics with "I don't know what to return". If there is at least one On but the arguments don't match, the same panic occurs.
To make Get accept anything and return zero values:
But — and this is the lenient-by-default trap — AssertExpectations will fail if Get was never called (because the expectation was unmet). To allow zero or more calls, use Maybe:
Maybe() registers the expectation but does not require it to be called.
5. Argument matchers¶
// Exact value
repo.On("Get", mock.Anything, "u1").Return(...)
// Any value
repo.On("Get", mock.Anything, mock.Anything).Return(...)
// Type-based
repo.On("Save", mock.Anything, mock.AnythingOfType("*user.User")).Return(nil)
// Custom predicate
repo.On("Save", mock.Anything, mock.MatchedBy(func(u *user.User) bool {
return u != nil && u.ID != ""
})).Return(nil)
mock.MatchedBy takes a function with one concrete-typed parameter. The type assertion happens via reflection; if the argument is the wrong type, the matcher returns false silently. This means a typo in the parameter type results in the matcher never matching — and the test fails with "expected call but didn't happen" rather than "type mismatch".
6. Return-value plumbing¶
The receiver method does a type assertion on every call. If the test configures .Return(nil, nil), then args.Get(0) is interface{}(nil), and the assertion args.Get(0).(*User) returns (*User)(nil), false. With the comma-ok form you guard against this; with the panicking form you crash. Hand-written mocks usually use the guarded form:
mockery-generated mocks do this automatically.
7. Call counts and ordering¶
testify supports call counts via .Times(n), .Once(), .Twice(), .Maybe():
repo.On("Save", mock.Anything, mock.Anything).Return(nil).Once()
repo.On("Save", mock.Anything, mock.Anything).Return(errSecondCall).Once()
The second On registers a second expectation for the same arguments. testify consumes them in registration order. This is how you simulate different responses on successive calls.
There is no direct equivalent of gomock.InOrder across different methods. You can use mock.Mock.Calls to inspect the history and assert order manually:
repo.AssertCalled(t, "Save", mock.Anything, mock.Anything)
require.Equal(t, "Save", repo.Calls[0].Method)
require.Equal(t, "Commit", repo.Calls[1].Method)
But this is awkward and fragile. If ordering across methods matters, gomock's InOrder is cleaner.
8. Side effects via Run¶
testify's equivalent of gomock's Do:
repo.On("Save", mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
u := args.Get(1).(*user.User)
t.Logf("Save called with %+v", u)
}).
Return(nil)
The Run callback receives mock.Arguments (a slice). You pull values out by index and type-assert. There is no compile-time check that you type-assert correctly.
There is no DoAndReturn equivalent — Run and Return are separate calls. To compute the return dynamically, capture state in the closure and use a function-returning return value isn't supported either. The testify idiom is:
counter := 0
repo.On("Next").Return(func() string {
counter++
return fmt.Sprintf("v%d", counter)
}, nil)
But this requires the production code to call the returned function, which is awkward. Alternative: register multiple On("Next").Once() calls with different Return values.
9. Mockery — codegen for testify-style mocks¶
github.com/vektra/mockery is to testify what mockgen is to gomock: a generator that emits a *testify.Mock-based mock from an interface.
Install:
Configuration via .mockery.yaml at the repo root:
with-expecter: true
filename: "{{.InterfaceName}}.go"
dir: "{{.InterfaceDir}}/mocks"
outpkg: mocks
mockname: "Mock{{.InterfaceName}}"
packages:
example.com/myapp/internal/user:
interfaces:
UserRepo:
EmailSender:
example.com/myapp/internal/payment:
config:
dir: "{{.InterfaceDir}}/testmocks"
interfaces:
Provider:
Run:
For each configured interface, mockery emits a Go file with a struct type implementing the interface using testify/mock. With with-expecter: true, it also emits a typed EXPECT() API that looks similar to gomock's:
// Generated:
type MockUserRepo struct {
mock.Mock
}
type MockUserRepo_Expecter struct {
mock *mock.Mock
}
func (m *MockUserRepo) EXPECT() *MockUserRepo_Expecter {
return &MockUserRepo_Expecter{mock: &m.Mock}
}
func (e *MockUserRepo_Expecter) Get(ctx, id any) *MockUserRepo_Get_Call {
return &MockUserRepo_Get_Call{Call: e.mock.On("Get", ctx, id)}
}
Usage in tests:
repo := mocks.NewMockUserRepo(t)
repo.EXPECT().Get(mock.Anything, "u1").
Return(&user.User{ID: "u1"}, nil)
NewMockUserRepo(t) is a constructor mockery generates that registers t.Cleanup(func() { repo.AssertExpectations(t) }) automatically. This fixes the "forgot to call AssertExpectations" pitfall mentioned in section 3.
10. Mockery v2 vs v3¶
At time of writing, mockery has two active major versions:
- v2 — stable, widely used, configuration via
.mockery.yamlor CLI flags, supportswith-expecter. - v3 — beta, redesigned config schema, supports generics fully, uses Go's
text/templatefor output path. Many projects are still on v2.
Check mockery --version. If you are starting a new project today, mockery v2 with with-expecter: true is a safe default; revisit v3 once it stabilizes.
11. The naming question¶
Mockery's default output is Mock{{.InterfaceName}} in mocks/. Other common conventions:
Fake{{.InterfaceName}}— emphasizes "this is a stand-in", common in counterfeiter-using codebases.{{.InterfaceName}}Mock(suffix) — preferred in some teams for alphabetical grouping with the real interface.mocks_{{.InterfaceName}}— Pythonic, rare in Go.
Whichever you choose, configure .mockery.yaml once and stick with it:
Inconsistent naming across a large repo causes endless review nits.
12. The migration trade-off (testify vs gomock)¶
If you are choosing between testify+mockery and go.uber.org/mock+mockgen for a new project, here is the honest comparison:
| Dimension | gomock + mockgen | testify/mock + mockery |
|---|---|---|
| Codegen step | Required (mockgen) | Optional (mockery) or hand-written |
| Type safety in expectations | Compile-time (via generated EXPECT) | Runtime (or compile-time with with-expecter) |
| Strict by default | Yes | No (must call AssertExpectations) |
| Argument matchers | Rich (Cond, InAnyOrder, etc.) | Basic + MatchedBy |
| Ordering primitives | InOrder, After | None native; manual via Calls |
| Failure messages | Detailed, file:line for both sides | Less detailed |
| Ecosystem familiarity | Common in Google-influenced code | Common in everywhere else |
| Generics | Full (go.uber.org/mock@v0.4+) | Partial (mockery v2), full in v3 |
| Maintenance burden of mocks | Regenerate after every change | Regenerate after every change |
| Mock files in PR | Show up unless linguist-generated | Same |
If your codebase is already using testify everywhere, sticking with mockery is the lowest-friction choice. If you are starting clean, I lean toward gomock for stricter-by-default behavior, but it is genuinely a judgment call.
13. Migrating from hand-written testify mocks to mockery¶
If you inherit a codebase with hand-written testify mocks (one per interface, often in mocks/), mockery can replace them with generated files. Migration steps:
- Install mockery:
go install github.com/vektra/mockery/v2@latest. - Create
.mockery.yamlreferring to each interface. - Delete the hand-written mock files.
- Run
mockery. - Update test files: change
new(usermocks.UserRepo)tousermocks.NewMockUserRepo(t). AddEXPECT()if you opt intowith-expecter. - Run tests; fix any signature mismatches the old hand-written mocks hid.
Step 6 is where the bugs come out. Hand-written mocks often have stale signatures (the interface evolved, the mock didn't). Mockery enforces the current signature.
14. A complete mockery example¶
Interface and SUT:
// File: internal/billing/billing.go
package billing
import "context"
type Invoice struct {
ID string
Amount int64
Paid bool
}
type InvoiceRepo interface {
Find(ctx context.Context, id string) (*Invoice, error)
Update(ctx context.Context, inv *Invoice) error
}
type Charger interface {
Charge(ctx context.Context, amount int64) (txID string, err error)
}
type Service struct {
repo InvoiceRepo
charger Charger
}
func NewService(r InvoiceRepo, c Charger) *Service {
return &Service{repo: r, charger: c}
}
func (s *Service) PayInvoice(ctx context.Context, id string) error {
inv, err := s.repo.Find(ctx, id)
if err != nil {
return err
}
if inv.Paid {
return nil
}
if _, err := s.charger.Charge(ctx, inv.Amount); err != nil {
return err
}
inv.Paid = true
return s.repo.Update(ctx, inv)
}
Mockery configuration (.mockery.yaml):
with-expecter: true
packages:
example.com/myapp/internal/billing:
config:
dir: "{{.InterfaceDir}}/mocks"
outpkg: mocks
mockname: "Mock{{.InterfaceName}}"
interfaces:
InvoiceRepo:
Charger:
After mockery, you have internal/billing/mocks/InvoiceRepo.go and Charger.go. Tests:
// File: internal/billing/service_test.go
package billing_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"example.com/myapp/internal/billing"
mocks "example.com/myapp/internal/billing/mocks"
)
func TestPayInvoice_Success(t *testing.T) {
repo := mocks.NewMockInvoiceRepo(t)
charger := mocks.NewMockCharger(t)
inv := &billing.Invoice{ID: "i1", Amount: 1000, Paid: false}
repo.EXPECT().Find(mock.Anything, "i1").Return(inv, nil)
charger.EXPECT().Charge(mock.Anything, int64(1000)).Return("tx_1", nil)
repo.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i *billing.Invoice) bool {
return i.Paid
})).Return(nil)
svc := billing.NewService(repo, charger)
require.NoError(t, svc.PayInvoice(context.Background(), "i1"))
}
func TestPayInvoice_AlreadyPaid(t *testing.T) {
repo := mocks.NewMockInvoiceRepo(t)
charger := mocks.NewMockCharger(t)
repo.EXPECT().Find(mock.Anything, "i2").
Return(&billing.Invoice{ID: "i2", Paid: true}, nil)
// charger and Update should not be called. NewMockX(t) auto-asserts.
svc := billing.NewService(repo, charger)
assert.NoError(t, svc.PayInvoice(context.Background(), "i2"))
}
func TestPayInvoice_FindError(t *testing.T) {
repo := mocks.NewMockInvoiceRepo(t)
charger := mocks.NewMockCharger(t)
wantErr := errors.New("db down")
repo.EXPECT().Find(mock.Anything, "i3").Return(nil, wantErr)
svc := billing.NewService(repo, charger)
err := svc.PayInvoice(context.Background(), "i3")
assert.ErrorIs(t, err, wantErr)
}
func TestPayInvoice_ChargeFails(t *testing.T) {
repo := mocks.NewMockInvoiceRepo(t)
charger := mocks.NewMockCharger(t)
inv := &billing.Invoice{ID: "i4", Amount: 500, Paid: false}
repo.EXPECT().Find(mock.Anything, "i4").Return(inv, nil)
charger.EXPECT().Charge(mock.Anything, int64(500)).
Return("", errors.New("declined"))
// Update not called.
svc := billing.NewService(repo, charger)
assert.Error(t, svc.PayInvoice(context.Background(), "i4"))
}
The NewMockX(t) constructors do two things automatically:
- Bind the mock to the test (used in
Runcallbacks for error reporting). - Register
t.Cleanup(repo.AssertExpectations).
This makes testify+mockery feel almost as strict as gomock.
15. testify suite package — does it help?¶
github.com/stretchr/testify/suite provides xUnit-style test grouping with SetupTest/TearDownTest. With mocks, it can reduce boilerplate:
type ServiceSuite struct {
suite.Suite
repo *mocks.MockInvoiceRepo
charger *mocks.MockCharger
svc *billing.Service
}
func (s *ServiceSuite) SetupTest() {
s.repo = mocks.NewMockInvoiceRepo(s.T())
s.charger = mocks.NewMockCharger(s.T())
s.svc = billing.NewService(s.repo, s.charger)
}
func (s *ServiceSuite) TestSuccess() {
inv := &billing.Invoice{ID: "i1", Amount: 1000}
s.repo.EXPECT().Find(mock.Anything, "i1").Return(inv, nil)
s.charger.EXPECT().Charge(mock.Anything, int64(1000)).Return("tx_1", nil)
s.repo.EXPECT().Update(mock.Anything, mock.Anything).Return(nil)
s.NoError(s.svc.PayInvoice(context.Background(), "i1"))
}
func TestServiceSuite(t *testing.T) {
suite.Run(t, new(ServiceSuite))
}
Verdict: suite is fine for shared setup, but it conflicts with parallelism (t.Parallel() is awkward inside a suite). Use it only when several tests genuinely share substantial setup.
16. Pitfalls specific to testify/mock¶
16.1 String-based method names¶
Every On("MethodName", ...) is a string. Rename the interface method and the test still compiles but fails at runtime when the production code calls a method the mock implements as Called("OldName").
Mitigation: use with-expecter: true in mockery, which gives a typed EXPECT().MethodName(...) API.
16.2 Lenient by default¶
Without AssertExpectations, unused expectations are silently fine. With mockery's NewMockX(t) constructor this is fixed; with hand-written mocks you have to remember.
16.3 Panic on missing expectation¶
If no On("Foo") matches and Foo is called, Called panics. The panic crashes the goroutine, not the test runner — if the call happens in a worker goroutine, the test may pass with no output and your CI flakes forever.
Mitigation: always run tests with -race in CI; race detector tends to surface goroutine panics.
16.4 Untyped nil¶
repo.On("Get", mock.Anything, "u1").Return(nil, nil)
// In the mock method:
args.Get(0).(*User) // panics on nil
The fix is the guarded type assertion shown in section 6, or returning a typed nil:
mockery-generated mocks handle this correctly.
17. When to pick mockery over mockgen¶
Pick mockery when:
- The codebase already uses
stretchr/testifyheavily (assertions, require, suite). - You want flexible output paths (mockery's
dirtemplate is more powerful than mockgen's flat-destination). - You want auto-
AssertExpectations(via theNewMockX(t)constructor).
Pick mockgen when:
- You want strict-by-default failure semantics without remembering to call any cleanup.
- You need rich argument matching (
Cond,InAnyOrder). - You need cross-method ordering (
InOrder).
Either way, do not mix the two within a single project. Pick one mocking style and apply it consistently; mixed codebases are confusing to contributors.
18. Quick reference¶
| Task | gomock | testify (mockery) |
|---|---|---|
| Create controller / mock | gomock.NewController(t) | mocks.NewMockX(t) |
| Set return | m.EXPECT().F(args).Return(v) | m.EXPECT().F(args).Return(v) |
| Match anything | gomock.Any() | mock.Anything |
| Custom matcher | gomock.Cond(func) or Matcher | mock.MatchedBy(func) |
| Times exact | .Times(n) | .Times(n) / .Once() / .Twice() |
| Times zero or more | .AnyTimes() | .Maybe() |
| Side effect | .Do(func) / .DoAndReturn(func) | .Run(func) then .Return(v) |
| Ordering | gomock.InOrder(e1, e2, e3) | Manual via m.Calls |
| Verify all expectations | Automatic (via t.Cleanup) | m.AssertExpectations(t) or NewMockX(t) |
19. Closing thoughts¶
testify/mock is more popular in absolute numbers because testify itself is the most-used Go testing library. mockery makes it tolerable to write many mocks. But the runtime-reflection nature of testify means you discover problems at test time, not compile time, which costs more on large refactors.
The next file (senior.md) zooms out to cover the rest of the ecosystem: HTTP mocks, SQL mocks, gRPC bufconn, redismock, moq, counterfeiter, and how to choose between them per layer.
20. Deeper dive — testify Arguments object¶
mock.Arguments is a slice with helper methods. Its API is small but useful when writing hand-rolled mocks or Run callbacks:
type Arguments []interface{}
func (args Arguments) Get(i int) interface{}
func (args Arguments) Int(i int) int
func (args Arguments) Error(i int) error
func (args Arguments) Bool(i int) bool
func (args Arguments) String(i int) string
Each typed accessor panics on type mismatch — except Error, which gracefully returns nil if the argument is nil. The common idiom in hand-written mocks is:
func (m *Repo) Find(ctx context.Context, id string) (*User, error) {
args := m.Called(ctx, id)
return args.Get(0).(*User), args.Error(1)
}
This works as long as Return is always called with (*User, error). If you mistakenly write Return("oops", nil) somewhere, the type assertion in args.Get(0).(*User) panics at test runtime.
21. testify mock and goroutines¶
mock.Mock uses an internal sync.Mutex for goroutine safety, but its behavior under concurrent use is subtle:
repo := new(usermocks.UserRepo)
repo.On("Save", mock.Anything, mock.Anything).Return(nil)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
repo.Save(context.Background(), &user.User{ID: fmt.Sprintf("u%d", i)})
}(i)
}
wg.Wait()
This works: the mutex serializes access. But AssertExpectations only verifies that at least one matching call happened (since Return was not given .Times(n)). If you want to assert exactly 10 calls happened, use .Times(10) and trust that the mutex correctly serializes the call count.
Goroutine pitfall: if Save is called from a goroutine that panics (because the test forgot to set up an expectation), the panic happens inside the goroutine. Without recover, the goroutine crashes silently, the test's main goroutine continues, and the test might pass.
Mitigation: always run tests with -race and -timeout 30s. A goroutine panic with no recover crashes the test binary, which the race detector catches.
22. mockery and the inpackage mode¶
By default mockery generates mocks in a separate mocks/ subdirectory. For interfaces with unexported fields or methods that the mock needs to access, generate in the same package:
packages:
example.com/myapp/internal/store:
config:
inpackage: true
mockname: "mock{{.InterfaceName}}"
interfaces:
UserRepo:
This produces internal/store/mock_UserRepo.go in package store. The mock type starts lowercase (mockUserRepo) and is not exported. Tests in the same package can use it directly.
Trade-off: production builds include the mock file unless you add a build tag. Add //go:build !production (or similar) to keep mocks out of release builds.
23. Generated file headers and tooling¶
Both mockgen and mockery emit a // Code generated by ... DO NOT EDIT. header. Tools that respect this header:
gopls(the language server) marks generated files as such, useful for navigation.go vetskips some checks on generated files.golangci-lintcan exclude generated files via theexclude-rulessection.
.gitattributes magic:
This tells GitHub to collapse generated mock files in PR diffs, which is the single biggest quality-of-life improvement for code review when you have hundreds of generated mocks.
24. Snapshot of a real mockery output¶
For reference, a typical mockery v2 output with with-expecter: true looks like this (lightly trimmed for brevity):
// Code generated by mockery v2.40.1. DO NOT EDIT.
package mocks
import (
context "context"
user "example.com/myapp/internal/user"
mock "github.com/stretchr/testify/mock"
)
type MockUserRepo struct {
mock.Mock
}
type MockUserRepo_Expecter struct {
mock *mock.Mock
}
func (m *MockUserRepo) EXPECT() *MockUserRepo_Expecter {
return &MockUserRepo_Expecter{mock: &m.Mock}
}
// Get provides a mock function.
func (m *MockUserRepo) Get(ctx context.Context, id string) (*user.User, error) {
ret := m.Called(ctx, id)
var r0 *user.User
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*user.User, error)); ok {
return rf(ctx, id)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *user.User); ok {
r0 = rf(ctx, id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*user.User)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
type MockUserRepo_Get_Call struct {
*mock.Call
}
func (e *MockUserRepo_Expecter) Get(ctx, id interface{}) *MockUserRepo_Get_Call {
return &MockUserRepo_Get_Call{Call: e.mock.On("Get", ctx, id)}
}
func (c *MockUserRepo_Get_Call) Run(run func(ctx context.Context, id string)) *MockUserRepo_Get_Call {
c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return c
}
func (c *MockUserRepo_Get_Call) Return(_a0 *user.User, _a1 error) *MockUserRepo_Get_Call {
c.Call.Return(_a0, _a1)
return c
}
func (c *MockUserRepo_Get_Call) RunAndReturn(run func(context.Context, string) (*user.User, error)) *MockUserRepo_Get_Call {
c.Call.Return(run)
return c
}
func NewMockUserRepo(t interface {
mock.TestingT
Cleanup(func())
}) *MockUserRepo {
m := &MockUserRepo{}
m.Mock.Test(t)
t.Cleanup(func() { m.AssertExpectations(t) })
return m
}
Notice:
NewMockUserReporegisters the cleanup, so callers do not have to rememberAssertExpectations.- The
Getmethod does function-type detection on the return value, which is how mockery enables both staticReturn(v)and dynamicRunAndReturn(func)modes. - Typed nil is handled (the
if ret.Get(0) != nilguard).
The cost is that each method generates roughly 40 lines of code. For an interface with 10 methods you get 400 lines of generated code. This is fine for a test-only file but inflates go build slightly and shows up in coverage reports unless excluded.
25. Hand-writing fakes alongside testify¶
Some teams use testify for assertions but write fakes instead of mocks for repositories:
package userfakes
import (
"context"
"sync"
"example.com/myapp/internal/user"
)
type UserRepo struct {
mu sync.Mutex
Items map[string]*user.User
SaveErr error
GetErr error
}
func New() *UserRepo {
return &UserRepo{Items: map[string]*user.User{}}
}
func (r *UserRepo) Get(ctx context.Context, id string) (*user.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.GetErr != nil {
return nil, r.GetErr
}
u, ok := r.Items[id]
if !ok {
return nil, user.ErrNotFound
}
return u, nil
}
func (r *UserRepo) Save(ctx context.Context, u *user.User) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.SaveErr != nil {
return r.SaveErr
}
r.Items[u.ID] = u
return nil
}
func (r *UserRepo) Delete(ctx context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.Items, id)
return nil
}
Tests then use:
func TestService(t *testing.T) {
repo := userfakes.New()
svc := user.NewService(repo)
require.NoError(t, svc.Create(context.Background(), "alice"))
// Assert on observable state, not call patterns.
require.NotNil(t, repo.Items["alice"])
require.Equal(t, "alice", repo.Items["alice"].ID)
}
The fake is 30 lines of code (compared to ~150 lines of generated mock) and survives refactors that change which methods the service calls in what order, as long as the observable behavior is the same.
The trade-off: a fake encodes assumptions about the production implementation (e.g. "Save and Get are CRUD on the same map"). If those assumptions diverge from the real implementation (e.g. the real database adds constraints the fake doesn't), tests can pass while production breaks. Many teams write a small contract test that runs both the fake and the real implementation against the same scenarios to catch this drift.
26. Detecting unused mocks in CI¶
Both testify and gomock can leave unused expectations. To catch them:
Add this to your CI pipeline as a separate "test sanity" step. It does not gate the build (tests already gate the build), but it surfaces trends like "this package has 12 unused expectations" that indicate dead test code.
mockery's NewMockX(t) constructor calls AssertExpectations automatically at t.Cleanup, so unused expectations cause test failures. If you write hand-rolled testify mocks, add the same wrapper:
func NewUserRepo(t *testing.T) *usermocks.UserRepo {
t.Helper()
m := new(usermocks.UserRepo)
t.Cleanup(func() { m.AssertExpectations(t) })
return m
}
This is one of the easiest test hygiene wins you can adopt.
27. testify vs gomock for table-driven tests¶
Mocks fit awkwardly into table-driven tests because each case wants different mock behavior. The standard pattern is to attach a setup function to each case:
type testCase struct {
name string
setupRepo func(*mocks.MockUserRepo)
input string
wantErr bool
}
cases := []testCase{
{
name: "happy",
setupRepo: func(m *mocks.MockUserRepo) {
m.EXPECT().Get(mock.Anything, "u1").Return(&user.User{ID: "u1"}, nil)
},
input: "u1",
wantErr: false,
},
{
name: "not found",
setupRepo: func(m *mocks.MockUserRepo) {
m.EXPECT().Get(mock.Anything, "u2").Return(nil, user.ErrNotFound)
},
input: "u2",
wantErr: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
repo := mocks.NewMockUserRepo(t)
tc.setupRepo(repo)
svc := user.NewService(repo)
_, err := svc.Get(context.Background(), tc.input)
if (err != nil) != tc.wantErr {
t.Errorf("err = %v, wantErr = %v", err, tc.wantErr)
}
})
}
This pattern works for both gomock and testify. The key is the per-case setupRepo function: it lets each table row encode its own mock expectations without forcing a giant switch statement.
28. Generating mocks for vendored interfaces¶
If you need to mock an interface from a third-party module (e.g. io.Reader, aws.Client):
mockery:
mockgen reflect mode:
Both produce a mock you can use without writing the interface yourself. Useful for testing code that consumes stdlib interfaces.
29. Composing mocks (one struct implementing multiple interfaces)¶
Sometimes one dependency satisfies multiple interfaces — e.g. a single PaymentClient is both a Charger and a Refunder. You can either:
a. Mock the union interface (ChargerRefunder). b. Mock each separately and inject the same instance twice.
Option (a) is cleaner; option (b) violates DRY but lets each test focus on one capability.
In mockery you can declare a union interface and generate one mock:
mockery sees Payments, flattens the embedded interfaces, and emits MockPayments with both Charge and Refund methods.
30. Wrap-up¶
You should now be able to:
- Write hand-rolled testify mocks for any interface.
- Configure mockery via
.mockery.yamland generate mocks from it. - Use
with-expecter: trueto get a typed expectation API. - Recognize and avoid the four classic testify pitfalls (string method names, lenient default, panic propagation, untyped nil).
- Decide whether testify+mockery or gomock+mockgen fits a given project better.
The senior file dives into HTTP, SQL, gRPC, and Redis mocking, and introduces the lighter-weight code generators moq and counterfeiter.
31. Bonus — mockery template variables¶
mockery's path templates support more variables than the docs make obvious. The full set in v2.x:
{{.InterfaceName}}— bare name (UserRepo).{{.InterfaceNameSnake}}— snake_case version (user_repo).{{.InterfaceNameLower}}— lowercase (userrepo).{{.InterfaceNameCamel}}— camelCase (userRepo).{{.InterfaceDir}}— directory of the source interface.{{.InterfaceDirRelative}}— relative to module root.{{.PackageName}}— short package name.{{.PackagePath}}— full import path.{{.SrcPackageName}}— source package short name.
Example for a monorepo with many services:
filename: "mock_{{.InterfaceNameSnake}}.go"
dir: "{{.InterfaceDir}}/internal/mocks"
outpkg: "{{.PackageName}}mocks"
Result for example.com/myapp/services/billing/Charger:
- file
services/billing/internal/mocks/mock_charger.go - package
billingmocks
Tune these once and contributors don't have to think about file placement again.
32. Bonus — combining testify and gomock in one project (don't)¶
If part of your codebase predates the other half and uses a different mocking style, the temptation is to write new tests with the modern library and leave the old ones alone. This works but creates an asymmetry where contributors must learn both libraries.
A better approach:
- Pick one library going forward and add it to your contribution guidelines.
- As you touch test files for unrelated reasons, convert them to the new library.
- Set a deadline (e.g. 6 months) to finish the migration and remove the old library.
Tracking remaining usages is one grep:
A burn-down chart of this number over time keeps the migration on people's minds.
33. Bonus — testify mock with generics (workaround)¶
mockery v2 has incomplete generics support. For a generic interface:
mockery v2 either errors out or produces a non-generic mock for a specific instantiation. The workarounds:
a. Hand-roll the mock for that interface. b. Wrap the generic interface with a non-generic one used at the call site:
Then mock StringIntCache. Slightly more boilerplate but avoids mockery limitations.
c. Use mockery v3 (currently beta), which has improved generics support.
For go.uber.org/mock, generics work out of the box in v0.4+.
34. Bonus — testify assertions vs require¶
A common confusion: assert vs require. Both packages have identical signatures, but:
assert.Equal(t, want, got)— on failure, marks the test as failed but continues running.require.Equal(t, want, got)— on failure, marks the test as failed and callst.FailNow, ending the test immediately.
In mock-based tests, prefer require for setup assertions (failing fast is correct when setup is broken) and assert for behavioral checks (you may want to know all the things that are wrong).
require.NoError(t, svc.Init(ctx)) // setup; bail if it fails
assert.Equal(t, "Alice", got.Name)
assert.True(t, got.Active)
assert.WithinDuration(t, time.Now(), got.CreatedAt, time.Second)
This is unrelated to mocking but pervasive in testify-heavy code; worth internalizing.
35. Closing checklist for testify+mockery projects¶
- Pin mockery version in
tools.go. - Check
.mockery.yamlinto the repo. - CI runs
mockery && git diff --exit-codeto catch stale mocks. - All generated mocks use
NewMockX(t)constructor (auto-cleanup). - Use
mock.MatchedByfor non-trivial argument matching. - Mock files marked
linguist-generated=truein.gitattributes. -
golangci-lintexcludes generated files from style checks.
With these in place, testify+mockery is a productive mocking setup for the long term.
36. Reading exercises before senior.md¶
Before moving on, try the following on a small repo of yours:
- Add a new interface with three methods, generate a mockery mock for it, and write a happy-path and two error-path tests.
- Convert one of those tests to gomock to feel the difference. Compare line counts and failure messages.
- Replace one mockery mock with a hand-written fake. Note which tests become simpler and which require new state.
- Run
mockeryin CI and verify it fails the build when you delete a generated file without rerunning the generator. This catches stale mocks before review.
Doing these three exercises gives you the muscle memory needed for the senior-level material in the next file, which assumes you already know the trade-offs between the two main libraries.
37. One more nuance — Mock.Test and friendly failures¶
mock.Mock has an internal field t mock.TestingT. Setting it via m.Mock.Test(t) causes mock failures (e.g. unexpected method calls) to fail the specific test rather than panic. Mockery's NewMockX(t) constructor calls m.Mock.Test(t) for you. If you write hand-rolled testify mocks, do the same in your wrapper constructor:
func NewUserRepo(t *testing.T) *usermocks.UserRepo {
t.Helper()
m := new(usermocks.UserRepo)
m.Mock.Test(t)
t.Cleanup(func() { m.AssertExpectations(t) })
return m
}
Without m.Mock.Test(t), unexpected calls panic mid-test, which is harder to debug than a clean t.Fatalf. This is one of those small details that distinguishes a hand-rolled mock setup that ages well from one that doesn't.