Dev Skills

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 truncated
docker run -d --name exp-test-pg \
-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=expenses_test \
-p 5433:5432 postgres:16
export 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.sql
touch internal/expense/handler_test.go internal/expense/helpers_test.go
touch internal/expense/service_test.go internal/user/auth_test.go
# run them
go test ./... -race -cover
go test ./internal/expense/ -run TestUserCannotTouch -v
# see what is untested
go test ./... -coverprofile=c.out && go tool cover -html=c.out

What 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.

LayerTest it withBecause
Handlerhttptest and a real handleryou are checking statuses, JSON and auth — no network needed
Servicea hand-written fake repositoryyou are checking rules, and a database would only slow it down
Repositorya real throwaway Postgresa 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")
}
}
This one test is what stops you shipping an IDOR. Write it before you write anything else in this step.

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
}
Without t.Helper, every failure reports the line inside this function rather than the line in the test that actually failed.

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
}
This works because the service declares the interface it needs. Two methods to fake, not fifteen.

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.
Coverage tells you what is definitely untested. It does not tell you what is well tested, and chasing 100% tends to produce tests that assert nothing.
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.Helper and t.Cleanup do the plumbing; you rarely need a mocking library.
  • Run go test -race in 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
  1. 1Add the same cross-user test for the monthly report endpoint.
  2. 2Write a fuzz test on your JSON decoding and run it for 60 seconds.
  3. 3Check coverage with go tool cover -html and find one error path nothing reaches.
  4. 4Delete the t.Helper() line and see how much worse the failure message gets.
Check yourselfTake the Project 2 — Expense tracker test