Step 8 — Test that the walls actually hold
One test here matters more than the rest: the one that proves users are kept apart.
Topic 73 of 98 · 5 min to read · 50 min to build
After this topic you can
- Write a test proving one user cannot read or change another user's expenses.
- Write test helpers that call
t.Helper()and a hand-written fake instead of a mock library. - Run tests with the race detector and coverage.
Every command for this step
terminal — run these in order
# a separate database for tests, so your real data is never truncateddocker run -d --name exp-test-pg \ -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=expenses_test \ -p 5433:5432 postgres:16export TEST_DATABASE_URL='postgres://postgres:secret@localhost:5433/expenses_test?sslmode=disable'docker exec -i exp-test-pg psql -U postgres -d expenses_test < migrations/001_init.up.sqltouch internal/expense/handler_test.go internal/expense/helpers_test.gotouch internal/expense/service_test.go internal/user/auth_test.go# run themgo test ./... -race -covergo test ./internal/expense/ -run TestUserCannotTouch -v# see what is untestedgo test ./... -coverprofile=c.out && go tool cover -html=c.outWhat to test, and at which level
You have three layers, and each one is tested differently. Getting this right is the difference between tests that catch bugs and tests that only confirm your mocks work.
| Layer | Test it with | Because |
|---|---|---|
| Handler | httptest and a real handler | you are checking statuses, JSON and auth — no network needed |
| Service | a hand-written fake repository | you are checking rules, and a database would only slow it down |
| Repository | a real throwaway Postgres | a fake cannot catch a mistyped column or a broken constraint |
The test that matters most
Everything in this project exists to stop one user reading another user’s data. So the most valuable test in the codebase is the one that proves it.
internal/expense/handler_test.go — create this file
func TestUserCannotTouchAnotherUsersExpense(t *testing.T) { h := newTestHandler(t) tokenA := registerAndLogin(t, h, "a@x.com") tokenB := registerAndLogin(t, h, "b@x.com") // A creates an expense id := createExpense(t, h, tokenA, 1200, "food") // B tries everything. All of it must be 404 — never 403, never 200. tests := []struct { name string method string body string }{ {"read", "GET", ""}, {"update", "PATCH", `{"note":"stolen"}`}, {"delete", "DELETE", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(tt.method, fmt.Sprintf("/v1/expenses/%d", id), strings.NewReader(tt.body)) req.Header.Set("Authorization", "Bearer "+tokenB) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 404 { t.Errorf("%s as another user = %d, want 404 — body %s", tt.method, rec.Code, rec.Body) } }) } // and A's expense is untouched if got := getExpense(t, h, tokenA, id); got.Note == "stolen" { t.Fatal("user B modified user A's expense") }}Test helpers, and why t.Helper matters
internal/expense/helpers_test.go — create this file
func newTestDB(t *testing.T) *sql.DB { t.Helper() // failures point at the CALLER's line db, err := sql.Open("pgx", os.Getenv("TEST_DATABASE_URL")) if err != nil { t.Fatal(err) } mustMigrate(t, db) t.Cleanup(func() { // runs even if the test fails or panics db.Exec("TRUNCATE users, expenses, refresh_tokens RESTART IDENTITY CASCADE") db.Close() }) return db}A fake, not a mocking library
Because Go interfaces are implicit and small, a hand-written fake is a few lines and is often easier to read than a generated mock. You rarely need a mocking library.
internal/expense/service_test.go
type fakeRepo struct { items map[int64]*Expense err error // set this to force the failure path}func (f *fakeRepo) GetByID(_ context.Context, id, userID int64) (*Expense, error) { if f.err != nil { return nil, f.err } e, ok := f.items[id] if !ok || e.UserID != userID { return nil, ErrNotFound } return e, nil}The auth cases worth covering
internal/user/auth_test.go
tests := []struct { name string body string want int}{ {"valid", `{"name":"a","email":"a@b.c","password":"password123"}`, 201}, {"duplicate email", `{"name":"a","email":"a@b.c","password":"password123"}`, 409}, {"short password", `{"name":"a","email":"c@b.c","password":"short"}`, 422}, {"no email", `{"name":"a","password":"password123"}`, 422}, {"malformed json", `nope`, 400},}Run them properly
terminal
go test ./... -race -cover# -race finds data races that only appear under concurrency.# Run it in CI, always.Worth knowing: That is project two. You now have accounts, hashed passwords, tokens, middleware, per-user data, reports, sessions that can be ended, and tests proving the walls hold. Project three adds files, money and roles.
The short version
- Handlers with
httptest, services with a hand-written fake, repositories against a real database. - The cross-user 404 test is the most valuable test in the project. Write it first.
t.Helperandt.Cleanupdo the plumbing; you rarely need a mocking library.- Run
go test -racein CI, always.
Remember: A test against a fake repository only proves your fake works. The rules go against a fake; the SQL goes against a real database.
Build this before moving on
Write the cross-user test first and watch it pass. Then break it on purpose by removing AND user_id = $2 from one query, confirm the test fails, and put it back.Then try these in your own editor
- 1Add the same cross-user test for the monthly report endpoint.
- 2Write a fuzz test on your JSON decoding and run it for 60 seconds.
- 3Check coverage with
go tool cover -htmland find one error path nothing reaches. - 4Delete the
t.Helper()line and see how much worse the failure message gets.
Official documentation