The earlier chapters covered the mechanics: how to write tests, table-drive them, run them in parallel, fake out HTTP, measure coverage. This chapter is about the decisions you make once the mechanics are second nature. Naming, structure, isolation, what to mock and what to leave real, when to use a third-party assertion library, and how to keep tests deterministic so they don't go flaky in CI a month after you wrote them.
Go has one hard rule for test names: they must start with Test and the next character must be uppercase. Beyond that, the convention is your call, but a consistent pattern pays off when a CI failure dumps a hundred test names at you.
The pattern that holds up across most teams is TestFunc_WhenX_DoesY:
Three things this gives you for free:
grep TestApplyDiscount lists every test for that function.The alternative is to use subtests with descriptive names:
Both styles are fine; pick one per package and stick with it. Mixing them in the same file reads like two different authors arguing.
What to avoid: names like TestCase1, TestStuff, TestEdgeCases. They tell you nothing when a build breaks at 2am. A small amount of typing up front saves a lot of context-switching later.
Inside any test, three phases keep things readable: Arrange, Act, Assert. Set up the inputs, call the thing, check the outcome.
You don't need the comments in production code; the structure is the point. Blank lines between phases are enough. The reason this matters is that when a test fails, the reader's first question is "what was set up, what was called, what was wrong with the result?" The AAA layout answers all three at a glance.
A common anti-pattern is interleaving:
Two tests crammed into one. When this fails, you don't know which case broke without running it. Split it into two functions or use subtests.
A test must not depend on the success, failure, or side effects of any other test. That's the rule that keeps a 200-test suite from going flaky in a year.
Three concrete things to watch for:
Shared mutable global state. A var counter int at the package level that one test increments and another reads is a time bomb. The same applies to package-level maps and slices, singletons holding caches, and any function that lazily initializes a global on first call.
Whether TestAvailable passes depends on whether TestRestock ran first. Go runs tests in the order they're declared, but -run can pick a subset, t.Parallel() reshuffles things, and CI sharding can split them across machines. Don't rely on order.
The fix is either to remove the global state (return a *Inventory from a constructor and pass it around) or to reset the state at the start of each test:
Resetting is a band-aid. Removing the global is the real fix.
Filesystem and environment side effects. A test that writes to /tmp/data.json will collide with another test doing the same on a parallel run. Use t.TempDir(), which returns a per-test directory the toolchain cleans up automatically.
Time-of-day. A test that does time.Now() will eventually run during a daylight savings transition, or at 23:59:59, or on a leap day, and start failing. The hermetic-tests section below addresses this.
A test that does one thing and asserts one outcome tells you exactly what's broken when it fails. A test that does fifteen things and asserts twenty outcomes leaves you bisecting failure messages to figure out which step blew up.
Compare:
When this fails with "expected shipped, got placed", you don't know whether UpdateStatus is broken, GetOrder is broken, both, or whether one of the earlier steps silently produced bad state. You'll spend the next twenty minutes adding t.Log calls to figure out where the wheels came off.
Three focused tests:
Each test arranges its own state, exercises one operation, asserts one outcome. When one fails, the failure message points at exactly one operation. The trade-off is more code, but tests are read far more often than they're written, and the reading is the part that hurts.
Integration tests still have a place: an end-to-end smoke test that confirms the pieces actually fit together, run in a separate _integration_test.go file behind a build tag so the inner-loop suite stays fast. The default suite should be the small-focused kind.
These three terms get used interchangeably and it leads to bad code review conversations. The distinctions matter:
| Type | Definition | Example |
|---|---|---|
| Fake | A working alternative implementation, smaller in scope than production | An in-memory OrderStore that satisfies the OrderStore interface but skips the database |
| Stub | Returns canned data; no real logic | func (s *stubInventory) Available(name string) int { return 100 } |
| Mock | Has expectations about how it's called; fails the test if expectations aren't met | "Expect Charge to be called once with amount 19.99" |
The choice depends on what you're testing:
A practical example. Suppose you have an OrderService that talks to a payment provider:
A stub for the case "what happens when payment succeeds?":
A fake for "what happens across multiple calls?":
A mock for "is Charge actually called when an order is placed?":
The mock records calls and the test asserts on them. The library gomock automates this kind of code, but the pattern is the same. You can write mocks by hand for small interfaces; use code generation when interfaces grow.
The interface is the seam. Any of the four implementations plugs in, and the test picks whichever variant tells the clearest story for that particular test.
This is the rule that keeps test suites from turning into a museum of someone else's API.
If you mock *sql.DB directly, your test passes whether or not your actual SQL works. If you mock *http.Client directly, your test ignores connection pooling, retries, and the URL you actually built. The mock becomes a fiction layer that drifts from reality the moment the library updates.
Instead, wrap third-party types in your own interface:
Now your OrderService depends on OrderStore, not *sql.DB. Tests use a fake. Production wires up the real SQL implementation. The mock only needs to satisfy the contract you defined, which is small and stable.
The reverse pattern, mocking *sql.DB with something like sqlmock, has its place when you're specifically testing the SQL itself. But for testing business logic that uses a database, the wrapper-and-fake approach is shorter, faster, and survives library upgrades.
The same applies to http.Client. If your code constructs URLs and parses responses, an httptest.Server gives you a real HTTP round-trip without leaving the test binary. That gives you a real HTTP round-trip without leaving the test binary. A mocked http.Client is fragile and skips the parts that actually break.
The Go community is genuinely split on this. Neither side is wrong, and reading both before picking is worth your time.
The standard library way:
The testify/assert way:
The case for the standard library: no dependency, error messages you control, failure mode is explicit (t.Errorf vs t.Fatalf), and reading the code requires no library knowledge. The Go team and most of the standard library itself sticks with this style. cmp.Diff from github.com/google/go-cmp is the one third-party tool that's broadly accepted, because comparing structs with == is painful.
The case for testify: less boilerplate when assertions are simple, built-in messages for common cases (NotNil, Contains, ElementsMatch), and a familiar API for people coming from other ecosystems. The cost is a dependency, slightly slower failure investigation (the error always says "Expected and actual differ" before the actual values), and a tendency for assert calls to keep running after a failure that should have stopped the test.
A reasonable decision tree:
cmp.Diff regardless of which side you picked. The diff output is worth the dependency.require.X (which stops the test) vs assert.X (which doesn't); using assert for setup steps that must succeed produces confusing cascading failures.There's no quality difference in the tests themselves. There's a small productivity difference per style. Pick one, write it in your contributor docs, move on.
t.Parallel() is a single test's marker. Rolling it out across a whole codebase is where it gets interesting, and where it tends to break things.
The default is sequential: tests in a package run one after another. Adding t.Parallel() to one test marks it as eligible for parallel execution; tests without the call still run sequentially first, then the parallel ones fire together.
A staged rollout looks like this:
t.Parallel() to every test. Run go test -race ./that/pkg many times and watch for races.t.Parallel() usually has contention on a shared resource (a mutex, a single database connection, a global rate limiter), and parallelism is exposing it.The trap to know about: in Go 1.21 and earlier, the table-driven loop pattern needs explicit variable capture or the parallel subtests all see the last tc:
Go 1.22 changed loop variable semantics so the inner tc := tc isn't needed. If your go.mod says go 1.22 or later, you can drop the shadow. Older code still needs it.
Cost: Parallel tests use more CPU and more memory at the same time. On a laptop with 8 cores, go test -parallel 8 will pin the machine. CI runners often have fewer cores than your laptop, so what looks fast locally can starve on CI. The -parallel N flag caps it.
A hermetic test depends on nothing outside its own inputs. No network, no clock, no environment variables, no filesystem outside t.TempDir(), no random source that isn't seeded. Run it twice, get the same result. Run it in 2026 or 2030, same result.
The three most common hermeticity bugs:
Network calls. A test that hits a real API will fail when the network is down, the API rate-limits you, the API changes its response format, or the test runs on a sealed CI runner. The fix is httptest.Server for HTTP, or a fake of your own interface for anything else.
`time.Now()`. A test that reads the current time directly fails on leap days, daylight savings transitions, and any environment where the clock isn't what the test author assumed. The fix is to inject a clock:
In production, OrderService{clock: realClock{}}. In tests, OrderService{clock: &fakeClock{now: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)}}. The test asserts an exact PlacedAt value because the clock is fixed.
If touching every call site is too invasive, a package-level var nowFunc = time.Now and nowFunc = func() time.Time { return ... } in tests works as a smaller change. It's less clean (a global) but easier to retrofit.
Random sources. rand.Intn() (the v1 math/rand package, pre-Go 1.20) is non-deterministic by default starting in Go 1.20, which is great for production and a problem for tests. Seed it explicitly in tests, or use rand/v2 (Go 1.22+) and pass a *rand.Rand your code can fake.
The OrderService from earlier, fully hermetic, looks like:
Three dependencies, all faked. No network, no clock, no global state. Run it on any machine, any year, any timezone, same result. If it fails, the bug is in OrderService.Place, not in the environment.
Each refactor cut one source of non-determinism. The end state is a test that doesn't care about the wall clock or the network and still verifies the same behavior. Flakes go from "fails 2% of the time on Tuesdays" to never.
For each *_test.go file, Go gives you two choices for the package declaration:
| Declaration | Where it lives | What it sees |
|---|---|---|
package cart | Same package as production code | Everything, including unexported names |
package cart_test | A separate sibling package | Only the exported API of cart |
The trade-off is direct: same-package tests can reach inside and verify internals, but they couple to those internals. Separate-package tests can only use the public API, but they exercise the package the way real callers will.
Use package cart (same-package) when:
Use package cart_test (separate-package) when:
You can mix both styles in the same package, with different files. A common layout:
The _test suffix on the package name is special: Go allows exactly one such package per directory, and it's compiled only when running tests. Files in either package are picked up by go test.
Lean toward package cart_test by default. It enforces the public-API discipline, makes tests resilient to refactors, and the small loss in white-box convenience is usually worth the gain in API stability. Drop to same-package only when you have a specific internal you need to verify directly.
Here's a flaky test that ties together everything from this chapter:
Everything wrong with this test, in priority order:
ORD-1 stays in the database after the test runs.placed_at was set correctly.The hermetic version, applying the patterns from this chapter:
Six concrete improvements:
TestPlace_RecordsOrderWithCorrectTimestampAndCharges tells you what's verified.order_test means the test is locked to the public order.NewService API.Run this in 2026 or 2030, on a laptop or on CI, with the network up or down, and the result is the same.
10 quizzes