How to Run Tests in Go (go test Command)

Run Go tests using the go test command with optional flags for verbose output or specific test selection.

The moment you realize your code breaks

You just refactored a date parser. It handles standard formats perfectly. You run the program, and it works. Three days later, a user submits a timestamp with a trailing space. The parser panics in production. You could have caught it by running a few inputs through the function, but manual testing scales poorly. Go solves this with a built-in test runner that requires zero external dependencies. You write functions, name them correctly, and the toolchain handles compilation, execution, and reporting.

How Go finds and runs your tests

Go does not require a third-party testing framework. The standard library ships with the testing package, and the go test command knows exactly how to use it. The discovery process follows two strict naming conventions. Test files must end in _test.go. Test functions must start with the word Test followed by a capital letter. The compiler ignores these files during normal builds, which keeps production binaries lean. When you invoke go test, the toolchain compiles a temporary binary that imports your package alongside the testing package. It scans for exported functions matching the Test prefix, wires them into a test runner, and executes them sequentially.

The testing.T type acts as the communication channel between your test code and the runner. It tracks pass/fail state, captures log output, and handles cleanup. Think of it as a referee that watches your assertions and stops the match if you break a rule. The runner also respects the t.Parallel() method, which allows CPU-bound tests to execute concurrently while keeping I/O-bound tests sequential by default.

The smallest possible test

Here is the smallest valid test file that demonstrates the naming convention and the testing.T receiver.

package main

import "testing"

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

// TestAddTwo verifies the addition function with a single case.
func TestAddTwo(t *testing.T) {
    // call the target function with known inputs
    result := AddTwo(2, 3)
    // compare against the expected value
    if result != 5 {
        // record a failure and stop this specific test
        t.Errorf("expected 5, got %d", result)
    }
}

Run go test in the same directory. The command compiles the package, finds TestAddTwo, runs it, and prints a single line. No extra output appears unless a test fails or you request verbose mode. Add -v to see the test name printed before execution. The flag changes the runner from silent-pass to explicit-tracking.

What happens behind the scenes

The go test command does not interpret your code. It compiles it. The toolchain creates a temporary directory, copies your package files, adds a synthetic main function that calls testing.Main, and builds an executable. That synthetic main function handles flag parsing, test discovery, and result aggregation. When you pass -v, the runner prints the name of every test function before it executes. When you pass -run TestReader, the runner filters the discovered functions against a regular expression and only executes matches.

This compilation step means tests run at near-native speed. You are not paying the overhead of a virtual machine or an interpreted loop. The trade-off is that syntax errors in test files surface as compile failures rather than runtime exceptions. If you misspell a function name, the compiler rejects the program with testing: warning: no tests to run if the regex matches nothing, or undefined: TestXxx if the function signature is malformed. The runner also respects -count to repeat tests multiple times, which helps surface flaky behavior that only appears under repeated execution.

Real-world test structure

Production code rarely tests a single value. Go developers use table-driven tests to verify multiple inputs without duplicating logic. The pattern places test cases in a slice of structs, then iterates over them.

package main

import "testing"

// ParseStatus converts a numeric code to a human-readable label.
func ParseStatus(code int) int {
    switch code {
    case 200:
        return 0
    case 404:
        return 1
    default:
        return 2
    }
}

// TestParseStatus checks multiple inputs using a table-driven approach.
func TestParseStatus(t *testing.T) {
    // define cases to avoid repeating the assertion logic
    cases := []struct {
        input    int
        expected int
    }{
        {200, 0},
        {404, 1},
        {500, 2},
    }

    // iterate over each case and run a subtest
    for _, tc := range cases {
        t.Run(fmt.Sprintf("code_%d", tc.input), func(t *testing.T) {
            // capture the actual output
            got := ParseStatus(tc.input)
            // fail fast if the values diverge
            if got != tc.expected {
                t.Errorf("ParseStatus(%d) = %d, want %d", tc.input, got, tc.expected)
            }
        })
    }
}

The t.Run call creates a subtest. Subtests isolate failures so one bad case does not stop the rest of the table from running. You can target a specific subtest with the -run flag using a slash separator. Running go test -run TestParseStatus/code_200 executes only the first case. The slash tells the runner to match the parent test name, then the subtest name. This filtering becomes essential when debugging a large table without waiting for every row to execute.

Common traps and compiler messages

New Go developers often confuse t.Error and t.Fatal. Both record a failure, but t.Fatal stops the current test function immediately. Use t.Fatal when continuing would cause a panic or corrupt state. Use t.Error when you want to collect multiple failures in a single run. The runner marks the test as failed either way, but the execution path diverges.

Another frequent mistake involves the -run flag syntax. The flag expects a regular expression, not a substring. If you run go test -run TestParse, it matches TestParseStatus, TestParseJSON, and TestParseInt. If you want an exact match, anchor the pattern with ^ and $. Running go test -run ^TestParseStatus$ guarantees you only hit that one function. The runner prints testing: warning: no tests to run when your regex does not match any discovered function. This is not a syntax error. It means the runner compiled successfully but found zero targets.

Table-driven tests often suffer from shadowing the loop variable. In older Go versions, capturing tc inside the closure would reference the same memory address for every iteration. Go 1.22 fixed this by making loop variables implicitly per-iteration, but the compiler still warns with loop variable tc captured by func literal if you are on an older version or use a linter. The t.Run pattern avoids the issue entirely because it creates a new scope for each subtest.

You will also see imported and not used if you forget to remove fmt after switching to t.Errorf. The compiler enforces strict import hygiene. Convention aside: Go tests do not use assertion libraries like assert.Equal. The standard library approach relies on if got != want { t.Error(...) }. It feels verbose compared to third-party frameworks, but it keeps the test output readable and avoids hidden magic. The community accepts the boilerplate because it makes the failure condition explicit.

Tests that allocate resources should clean them up. Use t.Cleanup(func() { ... }) to register a deferred action that runs after the test finishes, regardless of pass or fail. The runner executes cleanup functions in reverse order of registration. This prevents file handles, database connections, or temporary directories from leaking between test runs.

When to use go test versus other approaches

Use go test when you need fast, native-speed unit or integration tests that run alongside your build pipeline. Use go test -race when you suspect data races in concurrent code and need the compiler to inject synchronization checks. Use go test -cover when you want a quick percentage of code paths exercised by your test suite. Use external mock generators like gomock when your function depends on complex interfaces that are expensive to instantiate. Use plain sequential code when you are prototyping and do not yet need automated verification.

The runner handles everything from compilation to reporting. Trust the toolchain. Write table-driven tests. Anchor your regex. Clean up your resources.

Where to go next