Unit Tests
Writing effective unit tests with table-driven patterns
The Table-Driven Pattern
Go has a distinctive approach to testing that you won't find in most other languages: table-driven tests. Instead of writing separate test functions for each case, you define a slice of test cases and iterate through them. This pattern emerged from the Go standard library and has become idiomatic for good reason.
The power of this pattern becomes clear when you need to add a new test case. Instead of copying an entire test function and modifying it, you add a single struct to the slice. The test logic stays in one place, making it easy to verify that all cases are tested consistently.
When to Use Tables vs Individual Tests
Table-driven tests work best when test cases share identical setup and verification logic, differing only in inputs and expected outputs. The email validation example above is a good fit: every case calls the same function and checks for an error.
Use individual test functions when cases have substantially different setup, need distinct assertions, or when the test function name itself serves as valuable documentation. For example, testing a retry mechanism:
Forcing these into a table would require complex setup functions in the struct, making the test harder to read. The descriptive function names also make CI output more informative than generic table case names would be.
A reasonable heuristic: if your table struct needs function fields for setup or custom assertions, consider whether individual tests would be clearer.
The t.Run() call is essential. It creates a subtest with a name, which means test output shows exactly which case failed. You can also run a single case with go test -run TestValidateEmail/empty_string. Without t.Run(), you'd see a failure at some line number and have to count through the slice to figure out which case it was.
Naming Tests
Test naming happens at two levels: the test function name and the individual test case names within table-driven tests. Both matter for understanding failures in CI output.
Test Function Names
Name test functions as Test<Type>_<Behavior> or Test<Function>_<Scenario>. The name should describe what's being tested, not just label a group of tests.
Avoid grouping unrelated tests under generic names like TestEdgeCases or TestHelpers. If tests don't share setup or a common theme, they belong in separate functions. When a test function grows beyond 50-60 lines, consider whether it's doing too much.
Test Case Names
The name field in your test struct is documentation. It should describe the scenario and, ideally, hint at the expected outcome. Someone reading the test output should understand what was being tested without looking at the code.
When a test fails in CI, you'll see something like TestValidateEmail/missing_domain_is_rejected. Good names make it possible to understand failures without context-switching into the code.
Parallel Execution
Go can run subtests in parallel, which speeds up test suites and surfaces race conditions. Add t.Parallel() at the start of your subtest to opt in.
There's a critical gotcha here that trips up everyone at least once. In the loop above, tt is captured by reference, meaning all parallel subtests might see the same value (the last one in the slice). Before Go 1.22, you needed to create a local copy:
Go 1.22 changed loop variable semantics to fix this, but if you're maintaining older code or your muscle memory includes the copy, it doesn't hurt to keep it.
When to Use Parallel
Not all tests should run in parallel. Use t.Parallel() when tests are pure computation with no shared state. Skip it when:
- Tests share a database connection and mutate state
- Tests start background goroutines that might interfere with each other
- Tests depend on a specific sequence of operations
- Tests use shared resources like file paths or network ports
- Cleanup order matters (parallel subtests have unpredictable completion order)
A good heuristic: if your test creates resources that need cleanup with defer or t.Cleanup(), think carefully about whether parallel execution is safe. Tests for a buffer that starts background goroutines, for instance, should probably run serially to avoid goroutine leaks or race conditions during cleanup.
Test Data
Small test data belongs inline in the test file. There's no need to read from a file when a string literal will do:
For larger fixtures (JSON schemas, certificates, binary files) use a testdata/ directory. Go tooling ignores this directory by convention, and Bazel can include it with a glob:
Read test data relative to the test file:
Unique Identifiers
Tests that create resources need unique identifiers. Hardcoded IDs like "user_123" work fine for a single test, but cause collisions when tests run in parallel or when cleanup fails to run.
The uid.New() function generates a unique identifier with a prefix for readability. When you see test_user_01HXYZ... in logs or database rows, you know it came from a test.
Testing Time
Functions that depend on time are notoriously hard to test. If your code calls time.Now() directly, you can't test expiration logic without actually waiting. Our pkg/clock package solves this with a test clock you can control.
The code under test must accept a clock.Clock interface rather than calling time.Now() directly. This is a small price for deterministic tests that run in milliseconds instead of waiting for real time to pass.
Testing Errors
Test both success and failure paths. Many bugs hide in error handling code that rarely executes in manual testing.
When testing specific error types, use errors.Is() or errors.As() rather than string matching:
This works correctly even when errors are wrapped with additional context.
Testing Generic Functions
Go generics let you write functions that work with multiple types, but you don't need to test every possible type parameter. The compiler ensures type safety, so exhaustive type testing has low value.
Test generic functions with one or two representative types that exercise the actual logic:
Focus tests on the behavior of the generic function itself, not on proving that Go's type system works. If your generic function has type-specific behavior (like sorting, which behaves differently for strings vs numbers), test those specific cases.
Testing Background Goroutines
Code that starts background goroutines presents a testing challenge. You need to verify that the goroutine does its job and that it stops cleanly when asked. Failing to test cleanup leads to goroutine leaks that accumulate across test runs.
The key is making the stop mechanism observable. If your code uses a stop channel or context cancellation, test that calling the stop function actually terminates the goroutine:
For code that uses timers or tickers, inject a test clock rather than waiting for real time. Our pkg/clock package provides this. If the background goroutine runs every minute, you don't want your test to take a minute.
When testing cleanup, verify that resources are actually released. A goroutine that ignores its stop signal is a leak that will eventually cause problems in production.
Bazel Configuration
A typical unit test BUILD.bazel entry looks like this:
The size = "small" declaration tells Bazel this test should complete in under 60 seconds and doesn't need external resources. If your test needs more time or resources, you probably need an integration test instead.