Lesson 10 · Testing the layers & running

Testing a repository

The repo is the system under test, so you mock the driver beneath it — a round-trip through testutil.MockDB, no database in sight.

Your win: write a repository test that fakes a query and its rows, gets real entities back, and covers the success, query-error, and scan-error cases — using spike's Test_Get as the template.

The shift: mock below the SUT

For a usecase you mocked the repos (Lesson 9). But a repository is the thing that talks to the database — it's the SUT, so you can't mock it. You mock the layer below it: the pgx driver, via testutil.MockDB (Lesson 8). The SUT is the real repo:

internal/spike/modules/email/infrastructure/repositories/email_test.go:225-232
mockDB := testutil.NewMockDB()
db := mockDB.DB
r  := &EmailRepo{}          // ← the REAL repo is the SUT; only the driver is mocked

Arrange — fixtures + a faked query and rows

Build the entities you expect back (the house helper database.AllRandomEntity fills one with random values), then program the query and the scan:

email_test.go:234-248 (the "success" case, condensed)
ent1, ent2 := &model.Email{}, &model.Email{}
database.AllRandomEntity(ent1); database.AllRandomEntity(ent2)   // random fixtures
ent1.EmailID.Set("email-id-1"); ent2.EmailID.Set("email-id-2")

fields, vals1 := ent1.FieldMap()        // column names + scan-target values
_,      vals2 := ent2.FieldMap()
mockDB.MockScanArray(nil, fields, [][]interface{}{vals1, vals2})   // fake TWO rows of Scan
mockDB.MockQueryArgs(t, nil, mock.Anything, mock.Anything, database.TextArray(emailIDs))  // fake Query→Rows

FieldMap() gives the entity's column names and the pointers a scan writes into; MockScanArray then uses reflection (Lesson 8) to copy vals1/vals2 into the repo's scan destinations, one call per row. No Postgres runs.

Act & assert — the round-trip

email_test.go:249-252
emails, err := r.Get(ctx, db, emailIDs)   // act — the real repo builds SQL, "queries", scans
assert.Nil(t, err)
assert.Equal(t, ent1, emails[0])          // the entity round-tripped back intact
assert.Equal(t, ent2, emails[1])

The repo built a query, "ran" it against the mock, and scanned two rows into model.Emails — and you got back exactly the entities you seeded. That verifies the repo's scan wiring (right columns → right fields) without a database.

The error cases — the checklist, cheaply

Two error paths, each one line of setup:

email_test.go:255-269
t.Run("error query", func(t *testing.T) {
    mockDB.MockQueryArgs(t, pgx.ErrNoRows, mock.Anything, mock.Anything, database.TextArray(emailIDs))
    emails, err := r.Get(ctx, db, emailIDs)
    assert.Nil(t, emails); assert.ErrorIs(t, err, pgx.ErrNoRows)     // Query failed
})
t.Run("error scan", func(t *testing.T) {
    mockDB.MockScanFields(pgx.ErrNoRows, fields, vals)              // Scan fails
    mockDB.MockQueryArgs(t, nil, mock.Anything, mock.Anything, database.TextArray(emailIDs))
    emails, err := r.Get(ctx, db, emailIDs)
    assert.Nil(t, emails); assert.ErrorIs(t, err, pgx.ErrNoRows)
})
Why repo tests are worth writing Pass an error to MockQueryArgs → the query fails. Pass an error to the scan → scanning fails. Two of the six checklist cases, for free, because you control the driver. This is what a real DB can't easily give you: deterministic failures. Three cases — success + error-query + error-scan — and the repo's read path is genuinely covered.1
Bonus: asserting the SQL itself Because MockQueryArgs/MockExecArgs captured and parsed the SQL into mockDB.RawStmt (Lesson 8), a repo test can go further and assert on the query shape — the right table, a deleted_at IS NULL filter, the expected columns — with the helpers in mock/testutil/{select,update,where}.go. Useful when the query logic itself is what you're testing.
Read this next

The repository test & the DB toolkit

The real file plus the wrapper it leans on — read them side by side.

→ in-repo …/infrastructure/repositories/email_test.go:225-270 · mock/testutil/mock_db_wrapper.go
pkg.go.dev — pgx · strategy .claude/rules/eureka-v2-conventions.md:103-109

Check yourself (from memory)

Q1. In a repository test, what is the system under test?

r := &EmailRepo{} — the real repo. You mock what's below it (the driver) via testutil.MockDB.

Q2. How do you force a repo test to exercise a scan failure?

The scan helpers take an err — pass pgx.ErrNoRows and assert ErrorIs. Deterministic failure, no DB.

Q3. What does the "success" case actually verify about the repo?

Seeded values round-trip back into the entities → the repo scanned the right columns into the right fields. That's the read-path logic.
Recall: how to test a repository.
SUT + arrange + the three cases, then reveal
SUT = the real repo (r := &EmailRepo{}); mock the driver below via testutil.NewMockDB(). Arrange (success): build fixtures (database.AllRandomEntity), get fields, vals := ent.FieldMap(), mockDB.MockScanArray(nil, fields, [][]interface{}{vals1, vals2}) (fake rows via reflection) + mockDB.MockQueryArgs(t, nil, …, database.TextArray(ids)). Act: r.Get(ctx, db, ids). Assert: emails[i] == ent[i] → verifies scan wiring (columns→fields). Error cases: MockQueryArgs(t, pgx.ErrNoRows, …) → error-query; MockScanFields(pgx.ErrNoRows, …) → error-scan; assert.ErrorIs. Three cases cover the read path — no DB. Bonus: assert SQL shape via mockDB.RawStmt.
🎯 Interview one-liner "How do you test a data-access layer without a database?" → "The repo is the unit, so I mock the driver. A house toolkit fakes the query returning rows and reflects seeded values into the scan targets — so I assert the entities round-trip back, proving the scan wiring. And I get error paths for free by making the mocked query or scan return an error, which a real DB can't do on demand."
Next: the top layer — testing a gRPC handler, where the full error-path checklist finally gets its due (including a log-only path). Ask me about the SQL-shape assertions if you want to test query logic directly.

1. In-repo: …/repositories/email_test.go:225-270, mock/testutil/mock_db_wrapper.go. pgx.