Skip to content

Unit Testing Intermediate

Introduction

Go has a first-class testing framework built into the language and toolchain — no external test runner needed. The testing package, combined with the go test command, provides everything from basic assertions to subtests, benchmarks, and coverage analysis. The table-driven test pattern is the idiomatic Go way to write tests and shows up in virtually every Go codebase and interview question about testing.


Syntax & Usage

Basic Test Function

Test files end in _test.go and test functions start with Test followed by an uppercase letter.

// math.go
package math

func Add(a, b int) int {
    return a + b
}
// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d, want 5", result)
    }
}
go test ./...          # run all tests recursively
go test -v ./...       # verbose output
go test -run TestAdd   # run specific test by regex

Table-Driven Tests (THE Go Pattern)

This is the most important testing pattern in Go. It separates test data from test logic.

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -1, -2, -3},
        {"zero", 0, 0, 0},
        {"mixed signs", -5, 10, 5},
    }

    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)
            }
        })
    }
}

Output with -v:

=== RUN   TestAdd
=== RUN   TestAdd/positive_numbers
=== RUN   TestAdd/negative_numbers
=== RUN   TestAdd/zero
=== RUN   TestAdd/mixed_signs
--- PASS: TestAdd (0.00s)

Subtests with t.Run

t.Run creates named subtests that can be filtered and run independently.

func TestUser(t *testing.T) {
    t.Run("creation", func(t *testing.T) {
        u := NewUser("alice", "alice@example.com")
        if u.Name != "alice" {
            t.Errorf("expected name alice, got %s", u.Name)
        }
    })

    t.Run("validation", func(t *testing.T) {
        _, err := NewUser("", "")
        if err == nil {
            t.Error("expected error for empty fields")
        }
    })
}
go test -run TestUser/creation   # run only the creation subtest

t.Helper()

Marks a function as a test helper so that when it reports a failure, the file and line number point to the caller, not the helper itself.

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // without this, errors point to this line
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    assertEqual(t, Add(2, 3), 5)   // error would point here
    assertEqual(t, Add(0, 0), 0)
}

t.Parallel()

Runs subtests concurrently for faster execution.

func TestParallel(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  string
    }{
        {"uppercase", "hello", "HELLO"},
        {"already upper", "HELLO", "HELLO"},
        {"mixed", "HeLLo", "HELLO"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // runs this subtest in parallel
            got := strings.ToUpper(tt.input)
            if got != tt.want {
                t.Errorf("ToUpper(%q) = %q, want %q", tt.input, got, tt.want)
            }
        })
    }
}

Parallel Loop Variable Capture

In Go versions before 1.22, using t.Parallel() with a range loop required capturing the loop variable:

for _, tt := range tests {
    tt := tt // capture! (not needed in Go 1.22+)
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // use tt safely
    })
}
Go 1.22+ changed loop variable scoping, making this unnecessary.


t.Cleanup

Registers a cleanup function that runs after the test (and its subtests) complete. Runs in LIFO order.

func TestWithDB(t *testing.T) {
    db := setupTestDB(t)
    t.Cleanup(func() {
        db.Close() // always runs, even if test fails or panics
    })

    // ... test using db ...
}

func setupTestDB(t *testing.T) *sql.DB {
    t.Helper()
    db, err := sql.Open("postgres", testDSN)
    if err != nil {
        t.Fatalf("failed to open db: %v", err)
    }
    t.Cleanup(func() { db.Exec("DELETE FROM test_data") })
    return db
}

testing.Short() — Skipping Slow Tests

func TestIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test in short mode")
    }
    // expensive test that hits a real database or network
}
go test -short ./...   # skips slow tests
go test ./...          # runs everything

Test Fixtures and testdata Directory

The testdata directory is special — go build ignores it, but go test can read from it.

func TestParseConfig(t *testing.T) {
    // testdata/ is relative to the test file's package directory
    data, err := os.ReadFile("testdata/valid_config.json")
    if err != nil {
        t.Fatalf("reading fixture: %v", err)
    }

    cfg, err := ParseConfig(data)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if cfg.Port != 8080 {
        t.Errorf("expected port 8080, got %d", cfg.Port)
    }
}

TestMain — Setup and Teardown for the Whole Package

func TestMain(m *testing.M) {
    // Global setup
    pool, err := setupDockerDB()
    if err != nil {
        log.Fatalf("could not start db: %v", err)
    }

    code := m.Run() // run all tests

    // Global teardown
    pool.Purge()
    os.Exit(code)
}

TestMain Replaces Default

When you define TestMain, it replaces the default test runner. You must call m.Run() and os.Exit(code) yourself, or no tests will execute.


Testing Private vs Exported Functions

// Package: mypackage

// mypackage/handler.go
package mypackage

func PublicHandler() string { return format("hello") }
func format(s string) string { return "[" + s + "]" }
// Same package — can test unexported functions
// mypackage/handler_test.go
package mypackage

func TestFormat(t *testing.T) {
    got := format("test") // can access unexported function
    if got != "[test]" {
        t.Errorf("got %s, want [test]", got)
    }
}
// External test package — tests only the public API
// mypackage/handler_external_test.go
package mypackage_test

import "mypackage"

func TestPublicHandler(t *testing.T) {
    got := mypackage.PublicHandler()
    // cannot call mypackage.format() here
    if got != "[hello]" {
        t.Errorf("got %s, want [hello]", got)
    }
}
Package File Access
package mypackage xxx_test.go All functions (exported + unexported)
package mypackage_test xxx_test.go Only exported functions

Code Coverage

go test -cover ./...                          # show coverage percentage
go test -coverprofile=coverage.out ./...      # generate coverage file
go tool cover -html=coverage.out              # open HTML coverage report
go tool cover -func=coverage.out              # per-function coverage
// Use build tags or test flags for coverage-aware testing
// Example output:
// ok   mypackage  0.003s  coverage: 85.2% of statements

Quick Reference

Concept Syntax / Command Notes
Test function func TestXxx(t *testing.T) Must start with Test + uppercase
Run tests go test ./... Recursive, all packages
Verbose go test -v Shows each test name and status
Run specific go test -run "TestUser/creation" Regex filter
Subtest t.Run("name", func(t *testing.T) {...}) Named, filterable
Table-driven for _, tt := range tests { t.Run(...) } Idiomatic Go pattern
Mark helper t.Helper() Correct line numbers in errors
Parallel t.Parallel() Concurrent subtests
Skip t.Skip("reason") Conditionally skip
Short mode go test -short testing.Short() returns true
Cleanup t.Cleanup(func() {...}) Runs after test, LIFO
Fatal t.Fatalf("msg: %v", err) Stops the test immediately
Error t.Errorf("got %d, want %d", g, w) Records failure, continues
Coverage go test -cover Statement coverage percentage
Test fixture testdata/ directory Ignored by go build
Package-level setup func TestMain(m *testing.M) Must call m.Run()

Best Practices

  1. Use table-driven tests for any function with multiple input/output scenarios — it's the expected pattern in Go codebases
  2. Name subtests descriptivelyt.Run("empty_input_returns_error", ...) reads well in output and filters
  3. Call t.Helper() in every test helper function — keeps error reporting accurate
  4. Use t.Cleanup() over defer** in helper functions — cleanup runs after the calling test completes, not when the helper returns
  5. Test behavior, not implementation — test the public API (package foo_test) unless you specifically need to test internals
  6. Keep tests close to codefoo.go and foo_test.go live in the same directory
  7. Use testing.Short() to separate fast unit tests from slow integration tests
  8. Don't use assertion libraries unless the team agrees — the Go community prefers plain if checks with t.Errorf
  9. Use testdata/ for fixtures — it's the conventional location and ignored by the build tool

Common Pitfalls

Forgetting t.Helper()

func assertOK(t *testing.T, err error) {
    // without t.Helper(), error points to THIS line
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}
Always add t.Helper() as the first line in test helper functions.

Using t.Fatal in Goroutines

func TestAsync(t *testing.T) {
    go func() {
        t.Fatal("this will panic!") // WRONG: t.Fatal from non-test goroutine
    }()
}
t.Fatal, t.Fatalf, and t.FailNow must only be called from the goroutine running the test function. Use channels or t.Error (which is safe) to report failures from goroutines.

Loop Variable with t.Parallel (Pre-Go 1.22)

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // BUG (pre-1.22): tt points to last element for all goroutines
        check(tt.input) // all tests use same tt value
    })
}
In Go < 1.22, add tt := tt before t.Run. Go 1.22+ fixes loop variable scoping.

Forgetting m.Run() in TestMain

func TestMain(m *testing.M) {
    setup()
    // WRONG: forgot to run tests!
    teardown()
}
Always call code := m.Run() and os.Exit(code).

Not Checking Error in Test Setup

func TestFoo(t *testing.T) {
    f, _ := os.Open("testdata/fixture.json") // ignoring error!
    // f is nil, test panics with confusing message
}
Use t.Fatalf for setup errors to get clear failure messages.


Performance Considerations

Scenario Recommendation
Many independent subtests Use t.Parallel() to run concurrently
Expensive setup (DB, containers) Use TestMain for one-time setup/teardown
Slow integration tests Guard with testing.Short() and -short flag
Test binary caching go test caches passing results; use -count=1 to force rerun
Large table-driven tests Tests are data — generate edge cases programmatically
Coverage overhead -cover adds ~5% overhead; -covermode=atomic is slower but goroutine-safe
Benchmark comparison Use go test -bench=. -benchmem for allocation-aware benchmarks

Interview Tips

Interview Tip

"What is a table-driven test?" It's Go's idiomatic pattern where test cases are defined as a slice of structs, and a single loop iterates over them calling t.Run for each. It separates test data from test logic, makes adding cases trivial, and produces clear output.

Interview Tip

"What's the difference between t.Error and t.Fatal?" t.Error (and t.Errorf) records the failure but continues the test. t.Fatal (and t.Fatalf) records the failure and stops the test immediately by calling runtime.Goexit(). Use t.Fatal for setup errors where continuing is pointless.

Interview Tip

"How do you test unexported functions?" Put the test file in the same package (package foo, not package foo_test). For black-box testing of the public API, use the external test package (package foo_test). Both approaches are valid and serve different purposes.

Interview Tip

"Does Go have a built-in assertion library?" No. Go's philosophy is that if statements with t.Errorf are clear and sufficient. The standard library itself uses this style. Third-party libraries like testify exist but are not idiomatic for the stdlib.

Interview Tip

"How do you skip slow tests?" Use testing.Short() at the top of slow tests: if testing.Short() { t.Skip("skipping in short mode") }. Run with go test -short to skip them. This is how you separate fast unit tests from slow integration tests.


Key Takeaways

  • Test files end in _test.go; test functions start with TestXxx(t *testing.T)
  • Table-driven tests are the idiomatic Go pattern — use them everywhere
  • t.Run("name", ...) creates subtests that are filterable and independently runnable
  • t.Helper() is essential in helper functions for accurate error reporting
  • t.Parallel() runs subtests concurrently; watch for loop variable capture in pre-1.22 Go
  • t.Cleanup() registers teardown functions that run after the test completes (LIFO)
  • TestMain(m *testing.M) provides package-level setup/teardown — must call m.Run()
  • Same-package tests access unexported functions; external test package (_test) tests only the public API
  • Use testing.Short() and -short flag to separate fast and slow tests
  • go test -cover shows coverage; -coverprofile generates detailed reports