How to Write Unit Tests in Go with testing Package

Write test functions in *_test.go files using the testing package and run them with go test.

The safety net that runs with your build

You write a function to calculate shipping costs. It works. You deploy. Two weeks later, a customer complains the cost is wrong because you forgot to handle zero-weight items. You fix the bug, but now the discount logic breaks. You need a safety net that runs automatically every time you change code, catching regressions before they reach production.

Go provides this safety net in the standard library. You do not need to install third-party testing frameworks. The testing package and the go test command are built into the toolchain. Tests live in files ending with _test.go. The compiler treats these files specially: it compiles and runs them, but excludes them from the final binary.

Tests are first-class citizens in Go. The toolchain supports them natively.

Anatomy of a test

A test is a function that starts with the prefix Test, takes a single parameter of type *testing.T, and lives in a _test.go file. The *testing.T object tracks the test state. It records failures, prints logs, and controls execution flow.

Here is the minimal structure of a test.

package mathutil

import "testing"

// Add returns the sum of two integers.
func Add(a, b int) int {
    return a + b
}

// TestAdd verifies Add returns the correct sum.
func TestAdd(t *testing.T) {
    got := Add(2, 3)
    want := 5
    if got != want {
        // t.Errorf marks the test as failed and prints the formatted message.
        t.Errorf("Add(2, 3) = %d; want %d", got, want)
    }
}

When you run go test, the tool finds all files matching *_test.go in the package. It compiles them into a test binary. The runtime scans for functions matching the pattern TestXxx(*testing.T). It calls each function. If the function returns without calling any failure method, the test passes. If t.Errorf is called, the test fails.

A common mistake is confusing t.Error and t.Errorf. The method t.Error takes a list of values. The method t.Errorf takes a format string. If you pass a format string and arguments to t.Error, the compiler rejects the program with too many arguments in call to t.Error. Use t.Errorf when you need to interpolate variables into the error message.

The testing package is built-in. You never need to install a third-party assertion library for basic checks.

Table-driven tests and subtests

Real Go code rarely writes one assertion per function. The community standard is table-driven tests. You define a slice of structs containing inputs and expected outputs. You loop over the slice and run the check for each case. This keeps code DRY and makes adding new cases trivial.

Here is a table-driven test using t.Run for subtests.

// TestAddTable demonstrates the table-driven pattern with subtests.
func TestAddTable(t *testing.T) {
    // cases holds multiple inputs and expected outputs.
    cases := []struct {
        name string
        a, b int
        want int
    }{
        {"positive", 2, 3, 5},
        {"zero", 0, 0, 0},
        {"negative", -1, -1, -2},
    }

    for _, tc := range cases {
        // t.Run creates a subtest with its own isolated *testing.T.
        t.Run(tc.name, func(t *testing.T) {
            got := Add(tc.a, tc.b)
            if got != tc.want {
                t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
            }
        })
    }
}

The t.Run method creates a subtest. The closure receives a new *testing.T object scoped to that subtest. This allows individual cases to fail independently. The output shows which subtest failed. You can also run a specific subtest using go test -run TestAddTable/positive.

Table-driven tests are the Go way. They keep code DRY and make adding cases trivial.

Helpers and stack traces

As tests grow, you extract common logic into helper functions. A helper might check equality, set up a database connection, or parse JSON. When a helper calls t.Errorf, the stack trace points to the helper, not the test case. This makes debugging harder.

The t.Helper method fixes this. Call t.Helper() at the top of any test helper function. The test runner treats the helper as transparent and reports failures at the caller's line number.

Here is a helper function with proper stack trace handling.

// assertEqual checks equality and logs a failure if values differ.
func assertEqual(t *testing.T, got, want int) {
    // t.Helper tells the runner to report failures at the caller's line.
    t.Helper()
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

// TestAddHelper uses the helper function.
func TestAddHelper(t *testing.T) {
    assertEqual(t, Add(2, 3), 5)
}

Helper functions improve debugging. Use t.Helper() so failures point to the test case, not the utility.

Pitfalls and runtime behavior

Tests in Go have specific behaviors that differ from other languages. Understanding these prevents subtle bugs.

Test execution order is randomized by default. The go test command shuffles the order of tests to catch hidden dependencies. If your test fails sometimes and passes others, it likely depends on state leaking between tests. Each test should be independent. Do not rely on global state or file system artifacts left by previous tests.

File naming matters. The test runner only scans files ending with _test.go. If you name a file test_math.go, go test ignores it. The compiler also requires the testing import. If you forget to import the package, you get undefined: testing from the compiler. If you import it but do not use it, you get imported and not used.

The method t.Fatal stops the test immediately. The method t.Error marks the test as failed but allows execution to continue. Use t.Fatal when the test cannot proceed meaningfully after a failure, such as when a setup step fails. Use t.Error when you want to report multiple issues in a single run.

Go includes a built-in race detector. Run go test -race to instrument the binary and detect data races at runtime. The detector reports concurrent access to shared memory without proper synchronization. This is cheap insurance against concurrency bugs.

The testing.Short() function returns true when the -short flag is passed. Use this to skip slow tests or tests that hit external services. This allows fast feedback loops during development while keeping comprehensive tests for CI.

// TestSlowOperation skips when -short is enabled.
func TestSlowOperation(t *testing.T) {
    // Skip this test in short mode to speed up local runs.
    if testing.Short() {
        t.Skip("skipping in short mode")
    }
    // ... slow test logic ...
}

Randomization saves you. If a test is flaky, the bug is in the test, not the compiler.

When to use what

Testing involves choices about failure reporting, structure, and execution. Pick the right tool for the scenario.

Use t.Errorf when you want to report a failure but let the test continue running to find more issues.

Use t.Fatalf when the test cannot proceed meaningfully after a failure, such as when a setup step fails or a required resource is missing.

Use t.Run when you have multiple test cases and want isolated subtests with individual pass/fail reporting.

Use table-driven tests when you need to verify the same logic against many different inputs.

Use go test -race when you want to detect data races in concurrent code.

Use testing.Short() when you want to skip slow tests or external service calls during quick local runs.

Use go test -v when you need verbose output to see which tests ran and their logs.

Use go test -run Pattern when you want to run only specific tests matching a regex.

Pick the right tool for the failure mode. Fail fast or fail loud, but always fail clearly.

Where to go next