Testing deep: table-driven, subtests, mocks, race detector
Go's testing package is intentionally minimalist. No describe and it, no beforeEach, no fluent assertion library bundled in. Just testing.T, t.Run, t.Parallel, and the convention that test files end in _test.go. The minimalism is a feature: it pushes you toward small, fast, readable tests written in the same idiom as the code under test.
Once you absorb the conventions, you stop reaching for testing libraries. The standard library plus a careful project structure handles 95 percent of what a senior backend tests for.
The basics
A test file lives next to the file under test, with _test.go suffix. The function takes *testing.T, names start with Test, and the package can be the same as the code or package foo_test for black-box testing.
// adder_test.go
package adder
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
if got != 5 {
t.Errorf("Add(2,3) = %d, want 5", got)
}
}t.Errorf records the failure but lets the test keep running. t.Fatalf records and stops the test. Use Fatalf when continuing would just produce noise (a nil pointer crash, for example).
Run with:
go test ./...
go test -run TestAdd ./...
go test -v -run TestAdd ./...The table-driven pattern
This is THE Go-idiomatic test style. One test function, a slice of named cases, a loop that runs each case as a subtest.
func TestParseDuration(t *testing.T) {
cases := []struct {
name string
input string
want time.Duration
wantErr bool
}{
{"seconds", "5s", 5 * time.Second, false},
{"minutes", "3m", 3 * time.Minute, false},
{"compound", "1h30m", 90 * time.Minute, false},
{"empty", "", 0, true},
{"garbage", "five seconds", 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseDuration(tc.input)
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
}
if got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}t.Run(name, fn) creates a subtest that shows up as TestParseDuration/compound in the output. You can run a single case with go test -run TestParseDuration/compound. Each case has its own pass/fail line, so a regression points straight at the failing input.
Senior rule: if a test needs more than 10 lines of setup, your code is wrong, not your test. Hard-to-test code is usually code with too many responsibilities or hidden dependencies. Refactor toward smaller surface area before reaching for elaborate mocks.
Parallel subtests and the loop-variable trap
t.Parallel() signals that the test can run concurrently with other parallel tests. The framework batches them and runs them on multiple cores.
for _, tc := range cases {
tc := tc // capture by value for Go versions before 1.22
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// ... use tc
})
}The tc := tc line is the loop-variable trap. Before Go 1.22, tc was a single variable reused across iterations, so all parallel goroutines saw the LAST case. Since 1.22 the loop variable is per-iteration by default, but if you target older Go versions or want self-documenting code, keep the shadowing.
Mocking via interfaces, no library needed
Go does not need a mocking library because interfaces are structural. If your production code depends on a UserRepo interface, you write a fake type in the test file that satisfies it.
// service.go
type UserRepo interface {
Get(ctx context.Context, id string) (User, error)
}
type Service struct{ repo UserRepo }
func (s *Service) Greet(ctx context.Context, id string) (string, error) {
u, err := s.repo.Get(ctx, id)
if err != nil {
return "", err
}
return "hello " + u.Name, nil
}// service_test.go
type fakeRepo struct {
user User
err error
}
func (f *fakeRepo) Get(ctx context.Context, id string) (User, error) {
return f.user, f.err
}
func TestGreet(t *testing.T) {
s := &Service{repo: &fakeRepo{user: User{Name: "Asha"}}}
got, err := s.Greet(context.Background(), "u1")
if err != nil {
t.Fatal(err)
}
if got != "hello Asha" {
t.Errorf("got %q", got)
}
}No gomock, no mockery, no code generation step. Fifteen lines of plain Go.
For more sophisticated fakes (recording calls, configurable per-call behaviour), grow the fake gradually. If you find yourself rebuilding a mocking framework, that is the signal that your interface has too many methods, not that you need a framework.
Interview trap: candidates name
gomockormockeryreflexively. The senior answer is "I prefer hand-written fakes because they are easier to read in test output and they document the interface contract". You can mention the libraries exist, but never name-drop them as the default.
httptest: handlers in-process
httptest.NewRecorder is a ResponseWriter that captures the response in memory. httptest.NewRequest builds a request without a network. Together they let you unit-test a handler without binding a port.
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
healthHandler(rec, req)
res := rec.Result()
if res.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", res.StatusCode)
}
}For full integration tests against a running server:
srv := httptest.NewServer(http.HandlerFunc(myHandler))
defer srv.Close()
resp, err := http.Get(srv.URL + "/users/123")httptest.NewServer picks an ephemeral port, you talk to it over the real network in-process. Perfect for testing client code or middleware chains end to end.
The race detector
go test -race instruments the binary with a happens-before tracker. Any read-write or write-write conflict on the same memory address without synchronisation gets flagged at runtime.
go test -race ./...It costs roughly 5x CPU and 10x memory, so do not run it in production. Do run it in CI on every PR. Race conditions that manifest as flaky tests in CI are races that will become customer-facing outages in production.
Senior rule:
go test -race ./...belongs in your CI. If a test only fails under-race, it is not flaky. It is a real bug, and disabling the test means shipping the bug.
Example functions: docs that compile
A function named ExampleAdd in adder_test.go shows up in godoc AND runs as a test that verifies the output matches the // Output: comment.
func ExampleAdd() {
fmt.Println(Add(2, 3))
// Output: 5
}Two birds, one stone. The example appears in your package documentation, and CI verifies it never drifts from the real behaviour.
A word on testify
Testify (assert, require) gives you fluent assertions. require.Equal(t, want, got) reads slightly nicer than if got != want { t.Errorf(...) }. That is the entire upside.
The downside is that test output becomes "Not equal: expected X, actual Y" instead of a message you wrote. For trivial equality it is fine, but for any non-trivial assertion, hand-rolled error messages convey the intent better.
Use it if your team likes it. Do not feel obligated. The standard library is enough.
Testing toolkit
- _test.go
- Suffix that marks a file as test-only. The compiler excludes these from production builds.
- t.Run
- Creates a subtest with its own name and pass/fail line. Enables -run TestX/subname filtering.
- t.Parallel
- Signals a test (or subtest) can run concurrently with other parallel tests. Watch the loop-variable trap.
- httptest
- Stdlib package with NewRecorder (in-memory ResponseWriter) and NewServer (real ephemeral-port server) for HTTP tests.
- race detector
- Runtime instrumentation enabled by -race that flags data races. Costs ~5x CPU. Mandatory in CI.
- Example function
- Test function named ExampleX with an // Output: comment. Verified by the test runner and rendered in godoc.
What we will cover next
The final lesson rolls up everything we have built into the four data-structure-and-pattern problems every senior Go interview asks: rate limiter, worker pool, LRU cache, pub/sub. Each one is a chance to use channels, mutexes, and context together with the discipline of the previous five lessons.
Watching quietly. Tap me if you want a tip.
Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.
Try this (0 of 1 done)
- 1
Predict the output if all test cases pass.
show answer
// already in the editor