Mocking Libraries — Tasks¶
The tasks below are progressive. Each builds on the previous one. Use the official module paths exactly as cited.
Task 1 — gomock from scratch¶
Install go.uber.org/mock/mockgen and generate a mock for the following interface. Write a single test that exercises Save and verifies it was called exactly once with the right argument.
package store
type EventStore interface {
Save(ctx context.Context, e Event) error
}
type Event struct {
ID string
Body []byte
}
Acceptance:
mock_store.gois produced by//go:generate mockgen -source=store.go -destination=mock_store.go -package=store.- The test uses
gomock.NewController(t)andEXPECT().Save(...). - Running
go test ./...passes; deleting theSavecall in the SUT causes the test to fail.
Task 2 — testify/mock equivalent¶
Rewrite the same test using github.com/stretchr/testify/mock. Write the mock by hand (no codegen). Verify with mock.AssertExpectations(t).
Acceptance:
- The hand-written mock embeds
mock.Mock. - A typo in the method name (e.g.
Sveinstead ofSave) is not caught at compile time — note this in a comment explaining the trade-off.
Task 3 — mockery codegen¶
Add mockery to the project, configure .mockery.yaml to generate into internal/store/mocks/, and regenerate. Convert the test from Task 2 to use the mockery-generated mock with with-expecter: true.
Acceptance:
.mockery.yamllives at the repo root.- The generated file is
internal/store/mocks/EventStore.go. - The test uses
mocks.NewEventStore(t).EXPECT().Save(ctx, event).Return(nil).
Task 4 — moq codegen¶
Add a //go:generate go run github.com/matryer/moq -out event_store_moq.go . EventStore directive. Use the generated mock in a test where the SUT calls Save twice, and assert the second argument by reading the SaveCalls() slice.
Acceptance:
- The mock is generated without writing YAML config.
- The test reads
mock.SaveCalls()[1].E.IDto verify the second call's ID.
Task 5 — HTTP client with httpmock¶
Write a WeatherClient that calls https://api.example.com/weather?city=X and returns the temperature. Test it with github.com/jarcoal/httpmock:
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpmock.RegisterResponder("GET",
"https://api.example.com/weather?city=Tashkent",
httpmock.NewStringResponder(200, `{"temp":21}`))
Acceptance:
- A test using a 500 response asserts the client returns a wrapped error.
- A test using a malformed body asserts a parse error.
Task 6 — DB with sqlmock¶
Install github.com/DATA-DOG/go-sqlmock. Write a UserRepo.GetByID(ctx, id) that runs a single SELECT. Test it with sqlmock:
mock.ExpectQuery("SELECT id, name FROM users WHERE id = \\$1").
WithArgs("u1").
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).
AddRow("u1", "Alice"))
Acceptance:
- The test asserts
mock.ExpectationsWereMet()at the end. - A second test simulates
sql.ErrNoRowsand asserts the repo returns the domain-specificErrUserNotFound.
Task 7 — gRPC with bufconn¶
Define a small pb.UserService with one RPC GetUser. Implement a server. In the test, start the server on bufconn.Listen(1 << 20) and dial it with the custom dialer. Assert that the real client and server work end to end.
Acceptance:
- No mocks of the gRPC client are used.
- The test runs in under 100ms.
Task 8 — Redis with miniredis¶
Build a UserCache that uses github.com/redis/go-redis/v9:
type UserCache struct {
rdb *redis.Client
}
func (c *UserCache) Set(ctx context.Context, u *User) error { ... }
func (c *UserCache) Get(ctx context.Context, id string) (*User, error) { ... }
Test it with github.com/alicebob/miniredis/v2. Verify:
- A
Setfollowed by aGetreturns the stored value. - A
Getof an unknown key returnsredis.Nil. - After
miniredis.FastForward(ttl + 1), the key is gone.
Acceptance:
- Tests use
miniredis.RunT(t). - Each test creates its own miniredis instance (no shared state).
- The test suite runs in under 200ms.
Task 9 — Comparing testify and gomock on the same SUT¶
Write two test files for the same UserService:
service_gomock_test.go— usesgo.uber.org/mock.service_testify_test.go— usesgithub.com/stretchr/testify/mockwith a hand-written mock.
Both test files cover the same five scenarios. After implementing:
- Count lines of mock setup per test in each file.
- Force a typo: rename
SavetoSveon the production interface. Note which test file's compilation breaks first. - Remove the
Savecall from one test path in the SUT. Note which test file's failure message is more useful.
Acceptance:
- Both test files pass before the typo experiment.
- The typo experiment yields a write-up of "gomock catches at compile, testify catches at runtime".
Task 10 — Migrate from github.com/golang/mock to go.uber.org/mock¶
Find a public Go project still using the archived github.com/golang/mock. Fork it, perform the migration:
- Update
go.mod: replace the dependency. - Rewrite imports across the codebase.
- Reinstall
mockgenfrom the new module. - Regenerate all mocks.
- Run tests; fix any drift.
- Commit as a single PR with a clear description.
Acceptance:
- All tests pass.
git grep "github.com/golang/mock"returns nothing.- The diff is mostly mechanical (imports + regeneration).
Task 11 — Replace a mock-heavy test with a fake¶
Find one of the test files you wrote in Tasks 1-3 (the UserService tests). Replace the mocked UserRepo with a hand-written fakeUserRepo storing data in a map[string]*User. Adjust the tests:
- Pre-populate the fake before the SUT runs (instead of
EXPECT().Get). - Assert on the fake's final state after the SUT runs (instead of
EXPECT().Save).
Compare:
- Total line count (mock-based vs fake-based).
- Robustness: refactor the SUT to call
Savetwice. Which test still passes?
Acceptance:
- The fake is reusable across multiple test functions.
- Tests using the fake survive the "Save twice" refactor; tests using the mock fail.
Task 12 — Contract test for a fake¶
Take the fake from Task 11. Write a repoContractTest function that takes a func() UserRepo factory and runs the same five tests against whatever it returns. Run it against both:
- The fake.
- A real
*sql.DB-backed repository (usinggithub.com/testcontainers/testcontainers-gofor PostgreSQL).
Acceptance:
- The same test file passes against both implementations.
- The PostgreSQL test is gated behind
-short=falseso it doesn't run on every push.
Task 13 — Counterfeiter for a callback-heavy interface¶
Define an interface with a callback parameter:
type EventBus interface {
Subscribe(topic string, handler func(event Event)) (cancel func(), err error)
}
Generate a counterfeiter fake. Write a test where the SUT subscribes, the fake invokes the handler twice with different events, and the SUT processes both. Assert that the SUT's internal state reflects both events.
Acceptance:
- Uses
counterfeiter -o ./fakes/event_bus_fake.go . EventBus. - Test reads
fake.SubscribeCallCount()andfake.SubscribeArgsForCall(0)to verify the topic. - Handler is invoked from inside the
SubscribeStubfunction.
Task 14 — Mockgen reflect mode for an external package¶
Generate a mock for crypto/tls.ClientHelloInfo accessor functions (actually a struct, but for the sake of practice — use an interface from an external package, e.g. aws-sdk-go-v2/service/s3.PutObjectAPIClient).
Acceptance:
- Generated in reflect mode (no source file path).
- Mock is used in a test that exercises the consumer code.
Task 15 — Add CI check for stale mocks¶
Add a CI step to your repo:
Make a change that adds a method to one of your mocked interfaces. Without regenerating, push and observe the CI failure. Then regenerate locally and observe the CI pass.
Acceptance:
- The check fails when mocks are out of date.
- The check passes when mocks are regenerated.
- A README section documents how to regenerate locally.
Task 16 — Refactor a mock-heavy test into property-based test¶
Pick one of your gomock-based tests and convert it to a property-based test using testing/quick or pgregory.net/rapid. The mock will need to behave dynamically (different inputs each iteration). Use DoAndReturn to compute responses based on the input.
import "pgregory.net/rapid"
func TestServiceProperty(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
ctrl := gomock.NewController(t)
repo := mocks.NewMockUserRepo(ctrl)
repo.EXPECT().Get(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, id string) (*User, error) {
return &User{ID: id, Name: "test-" + id}, nil
}).
AnyTimes()
id := rapid.String().Draw(t, "id")
svc := user.NewService(repo)
got, err := svc.Get(context.Background(), id)
require.NoError(t, err)
require.Equal(t, id, got.ID)
})
}
Acceptance:
- The test exercises the SUT with at least 100 random inputs per invocation.
- The mock returns dynamic data per call (not the same hardcoded value).
- The test catches a regression you intentionally introduce in the SUT.
Task 17 — Comparison report¶
Write a 1-page report comparing your experience across Tasks 1-15. For each library you used, note:
- Setup time (minutes).
- Lines of code per typical test.
- Failure-message helpfulness on a 1-10 scale.
- Refactor resilience (did renames break tests unnecessarily?).
- Whether you would pick it for a new project.
This report becomes a useful artifact for your team to standardize on a mocking approach.
Acceptance:
- Report exists at
docs/mocking-comparison.mdin your project. - Includes at least 6 libraries (gomock, mockery, testify hand-rolled, moq, httpmock, sqlmock, miniredis, bufconn — pick six).
Task 18 — Final integration¶
Put it all together. Build a small service (a URL shortener, RSS aggregator, or paste bin — your choice) using:
- A
Repointerface backed by PostgreSQL in production. - Unit tests use an in-memory fake.
- Contract tests run the fake and a testcontainers PostgreSQL.
- A
Cacheinterface backed by Redis in production. - Tests use miniredis.
- A
Notifierinterface (mock with gomock in tests). - An HTTP API tested with
httptest.NewServer.
Acceptance:
- All tests pass with
go test ./... -short. - All tests including contract tests pass with
go test ./.... - CI is configured to run both.
- No mocks are hand-written; either generated or written as fakes intentionally.
This task simulates a realistic test architecture at small scale. The skills transfer directly to large microservices.