Writing Tests with the testing Package

Go ships with a complete unit-testing framework built directly into the standard library, so you never need a third-party dependency just to check that your code works. The testing package, paired with the go test command, lets you write plain Go functions that verify behavior, and the toolchain automatically finds, compiles, and runs them. Because tests are ordinary Go code sitting next to the code they exercise, they catch regressions the moment you introduce them and double as living documentation of how your functions are meant to behave.

Overview: How Go’s Testing System Works

A Go test lives in a file whose name ends in _test.go, placed in the same directory (and usually the same package) as the code it tests. The go test command looks at every _test.go file in a package, builds a temporary test binary that links your regular source files together with the test files, and then runs any function it finds with the exact signature func TestXxx(t *testing.T), where Xxx starts with an uppercase letter. That capitalized name is not a style preference — it is how go test tells a real test apart from an ordinary helper function.

Each test function receives a pointer to a testing.T value, which is the toolkit you use to report success or failure. The two workhorse methods are t.Errorf (format a failure message, mark the test as failed, and keep running the rest of the function) and t.Fatalf (format a failure message, mark the test as failed, and stop that test function immediately). There are non-formatted twins, t.Error and t.Fatal, for plain string messages, plus t.Log/t.Logf for diagnostic output that only appears when a test fails or when you pass -v. Crucially, none of these methods work like exceptions: a test that never calls any of them simply passes, and calling t.Errorf does not unwind the call stack the way a thrown exception would — execution continues in that function until it returns or you explicitly call a Fatal variant.

Most real-world Go tests use the table-driven pattern: a slice of small structs, each holding an input and the expected result, looped over with t.Run to spin up a named subtest per case. Subtests get their own pass/fail line in the output, can be filtered individually with go test -run TestName/subtest_name, and can opt into running concurrently with t.Parallel(). To run any of this, your code needs to sit inside a Go module (created once with go mod init) — go test, like go build, resolves packages relative to the module’s go.mod file rather than the legacy GOPATH layout.

Syntax

Every test function follows the same shape: a name starting with Test, a single parameter of type *testing.T, and no return value.

func TestXxx(t *testing.T) {
	// 1. arrange: set up inputs and the expected result
	got := SomeFunction(input)
	want := expected

	// 2. act + assert: compare what you got to what you wanted
	if got != want {
		t.Errorf("SomeFunction(%v) = %v; want %v", input, got, want)
	}
}
Part Meaning
Test prefix Required; go test only discovers functions whose name literally starts with the uppercase word Test.
Xxx A descriptive name for what’s being tested, e.g. Add or IsPalindrome_EmptyString. Must not start with a lowercase letter.
t *testing.T The handle used to report failures, log diagnostics, run subtests, and mark parallel execution.
Function body Ordinary Go code: call the function under test, compare the result to what you expect, and report a failure if it doesn’t match.

Examples

Example 1: Testing a Simple Function

Here is a small function and the program that demonstrates it. This is the "production code" you would normally keep in its own file inside a module.

package main

import "fmt"

func Add(a, b int) int {
	return a + b
}

func main() {
	fmt.Println(Add(2, 3))
}

Output:

5

Now put a file named math_test.go next to it, in the same package, containing a test function:

package main

import "testing"

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

Running go test -v in that directory compiles both files together into a test binary and executes TestAdd:

$ go test -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok  	example.com/mymodule	0.002s

Because got equals want, t.Errorf never runs, so the test reports PASS. If you changed Add to return the wrong value, the same command would print --- FAIL: TestAdd along with the formatted message from t.Errorf, telling you exactly what was expected versus what was received.

Example 2: Table-Driven Tests with Subtests

Real functions usually need more than one test case. Instead of writing a separate TestXxx function for every input, Go’s idiomatic approach is a table of cases run through a single loop.

package main

import (
	"fmt"
	"strings"
)

func IsPalindrome(s string) bool {
	s = strings.ToLower(s)
	for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
		if s[i] != s[j] {
			return false
		}
	}
	return true
}

func main() {
	fmt.Println(IsPalindrome("Level"))
	fmt.Println(IsPalindrome("Hello"))
}

Output:

true
false

IsPalindrome lower-cases the string, then walks inward from both ends comparing characters. Here is a table-driven test for it:

package main

import "testing"

func TestIsPalindrome(t *testing.T) {
	tests := []struct {
		name  string
		input string
		want  bool
	}{
		{"simple palindrome", "level", true},
		{"mixed case palindrome", "Level", true},
		{"not a palindrome", "hello", false},
		{"empty string", "", true},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			got := IsPalindrome(tt.input)
			if got != tt.want {
				t.Errorf("IsPalindrome(%q) = %v; want %v", tt.input, got, tt.want)
			}
		})
	}
}

Each row of the tests slice becomes its own subtest with t.Run, so the verbose output shows every case individually:

$ go test -v -run TestIsPalindrome
=== RUN   TestIsPalindrome
=== RUN   TestIsPalindrome/simple_palindrome
=== RUN   TestIsPalindrome/mixed_case_palindrome
=== RUN   TestIsPalindrome/not_a_palindrome
=== RUN   TestIsPalindrome/empty_string
--- PASS: TestIsPalindrome (0.00s)
    --- PASS: TestIsPalindrome/simple_palindrome (0.00s)
    --- PASS: TestIsPalindrome/mixed_case_palindrome (0.00s)
    --- PASS: TestIsPalindrome/not_a_palindrome (0.00s)
    --- PASS: TestIsPalindrome/empty_string (0.00s)
PASS
ok  	example.com/mymodule	0.002s

Notice go test replaces spaces in subtest names with underscores, and that you can re-run just one case with go test -run "TestIsPalindrome/empty_string". Adding a new case later means adding one more line to the table, not writing a whole new function.

Example 3: Testing Functions That Return Errors

Go functions commonly return an error alongside their result, and tests need to check both the value and whether an error was expected.

package main

import (
	"errors"
	"fmt"
)

func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

func main() {
	result, err := Divide(10, 2)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(result)
}

Output:

5

The test file below shows both flavors of failure reporting: t.Fatalf for a setup problem that makes the rest of the test pointless, and t.Errorf for a plain value mismatch.

package main

import "testing"

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

func TestDivideByZero(t *testing.T) {
	_, err := Divide(10, 0)
	if err == nil {
		t.Fatal("Divide(10, 0) expected an error, got nil")
	}
}

In TestDivide, if Divide unexpectedly returned an error, checking result afterward would be meaningless, so t.Fatalf stops the test right there. In TestDivideByZero, the whole point of the test is that an error should be present, so a nil error is itself the failure.

How go test Works Step by Step

Understanding the sequence behind a single go test invocation makes its output much less mysterious:

  • 1. go test scans the target package’s directory for files ending in _test.go.
  • 2. It compiles a temporary test binary that links your normal .go files with the test files, plus a small generated main that drives the test run — this is why a syntax error in a test file fails the whole build, just like in regular code.
  • 3. It inspects the compiled binary for functions matching func TestXxx(*testing.T) (and, if present, BenchmarkXxx(*testing.B) and ExampleXxx()).
  • 4. Each TestXxx runs, in the order it appears in the source, with a fresh *testing.T. If the function calls t.Run, each subtest gets its own *testing.T too.
  • 5. Calling t.Errorf records a failure on that *testing.T and lets the function keep executing to the end.
  • 6. Calling t.Fatalf records a failure and then stops that goroutine’s test function immediately (internally, via runtime.Goexit), though any deferred calls still run.
  • 7. After every test finishes, go test prints a PASS/FAIL line per test (with -v) and a final summary line — ok plus the elapsed time if everything passed, or FAIL if anything didn’t.
  • 8. The process exits with status 0 on success or 1 on any failure, which is exactly what continuous-integration systems check to decide whether a build is good.

Common Mistakes

Mistake 1: A Test Function Name That Doesn’t Start With an Uppercase "Test"

This code compiles perfectly and looks like a test, but go test silently never runs it, because the function name does not literally begin with the capitalized word Test.

package main

import "testing"

func testAdd(t *testing.T) {
	if Add(2, 3) != 5 {
		t.Error("Add(2, 3) should be 5")
	}
}

go test would report ok with zero tests run and no warning at all — a dangerous false sense of safety. The fix is simply to capitalize the prefix:

package main

import "testing"

func TestAdd(t *testing.T) {
	if Add(2, 3) != 5 {
		t.Error("Add(2, 3) should be 5")
	}
}

Mistake 2: Capturing the Loop Variable in Table-Driven Subtests

When a subtest calls t.Parallel(), it pauses at that point and lets the parent test function continue to its next loop iteration before the parallel subtests actually execute. On Go versions before 1.22, all the subtests’ closures shared the same loop variable, so by the time they ran in parallel, every one of them saw the value left over from the final iteration:

package main

import "testing"

func TestIsPalindromeBad(t *testing.T) {
	tests := []struct {
		input string
		want  bool
	}{
		{"level", true},
		{"hello", false},
	}

	for _, tt := range tests {
		t.Run(tt.input, func(t *testing.T) {
			t.Parallel()
			if IsPalindrome(tt.input) != tt.want {
				t.Errorf("IsPalindrome(%q) != %v", tt.input, tt.want)
			}
		})
	}
}

On older toolchains, every parallel subtest above would end up checking "hello" instead of its own case, producing confusing, hard-to-reproduce failures. Go 1.22 changed for loops so each iteration gets its own copy of the loop variable, which fixes this automatically — but explicitly shadowing the variable inside the loop body is still the clearer, more portable pattern and costs nothing:

package main

import "testing"

func TestIsPalindromeGood(t *testing.T) {
	tests := []struct {
		input string
		want  bool
	}{
		{"level", true},
		{"hello", false},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.input, func(t *testing.T) {
			t.Parallel()
			if IsPalindrome(tt.input) != tt.want {
				t.Errorf("IsPalindrome(%q) != %v", tt.input, tt.want)
			}
		})
	}
}

Best Practices

  • Keep test files named _test.go in the same directory as the code they test, normally in the same package, so they can call unexported functions directly.
  • Prefer table-driven tests for any function with more than one meaningful case; add a new row the moment you find a bug instead of writing a whole new function.
  • Use t.Run to name subtests clearly — it gives you per-case pass/fail reporting and lets you re-run a single case with go test -run.
  • Reach for t.Fatal/t.Fatalf when a failed step makes the rest of the test meaningless; reach for t.Error/t.Errorf when you want to keep checking other assertions afterward.
  • Call t.Helper() inside small reusable assertion helper functions so a failure reports the caller’s line number instead of the helper’s.
  • Test exported behavior rather than private implementation details, so refactoring doesn’t force you to rewrite tests that were never really broken.
  • Keep tests deterministic: avoid depending on wall-clock time, network calls, or map iteration order without controlling for them.
  • Run go test -cover occasionally to see which branches are untested, but treat coverage as a signal to investigate, not a target to chase to 100%.

Practice Exercises

  • Write a function Max(a, b int) int that returns the larger of two integers, then write a table-driven test with at least four cases, including equal inputs and negative numbers.
  • Write a function Reverse(s string) string that reverses a string, and a test file for it. Add a subtest for the empty string and think about what happens with multi-byte Unicode characters if you reverse byte-by-byte instead of rune-by-rune.
  • Take the Divide function from Example 3, change the expected value in TestDivide to an incorrect number on purpose, then run go test -v and read the failure message carefully to see exactly what a failing test reports.

Summary

  • Go’s built-in testing package plus the go test command provide a complete unit-testing workflow with no external dependencies.
  • Test functions live in _test.go files, must be named TestXxx with a capital first letter after "Test", and take a single *testing.T parameter.
  • t.Error/t.Errorf record a failure and keep running; t.Fatal/t.Fatalf record a failure and stop that test immediately.
  • Table-driven tests combined with t.Run subtests are the idiomatic way to cover many cases without duplicating code.
  • A lowercase test function name and loop-variable capture in subtests are two of the most common early mistakes — both are worth checking for explicitly.
  • Run tests with go test; add -v for verbose output, -run to filter by name, and -cover to inspect coverage.