Lesson 9 · Testing the layers & running

Testing a usecase/handler

The flagship pattern: inject mock repositories into the handler, drive its transaction, and verify the entity it built.

Your win: write a usecase test end to end — construct and inject the mocks, mock the transaction, assert on the entity via MatchedBy, and verify — using spike's CreateEmail as the template.

The strategy: mock the layer below

A usecase's job is business logic — it orchestrates repositories inside a transaction. So you test it by mocking the repositories and the database, then checking it called them correctly. Everything you learned in Part 2 comes together here.

Arrange — build and inject the mocks

internal/spike/modules/email/application/commands/create_email_handler_test.go:26-37
mockDB   := new(mock_database.Ext)            // the pool
mockTx   := new(mock_database.Tx)             // the transaction
mockEmailRepo          := new(mock_repo.MockEmailRepo)           // the ports…
mockEmailRecipientRepo := new(mock_repo.MockEmailRecipientRepo)
mockEmailMetrics       := new(mock_metrics.EmailMetrics)

handler := CreateEmailHandler{                // inject them into the SUT's fields
    DB: mockDB, EmailMetrics: mockEmailMetrics,
    EmailRepo: mockEmailRepo, EmailRecipientRepo: mockEmailRecipientRepo,
}

This is the seam from Lesson 5 in action: because those fields are interfaces, the mocks drop right in. (In a full test this goes in the newSuite() factory, Lesson 3.)

Arrange — program the mocks

The handler opens a transaction, upserts two entities, and records a metric. Program each:

create_email_handler_test.go:80-101, 149 (condensed)
mockDB.On("Begin", ctx).Return(mockTx, nil)       // the transaction opens…
mockTx.On("Commit", ctx).Return(nil)              // …and commits

mockEmailRepo.On("UpsertEmail", ctx, mockTx, mock.MatchedBy(func(e interface{}) bool {
    email, ok := e.(*model.Email)             // ← assert the handler built it right:
    return ok && email.Subject.String == "subject"
        && email.EmailID.String != ""          //   it generated a ULID
        && email.Status.String == "EMAIL_STATUS_CREATED"  //   it set the status
})).Return(nil)
mockEmailMetrics.On("RecordEmailEvents", ...).Once()  // the metric fires exactly once
Notice what's being tested The handler doesn't return the entity — so its correctness (did it assign the ULID? set the status? map the subject?) is only visible in the argument it hands to UpsertEmail. mock.MatchedBy is where that behaviour is asserted. The mock isn't just a stand-in here; its matcher is the test's core assertion. And mockTx gives you the transaction: to test the rollback path, make a repo call return an error and check the handler surfaces it (checklist case 6).

Act & assert

create_email_handler_test.go:155-164 (condensed)
email, err := handler.CreateEmail(ctx, createEmailPayload)   // act — one call

assert.Nil(t, err)                                          // assert the result…
assert.NotEmpty(t, email.EmailID)
mock.AssertExpectationsForObjects(t, mockDB, mockTx, mockEmailRepo, mockEmailRecipientRepo, mockEmailMetrics)  // …and the behaviour
An honest gap in this file — and the lesson in it This flagship test has only the happy case. By the coverage checklist (Lesson 4) it's incomplete — there's no "repo returns an error," no "commit fails." That's not a model to copy; it's a reminder that a passing test file isn't a complete one. The error paths for this flow are actually covered by the gRPC test one layer up (Lesson 11). When you write your own usecase test, walk all six checklist cases — don't stop at green.
Read this next

The full usecase test

Open the real file and read it top to bottom — it's the template you'll copy most.

→ in-repo internal/spike/modules/email/application/commands/create_email_handler_test.go
testify/mock (the API it uses) · rules .claude/rules/go-test-style.md

Check yourself (from memory)

Q1. How is a usecase test's system-under-test given its collaborators?

CreateEmailHandler{DB: mockDB, EmailRepo: mockEmailRepo, …} — the interface fields accept the mocks directly.

Q2. Why is mock.MatchedBy the core assertion in this test?

The handler doesn't return the entity, so the only place to check it built it right (ULID, status, subject) is the argument to UpsertEmail.

Q3. What's the flaw in this flagship test file?

Happy-case-only — incomplete by the checklist. A passing file isn't a complete one; the errors are covered a layer up.
Recall: how to test a usecase/handler.
strategy + the four steps, then reveal
Strategy: mock the layer below (repos + DB), check the usecase orchestrates them right. Arrange: build mocks (mock_database.Ext/Tx, mock repos, mock metrics) and inject into the handler's interface fields (CreateEmailHandler{DB: mockDB, EmailRepo: mockEmailRepo, …}); program them — mockDB.On("Begin").Return(mockTx), mockTx.On("Commit").Return(nil), mockEmailRepo.On("UpsertEmail", ctx, mockTx, mock.MatchedBy(…entity fields…)).Return(nil), metric .Once(). Act: handler.CreateEmail(ctx, payload). Assert: result (assert.Nil(err), NotEmpty(id)) + AssertExpectationsForObjects. MatchedBy is the core assertion (the entity isn't returned). Caveat: the real file is happy-case-only — write all six checklist cases yourself.
🎯 Interview one-liner "How do you test a service/usecase?" → "Inject mock repositories into its interface fields, mock the transaction with Begin/Commit, and drive it. Since it hands entities to the repo rather than returning them, I assert on those arguments with MatchedBy, then verify all the expected calls happened. And I cover every error path — a green happy-case test isn't a finished one."
Next: one layer down — testing a repository with testutil.MockDB, no database in sight. Ask me to sketch the rollback-path case for this handler if you'd like to see the sixth checklist item.

1. In-repo: …/create_email_handler_test.go:26-164. testify/mock.