Go Unit Testing: A Complete Guide to go test

Go Unit Testing: A Complete Guide to go test

Go ships with a built-in testing package that requires no third-party dependencies. The go test command finds and runs tests automatically. You don't need to configure a test runner, register test files, or install plugins. This built-in simplicity is one of Go's strongest features for developer experience.

The Testing Package

Every Go test file ends in _test.go. The testing package is testing. Test functions must start with Test:

package calculator

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

Run all tests:

go test ./...

Run tests in a specific package:

go test ./calculator/...

Run with verbose output:

go test -v ./...

t.Errorf vs t.Fatalf

The testing.T type provides two main assertion patterns:

// t.Errorf — marks test as failed but continues running
t.Errorf("got %v, want %v", got, want)

// t.Fatalf — marks test as failed and stops immediately
t.Fatalf("Setup failed: %v", err)

// t.Error / t.Fatal — same as above but without formatting
t.Error("something went wrong")
t.Fatal("cannot continue")

Use t.Fatalf when further test steps would panic or produce meaningless output if the current assertion fails. Use t.Errorf when you want to collect all failures in one run.

Common Assertion Pattern

Go doesn't have a built-in assertion library (the standard testing package intentionally omits one). Most Go developers write their own helper or use testify. The idiomatic standard library pattern:

func TestDivide(t *testing.T) {
    got, err := Divide(10, 2)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got != 5 {
        t.Errorf("Divide(10, 2) = %v; want 5", got)
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Error("expected error for division by zero, got nil")
    }
}

For cleaner assertions, use the testify library — covered in a separate post.

Subtests with t.Run

t.Run creates named sub-tests. This is the standard way to group related test cases:

func TestAdd(t *testing.T) {
    t.Run("positive numbers", func(t *testing.T) {
        got := Add(2, 3)
        if got != 5 {
            t.Errorf("got %d, want 5", got)
        }
    })

    t.Run("with zero", func(t *testing.T) {
        got := Add(0, 5)
        if got != 5 {
            t.Errorf("got %d, want 5", got)
        }
    })

    t.Run("negative numbers", func(t *testing.T) {
        got := Add(-1, -2)
        if got != -3 {
            t.Errorf("got %d, want -3", got)
        }
    })
}

Run a specific subtest:

go test -run TestAdd/positive_numbers ./...

Sub-tests are the building block for table-driven tests (covered in detail separately).

Table-Driven Tests (Overview)

The idiomatic Go pattern for testing multiple cases:

func TestMultiply(t *testing.T) {
    tests := []struct {
        name string
        a, b int
        want int
    }{
        {"positive", 3, 4, 12},
        {"zero", 0, 5, 0},
        {"negative", -2, 3, -6},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got := Multiply(tc.a, tc.b)
            if got != tc.want {
                t.Errorf("Multiply(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
            }
        })
    }
}

This pattern keeps test data separate from test logic and makes adding new cases trivial.

Setup and Teardown with TestMain

For package-level setup and teardown, use TestMain:

func TestMain(m *testing.M) {
    // Setup: run before all tests in the package
    db := setupTestDatabase()

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

    // Teardown: run after all tests
    db.Close()
    os.Exit(code)
}

TestMain gives you control over the entire test run for a package. Use it for expensive setup that should happen once (database connections, server startup).

For per-test setup:

func setupTest(t *testing.T) func() {
    // setup
    t.Log("Setting up")
    return func() {
        // teardown
        t.Log("Tearing down")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTest(t)
    defer teardown()

    // test code
}

The t.Cleanup function (Go 1.14+) is a cleaner alternative:

func TestSomething(t *testing.T) {
    db := connectDB()
    t.Cleanup(func() {
        db.Close()
    })
    // test code
}

Parallel Tests

Run tests concurrently with t.Parallel():

func TestSlowOperation(t *testing.T) {
    t.Parallel()
    // this test runs in parallel with other parallel tests
    result := SlowOperation()
    if result != expected {
        t.Errorf("got %v, want %v", result, expected)
    }
}

Parallel tests share the test binary's goroutine budget. Use -parallel to control maximum concurrency:

go test -parallel 4 ./...

Caution with shared state: Parallel tests must not share mutable state. Be careful with global variables, database records, and file system operations.

For table-driven parallel subtests, capture the loop variable:

for _, tc := range tests {
    tc := tc  // capture range variable
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()
        // use tc here
    })
}

(In Go 1.22+, loop variables are per-iteration, so this capture is no longer necessary.)

Testing Error Wrapping

With fmt.Errorf("...: %w", err), check errors with errors.Is and errors.As:

func TestFetchUser_NotFound(t *testing.T) {
    _, err := FetchUser(99999)
    if !errors.Is(err, ErrNotFound) {
        t.Errorf("expected ErrNotFound, got %v", err)
    }
}

Test Coverage

Run tests with coverage:

go test -cover ./...

Generate an HTML coverage report:

go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

Get coverage by function:

go tool cover -func=coverage.out

Aim for high coverage on business logic. Don't chase 100% — some code (like main functions and error paths that can't happen) is not worth testing.

Short Mode

Use testing.Short() to skip slow tests in short mode:

func TestSlowIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test in short mode")
    }
    // slow test
}

Run in short mode:

go test -short ./...

This keeps fast unit tests separate from slow integration tests without requiring build tags.

Test Helpers

Helper functions used only in tests live in _test.go files. A common pattern:

func assertEqual(t *testing.T, got, want interface{}) {
    t.Helper()  // marks this as a helper; line numbers point to caller
    if got != want {
        t.Errorf("got %v, want %v", got, want)
    }
}

t.Helper() is important — without it, test failure lines point to the helper function, not the call site.

Running Specific Tests

# Run tests matching a pattern
go <span class="hljs-built_in">test -run TestAdd ./...

<span class="hljs-comment"># Run a specific subtest
go <span class="hljs-built_in">test -run TestAdd/positive_numbers ./...

<span class="hljs-comment"># List tests without running them
go <span class="hljs-built_in">test -list . ./...

File Organization

Test files sit next to the code they test:

calculator/
  calculator.go
  calculator_test.go
  advanced.go
  advanced_test.go

For integration tests that need a separate build tag:

//go:build integration

package calculator_test

Run with:

go test -tags integration ./...

This lets you exclude slow integration tests from the default go test run.

Best Practices

Test the public API. Use package foo_test (external test package) to test only exported behavior. Internal tests can use package foo for white-box testing.

Keep tests close to zero dependencies. Avoid importing half your codebase into a test. If setup is complex, the code probably needs better decomposition.

Name tests clearly. TestUserService_CreateUser_DuplicateEmail is more useful in failure output than TestCreateUser2.

Use t.Helper() in every test helper function.

Don't test unexported functions unless necessary. If a private function is complex enough to need direct tests, consider making it testable by moving it or exporting it.

Read more