Table-Driven Tests and Test Helpers in Go
Write clear, maintainable Go tests using table-driven patterns, test helpers, subtests, and cleanup functions.
What you'll learn
- ✓How to structure table-driven tests for readability and coverage
- ✓Writing test helpers with t.Helper() for clean failure messages
- ✓Using subtests, parallel execution, and cleanup functions
Prerequisites
- •Basic Go knowledge
- •Familiarity with the testing package
Table-driven tests are the Go community’s standard pattern for writing tests. They reduce duplication, make it easy to add cases, and produce clear failure messages. This guide covers the pattern in depth, along with helpers and techniques for production-quality tests.
The Basic Table-Driven Pattern
Instead of writing separate test functions for each case, define a slice of test cases and loop over them.
func Add(a, b int) int {
return a + b
}
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{name: "positive numbers", a: 2, b: 3, expected: 5},
{name: "negative numbers", a: -1, b: -2, expected: -3},
{name: "mixed signs", a: -1, b: 5, expected: 4},
{name: "zeros", a: 0, b: 0, expected: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.expected)
}
})
}
}
Running go test -v produces:
=== RUN TestAdd
=== RUN TestAdd/positive_numbers
=== RUN TestAdd/negative_numbers
=== RUN TestAdd/mixed_signs
=== RUN TestAdd/zeros
--- PASS: TestAdd (0.00s)
Testing Errors
Include error cases in the same table. Use a wantErr field or check for a specific error.
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
}
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{name: "valid division", a: 10, b: 2, want: 5},
{name: "decimal result", a: 7, b: 2, want: 3.5},
{name: "divide by zero", a: 5, b: 0, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("Divide(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
Checking Specific Errors
When you need to verify the exact error, add an errIs field.
tests := []struct {
name string
input string
want *User
errIs error
}{
{name: "not found", input: "missing-id", errIs: ErrNotFound},
{name: "valid user", input: "u-123", want: &User{ID: "u-123", Name: "Alice"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetUser(ctx, tt.input)
if tt.errIs != nil {
if !errors.Is(err, tt.errIs) {
t.Fatalf("expected error %v, got %v", tt.errIs, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.ID != tt.want.ID {
t.Errorf("got ID %s, want %s", got.ID, tt.want.ID)
}
})
}
Test Helpers with t.Helper()
Extract repeated setup or assertion logic into helper functions. Call t.Helper() so that failure messages point to the caller, not the helper.
func assertStatusCode(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("status code = %d, want %d", got, want)
}
}
func assertJSONBody(t *testing.T, body io.Reader, target interface{}) {
t.Helper()
if err := json.NewDecoder(body).Decode(target); err != nil {
t.Fatalf("failed to decode JSON body: %v", err)
}
}
Without t.Helper(), the test failure would report the line inside assertStatusCode. With it, the failure correctly points to the test that called the helper.
Setup Helpers
Create helpers that return cleanup functions for common setup.
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open test db: %v", err)
}
_, err = db.Exec(`CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT)`)
if err != nil {
t.Fatalf("create table: %v", err)
}
t.Cleanup(func() {
db.Close()
})
return db
}
func TestUserRepository(t *testing.T) {
db := setupTestDB(t)
repo := NewUserRepository(db)
// db is automatically closed when the test finishes
user, err := repo.Create(ctx, "Alice", "alice@example.com")
if err != nil {
t.Fatalf("create user: %v", err)
}
if user.Name != "Alice" {
t.Errorf("name = %s, want Alice", user.Name)
}
}
Parallel Tests
Run subtests in parallel when they are independent.
func TestAPIEndpoints(t *testing.T) {
srv := setupTestServer(t)
tests := []struct {
name string
method string
path string
wantStatus int
}{
{name: "get users", method: "GET", path: "/users", wantStatus: 200},
{name: "health check", method: "GET", path: "/health", wantStatus: 200},
{name: "not found", method: "GET", path: "/missing", wantStatus: 404},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tt.method, tt.path, nil)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
assertStatusCode(t, rec.Code, tt.wantStatus)
})
}
}
Important: when using t.Parallel() in a loop, the test case variable tt is already captured correctly by the for range in Go 1.22+. In older versions, you must shadow it: tt := tt.
Testing HTTP Handlers
Use httptest.NewRecorder and httptest.NewRequest to test handlers without a real server.
func TestCreateUserHandler(t *testing.T) {
tests := []struct {
name string
body string
wantStatus int
wantBody string
}{
{
name: "valid user",
body: `{"name":"Alice","email":"alice@example.com"}`,
wantStatus: http.StatusCreated,
},
{
name: "missing email",
body: `{"name":"Alice"}`,
wantStatus: http.StatusBadRequest,
wantBody: "email is required",
},
{
name: "invalid json",
body: `{invalid`,
wantStatus: http.StatusBadRequest,
},
}
handler := NewUserHandler(mockRepo)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/users", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.Create(rec, req)
assertStatusCode(t, rec.Code, tt.wantStatus)
if tt.wantBody != "" && !strings.Contains(rec.Body.String(), tt.wantBody) {
t.Errorf("body = %q, want substring %q", rec.Body.String(), tt.wantBody)
}
})
}
}
Custom Comparison with cmp
For complex structs, use google/go-cmp instead of writing field-by-field comparisons.
import "github.com/google/go-cmp/cmp"
func TestGetUser(t *testing.T) {
got, err := repo.GetUser(ctx, "u-123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := &User{
ID: "u-123",
Name: "Alice",
Email: "alice@example.com",
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetUser() mismatch (-want +got):\n%s", diff)
}
}
The diff output shows exactly which fields differ:
GetUser() mismatch (-want +got):
&User{
ID: "u-123",
- Name: "Alice",
+ Name: "Bob",
Email: "alice@example.com",
}
TestMain for Global Setup
Use TestMain when you need one-time setup for the entire test package.
var testDB *sql.DB
func TestMain(m *testing.M) {
var err error
testDB, err = sql.Open("sqlite3", ":memory:")
if err != nil {
log.Fatalf("open test db: %v", err)
}
code := m.Run()
testDB.Close()
os.Exit(code)
}
Tips for Good Table-Driven Tests
- Name every test case. The name appears in output when a test fails.
- Keep test cases declarative. Put logic in the test loop, not in the table entries.
- Test edge cases. Add entries for empty inputs, nil values, and boundary conditions.
- Use t.Fatal for setup errors, t.Error for assertion failures. Fatal stops the subtest. Error lets it continue to check more things.
- Group related tests. One
TestXxxfunction per function-under-test, with subtests for each scenario.
Summary
- Table-driven tests reduce duplication and make adding cases trivial.
- Use
t.Runfor named subtests that produce clear output. - Mark
t.Helper()on assertion and setup helpers for accurate failure locations. - Use
t.Cleanupfor automatic teardown. - Use
t.Parallel()for independent subtests. - Prefer
go-cmpfor complex struct comparison. - Keep test data declarative in the table and logic in the loop.
Related articles
- Go Dependency Injection Patterns in Go
Learn practical dependency injection in Go using interfaces, constructor injection, and functional options without heavy frameworks.
- Go Go Fuzz Testing Tutorial
Write fuzz tests in Go to discover edge cases and bugs automatically. Learn the fuzzing API, corpus management, and fixing found crashes.
- Go Error Wrapping Strategies in Go
Master Go error wrapping with fmt.Errorf %w, errors.Is, errors.As, and custom error types for clear, debuggable error chains.
- Go Testing Strategies in Go
Write effective Go tests — table-driven tests, subtests, test helpers, mocking with interfaces, benchmarks, and integration testing patterns.