How a Go test is written, in every repo. What to test is the repo's testing-policy when it
ships one: the kinds of test it uses, what each is worth, which a feature needs, and the reusable
patterns to copy as the codebase grows. It never restates how a test is written, so the two do not
overlap. For the red-green-refactor loop, use tdd.
Your defaults that are wrong here
These are the habits that show up unprompted. Check your test against this table before writing it, and again before finishing, because they creep back in late in a long task.
| You reach for | Do this instead |
|---|---|
testify assert/require, any assertion library |
Plain if plus t.Errorf; cmp.Diff for anything not a scalar |
reflect.DeepEqual |
cmp.Diff(want, got), reported as (-want +got) |
A table with wantErr bool and an if tt.wantErr |
Two tests: a success table and an error table checked with errors.Is |
gomock, mockery, EXPECT().Times(1) |
A stateful in-memory fake; assert on the state it ends in |
sqlmock or a mocked database/sql driver |
A real Postgres from internal/pgtest |
TestMain starting a container per package |
pgtest.New(t): one container per repo, a database per test |
TRUNCATE between tests, shared rows |
A fresh database per test, so t.Parallel() is safe |
time.Sleep to wait for a goroutine |
A channel the code signals, a deadline poll, or testing/synctest |
strings.Contains(err.Error(), "...") |
errors.Is / errors.As against an exported sentinel or type |
| A new test dependency | Only github.com/google/go-cmp; nothing else without asking |
Rules
- Test behaviour through the exported API, in the black-box
package foo_test. Usepackage fooonly when the unit is unexported by design, andexport_test.goas a last resort. - Assert on outputs and state, not on which calls were made. A test that breaks on a refactor that changes no behaviour is a change-detector: delete it.
- Pick the most faithful double: real, then fake, then stub, then mock. Mock only a state-changing call across a system boundary (an email sent, a card charged), and then a hand-written recorder, not a mocking library.
- A fake is stateful and honest: the same sentinels, the same not-found behaviour. One contract suite runs against the fake and the real implementation. Force an error path with a one-method stub that embeds the interface, never with failure knobs on the fake.
- Test SQL against a real Postgres through
internal/pgtest. If the repo has nointernal/pgtest, install it from this skill's assets: loadreferences/integration-db.md. - Compare with
cmp.Diff(want, got)and report(-want +got). Ifgo.modlacksgithub.com/google/go-cmp, add it withgo geton the first test you touch, and fix any lint rule or doc in the repo that contradicts this in the same change. When the user or the repo has explicitly ruled out new dependencies ("stdlib only"), compare scalars with==, slices and maps withslices.Equal/maps.Equal, structs field by field. Neverreflect.DeepEqualeither way. - Failure messages read
Func(in) = got, want want. Match errors witherrors.Is/errors.As; a caller that needs to tell errors apart gets an exported sentinel or type. - Use a table only when every case is checked the same way: a slice of structs with a
namefield run witht.Run(tt.name, ...). A branch on a case field means split the test. t.Errorto keep going,t.Fatalwhen the rest can't run. Nevert.Fatalfrom a goroutine the test didn't start on; send the error back on a channel.- Helpers take
t, callt.Helper(), fail on setup errors and registert.Cleanup. They set up; they don't assert. No genericassertEqual. - No
time.Sleepto wait. Wait on a channel, poll a condition with a deadline, or run the test insynctest.Test(Go 1.25+) when it involves timers. t.Parallel()only for tests that share no state: no globals,t.Setenv,t.Chdiror shared rows. Apgtestdatabase is never shared, so DB tests can be parallel.- Compare JSON by decoding it, never as bytes. Normalise golden output before comparing; a
human reviews every
-updatediff. - Fuzz parsers of untrusted input with a property (no panic, round trip); commit
testdata/fuzz/. - Test HTTP handlers through the real router and middleware, built the way
mainbuilds them.
Running
Plain go test ./... runs everything, database tests included: no build tag, no -short skip,
no make target. Build tags are only for end-to-end suites. Without Docker and without
TEST_DATABASE_URL, pgtest fails with a message naming both; don't paper over it with
t.Skip. Run go test -race ./... and golangci-lint run before finishing.
Where to look
| If you're writing… | Load |
|---|---|
A test that touches Postgres, or a repo with no internal/pgtest yet |
references/integration-db.md |
CI for database tests (TEST_DATABASE_URL, service containers) |
references/integration-db.md |
| A unit, table, fake plus contract suite, HTTP, async, golden, fuzz or benchmark test | references/examples.md |
assets/pgtest/ holds the helper and its own tests; assets/golangci-depguard.yml holds the
lint rule that keeps testcontainers imports inside internal/pgtest.
See also
| Skill | When |
|---|---|
go-idiomatic |
Everything about the code under test |
tdd |
Writing the test first, one behaviour at a time |
testing-policy |
What to test: test kinds, their value, reusable patterns (per repo) |