Lesson 12 · Testing the layers & running

Running, coverage & the workflow

Run one test, run a whole service, read the coverage — and the edit loop that keeps mocks and tests honest.

Your win: run tests at every granularity, read the coverage number (and know what it hides), understand why tests run three times, and recite the daily edit → regenerate → test loop — then the whole course in one view.

Running — three granularities

the commands you'll actually type
# one test function (the tight inner loop while writing)
go test ./internal/spike/modules/email/... -run ^Test_SendEmail$ -v

# a whole service, with coverage (the pre-push check)
make service=spike unit-test-service

That make target expands to (Makefile:104):

Makefile:104-108 (condensed)
go test -count=3 ./internal/spike/... -cover -covermode=count \
        -coverprofile=cover.out -coverpkg=./internal/spike/...
go tool cover -func=cover.out          # → per-function coverage; the last line is the total

-count=3 — why tests run three times

The flake detector -count=3 disables test caching and runs the whole suite three times. A test that passes once but fails on a re-run is flaky — usually order-dependent or sharing state. This is why the house style is so strict about independence: t.Parallel() plus a fresh newSuite() per case (Lessons 2–3) exist precisely so the suite survives -count=3. If your test only passes at -count=1, it has a hidden shared dependency — fix it, don't ignore it.

Coverage — the number and its blind spot

go test -cover reports the percent of statements the tests executed.1 The house target is a soft >80% case coverage per function — and note it strips generated code so the number reflects real logic:

Makefile:109-114 (test-unit-for-coverage)
… | grep -v "_generated_impl.go" | grep -v ".sql.go"   # exclude generated files from the %

But remember Lesson 4: coverage is a smoke alarm, not the goal. The flagship handler test hit its lines at 100% while missing every error path — the number was green and the tests were incomplete. Chase the six-case checklist (behaviours); glance at the percent to catch a whole branch left unrun.

The daily loop — two rules, one habit

Two rules from .claude/rules/go-code-style.md define the workflow:

edit an INTERFACE ─────▶ make service=spike gen-mock-service (regenerate mocks) ← rule 1 │ edit an IMPL file ─────▶ go test ./…/path/... -run ^TestX$ -v (run nearest test) ← rule 2 │ before pushing ─────▶ make service=spike unit-test-service (whole service + coverage)

Rule 1 (regenerate after an interface change) prevents the baffling "mock no longer implements the interface" build break (Lesson 7). Rule 2 (run the nearest test after editing) catches regressions while the change is fresh. Skip either and you pay for it later, further from the cause.

🏛️ The whole course, in one view

WRITE (Part 1) table-driven: t.Run per case · t.Parallel · context.WithTimeout · "return X when Y" house shape: newSuite() + assertExpected() + arrange/act/assert · assert (not require) coverage checklist: error·nil·guard·create·update·tx (>80% case/function) MOCK (Part 2) doubles: mock (verifies behaviour) vs stub (answers queries) · the interface seam testify: .On().Return().Once() · MatchedBy · AssertExpectationsForObjects · .Run generate: gen-mock → reflection (structs) + mockery (interfaces) → testify in mock/ database: mock Ext/Tx (usecase) · testutil.MockDB reflects Scan + parses SQL (repo) TEST BY LAYER (Part 3) each layer mocks the layer BELOW it repository → mock the driver · usecase → mock the repos · transport → mock the usecase fake the auth ctx (ContextWithJWTClaims) · cover every error path · zaptest/observer for logs RUN (Part 3) go test -run ^TestX$ · make service=X unit-test-service (-count=3, coverage) loop: change interface → regenerate mocks → edit → run nearest test → coverage
The five ideas that carry the interview 1. Table-driven + isolated — one subtest per case, fresh mocks, parallel-safe (so -count=3 passes). 2. Mock vs stub — ours verify behaviour, not just return data. 3. The interface seam — DI is what makes code testable at all. 4. Mock the layer below — repo/usecase/transport each isolate the next rung down. 5. Coverage is a checklist, not a percentage — error paths are where bugs hide.
Read this next

Go coverage + Learn Go with Tests

What the coverage number measures, and a community-standard, TDD-first way to deepen the craft.

go.dev — The cover story · in-repo Makefile:99-114, .claude/rules/go-code-style.md:41-60
Learn Go with Tests (community, for going further)

Check yourself (from memory)

Q1. Why does unit-test-service use -count=3?

Three runs surface non-determinism. It's why every test is parallel-safe with a fresh suite — so it survives repetition.

Q2. Which is true about the coverage number here?

The profile excludes _generated_impl.go/.sql.go; and lines-covered ≠ cases-covered — chase the checklist.

Q3. After changing an interface, what must you do before the code compiles?

The stale mock no longer implements the changed interface — the injection stops compiling. Regenerate, then test.
Recall: running, coverage, and the loop.
commands + -count=3 + coverage + the loop, then reveal
Run: one test — go test ./…/... -run ^TestX$ -v; a service — make service=spike unit-test-service (go test -count=3 -cover -covermode=count -coverprofile -coverpkggo tool cover -func, total on last line). -count=3 = run 3× to detect flaky/order-dependent tests → why everything is parallel-safe with a fresh newSuite(). Coverage: % of statements executed; soft >80% case/function; generated code stripped (_generated_impl.go, .sql.go); a smoke alarm, not the goal (100% lines can miss error paths). Daily loop: change interface → make gen-mock-service (rule 1) → edit impl → run nearest test (rule 2) → unit-test-service for coverage before pushing.
🎓🏁 Course complete — Unit testing Twelve lessons: the foundation (table-driven tests, the house structure, assertions & coverage) → mocks & generation (test doubles, testify, the two generators, faking the DB) → testing the layers (usecase, repo, gRPC handler, then running & coverage). You can now write a test that passes review — table-driven, mocked, error-paths covered, mocks regenerated — and read any test in the repo, and name what it does unusually (the two generators, the mockgen-header lie, testutil.MockDB's reflection Scan, -count=3). That's interview-grade fluency in the repo's test craft.
That's the build. The one thing that would prove it stuck is a retention check: ask me to run a mock interview across this course (foundation → mocks → layers, cold, no peeking) and I'll write the first real learning records showing where you're solid and where to review. You've built the knowledge; let's confirm it's yours. Ask me to run it.

1. go.dev — The cover story. In-repo: Makefile:99-114, .claude/rules/go-code-style.md:41-60.