Skip to content
Codeloom
Go

Table-Driven Tests and Test Helpers in Go

Write clear, maintainable Go tests using table-driven patterns, test helpers, subtests, and cleanup functions.

·7 min read · By Codeloom
Intermediate 10 min read

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

  1. Name every test case. The name appears in output when a test fails.
  2. Keep test cases declarative. Put logic in the test loop, not in the table entries.
  3. Test edge cases. Add entries for empty inputs, nil values, and boundary conditions.
  4. Use t.Fatal for setup errors, t.Error for assertion failures. Fatal stops the subtest. Error lets it continue to check more things.
  5. Group related tests. One TestXxx function per function-under-test, with subtests for each scenario.

Summary

  • Table-driven tests reduce duplication and make adding cases trivial.
  • Use t.Run for named subtests that produce clear output.
  • Mark t.Helper() on assertion and setup helpers for accurate failure locations.
  • Use t.Cleanup for automatic teardown.
  • Use t.Parallel() for independent subtests.
  • Prefer go-cmp for complex struct comparison.
  • Keep test data declarative in the table and logic in the loop.