AlgoMaster Logo

Testing Best Practices

Medium Priority23 min readUpdated June 6, 2026

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.

Naming Tests

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:

  • The failing test name reads like a sentence in the CI log.
  • grep TestApplyDiscount lists every test for that function.
  • Reviewers can tell what a test verifies without opening the file.

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.

The AAA Structure

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.

Test Independence

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.

Small Focused Tests Beat Mega Integration Tests

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.

Fakes, Stubs, and Mocks

These three terms get used interchangeably and it leads to bad code review conversations. The distinctions matter:

TypeDefinitionExample
FakeA working alternative implementation, smaller in scope than productionAn in-memory OrderStore that satisfies the OrderStore interface but skips the database
StubReturns canned data; no real logicfunc (s *stubInventory) Available(name string) int { return 100 }
MockHas 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:

  • Use a stub when the dependency's return value is the only thing your code cares about. Quick, no logic, no expectations.
  • Use a fake when your code calls the dependency multiple times and the calls interact (write, then read what you wrote). The fake's internal state mirrors production behavior, just without the I/O.
  • Use a mock when the act of calling is what you're verifying (an email gets sent, a metric is recorded, a payment is charged). Mocks check behavior, not state.

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.

Don't Mock What You Don't Own

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.

testify vs Standard Library Assertions

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:

  • Greenfield project, small team: Pick one and stick with it. Either works.
  • Joining an existing project: Follow the project's existing style. Don't mix.
  • Comparing complex structs: Use cmp.Diff regardless of which side you picked. The diff output is worth the dependency.
  • Want zero dependencies: Standard library. It's the simpler choice and the one Go's own codebase uses.
  • Want one-liner assertions and don't mind a dependency: testify. Just remember 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() Rollout

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:

  1. Pick a leaf package first. Something that has no shared state and no I/O. Add t.Parallel() to every test. Run go test -race ./that/pkg many times and watch for races.
  2. Move up the dependency tree. Each layer is more likely to have shared state. Address it package by package.
  3. Watch the test runtime. A package whose tests get slower after t.Parallel() usually has contention on a shared resource (a mutex, a single database connection, a global rate limiter), and parallelism is exposing it.
  4. Don't parallelize tests that share state. If two tests mutate the same package-level variable, they can't run in parallel without locks. Fix the state, not the test.

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.

Hermetic Tests

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.

The Package Boundary: foo vs foo_test

For each *_test.go file, Go gives you two choices for the package declaration:

DeclarationWhere it livesWhat it sees
package cartSame package as production codeEverything, including unexported names
package cart_testA separate sibling packageOnly 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:

  • The function under test is unexported and complex enough to warrant a direct test.
  • A package-private helper has corner cases that don't surface through the public API.
  • You're testing an invariant the public API doesn't expose (a cache's internal eviction order, for instance).

Use package cart_test (separate-package) when:

  • You're testing the public API the way real callers use it.
  • You want the test to break if the public API changes, which is the point.
  • The test would otherwise create an import cycle (a separate-package test can import the production package and its sibling test helpers).

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.

Putting It Together: Refactoring a Flaky Test

Here's a flaky test that ties together everything from this chapter:

Everything wrong with this test, in priority order:

  1. Real database. Fails when Postgres isn't running, slows down the suite, pollutes other tests via shared rows.
  2. Real HTTP client. Fails when the payment provider's sandbox is down, slow, or reachable only from certain IPs.
  3. `time.Since` against wall clock. Flaky on a slow CI runner where the test sits in a queue for 6 seconds before asserting.
  4. No cleanup. Row ORD-1 stays in the database after the test runs.
  5. Vague assertion. "Older than 5 seconds" tells you almost nothing about whether placed_at was set correctly.

The hermetic version, applying the patterns from this chapter:

Six concrete improvements:

  • Name. TestPlace_RecordsOrderWithCorrectTimestampAndCharges tells you what's verified.
  • AAA layout. Three blank-line-separated phases.
  • Separate package. order_test means the test is locked to the public order.NewService API.
  • Hermetic. Fake clock, fake store, mock payment. No network, no database, no real time.
  • Exact assertions. The timestamp is asserted to equal a specific instant, not "younger than 5 seconds".
  • Independent. No shared state across tests, no row left in a database.

Run this in 2026 or 2030, on a laptop or on CI, with the network up or down, and the result is the same.

Quiz

Testing Best Practices Quiz

10 quizzes