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 testscans the target package’s directory for files ending in_test.go. - 2. It compiles a temporary test binary that links your normal
.gofiles with the test files, plus a small generatedmainthat 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)andExampleXxx()). - 4. Each
TestXxxruns, in the order it appears in the source, with a fresh*testing.T. If the function callst.Run, each subtest gets its own*testing.Ttoo. - 5. Calling
t.Errorfrecords a failure on that*testing.Tand lets the function keep executing to the end. - 6. Calling
t.Fatalfrecords a failure and then stops that goroutine’s test function immediately (internally, viaruntime.Goexit), though any deferred calls still run. - 7. After every test finishes,
go testprints aPASS/FAILline per test (with-v) and a final summary line —okplus the elapsed time if everything passed, orFAILif 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.goin 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.Runto name subtests clearly — it gives you per-case pass/fail reporting and lets you re-run a single case withgo test -run. - Reach for
t.Fatal/t.Fatalfwhen a failed step makes the rest of the test meaningless; reach fort.Error/t.Errorfwhen 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 -coveroccasionally 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) intthat 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) stringthat 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
Dividefunction from Example 3, change the expected value inTestDivideto an incorrect number on purpose, then rungo test -vand read the failure message carefully to see exactly what a failing test reports.
Summary
- Go’s built-in
testingpackage plus thego testcommand provide a complete unit-testing workflow with no external dependencies. - Test functions live in
_test.gofiles, must be namedTestXxxwith a capital first letter after "Test", and take a single*testing.Tparameter. t.Error/t.Errorfrecord a failure and keep running;t.Fatal/t.Fatalfrecord a failure and stop that test immediately.- Table-driven tests combined with
t.Runsubtests 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-vfor verbose output,-runto filter by name, and-coverto inspect coverage.
