Lesson 21 · Testing, tooling & performance

Mocks & interfaces in tests

Why Part 2's small interfaces pay off — testing with no database.

Your win: wire a testify mock for a repository interface the way our repo does, and explain why the interface seam (Lesson 6) is what makes it possible.

Interfaces are what make mocking work

Recall Lesson 6: our handlers depend on small, consumer-side interfaces, not concrete repos. That's the whole trick — in a test you inject a mock that satisfies the same interface, so the handler runs its real logic against a fake dependency. No interface, no seam, no mock.

production: handler ── EmailRepo(interface) ──▶ &repositories.EmailRepo{} (real, hits Postgres) test: handler ── EmailRepo(interface) ──▶ &MockEmailRepo{} (fake, in memory) ▲ same interface — swapped at the constructor

testify mocks, the repo way

Our mocks embed testify/mock.Mock and are generated by a custom tool (cmd/utils/mock/genSpike.go) — regenerate with make service=spike gen-mock-service after any interface change:

mock/spike/modules/email/infrastructure/repositories/email_repo.go:13
type MockEmailRepo struct{ mock.Mock }
func (r *MockEmailRepo) UpsertEmail(ctx, db, e *model.Email) error {
    args := r.Called(ctx, db, e); return args.Error(0)
}

In the test you program the mock, run the code, then verify the calls happened:

internal/spike/.../commands/create_email_handler_test.go
db := new(mock_database.Ext); tx := new(mock_database.Tx)
repo := new(mock_repo.MockEmailRepo)

db.On("Begin", ctx).Return(tx, nil)                  // expect + result
tx.On("Commit", ctx).Return(nil)
repo.On("UpsertEmail", ctx, mock.Anything,
        mock.MatchedBy(func(e interface{}) bool { ... })). // assert on the arg
    Once().Return(nil)

err := handler.CreateEmail(ctx, payload)              // act

require.NoError(t, err)
mock.AssertExpectationsForObjects(t, db, tx, repo)    // verify all fired
The three moves Arrange: .On("Method", args).Return(vals) — and mock.Anything / mock.MatchedBy to match arguments loosely or precisely. Act: call the code. Assert: AssertExpectationsForObjects fails the test if an expected call never happened.
Anchor — DB with no DB mock_database.Ext / mock_database.Tx mock the pgx executor seam (Lesson 6's database.Ext/QueryExecer), so a handler test exercises the full transaction flow — Begin → repo calls → Commit — entirely in memory. That's why our command-handler tests need no running Postgres.
Read this next

Learn Go with Tests — Mocking + testify/mock

How and why to mock (and when not to). The testify docs are the reference for .On/.Return/MatchedBy.

Learn Go with Tests — mocking
testify/mock docs

Check yourself (from memory)

Q1. Mocking a dependency in Go tests relies on…

The interface seam lets you substitute a mock that satisfies it — Part 2's small consumer-side interfaces paying off.

Q2. repo.On("UpsertEmail", ...).Return(nil) sets up…

It programs the mock: "expect this call, return this." mock.Anything/MatchedBy control argument matching.

Q3. mock.AssertExpectationsForObjects(t, repo) checks that…

It fails the test if a programmed call never fired — catching "the handler skipped the repo" bugs.
Why can our handler tests run with no database, and what makes it possible?
recall, then click to reveal
The handler depends on small interfaces (repo ports, database.Ext/Tx) — Lesson 6. A test injects testify mocks (MockEmailRepo, mock_database.Ext) via the constructor, programs them with .On(...).Return(...), runs the handler, and verifies with AssertExpectationsForObjects. No real DB needed because the interface seam lets you substitute a fake. Interfaces → testability.
Want the debate on mocks vs. real dependencies (integration tests), or how to avoid over-mocking? Ask me — a real senior-interview topic.

1. Learn Go with Tests — mocking; testify/mock.