Skip to content
gohacks.dev
Testing

Your First Go Test and How the go test Command Finds It

Write your first Go test and learn how go test finds it: file and function naming, test caching, -v and -run, failure output, and the vet check on test names.

7 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Testing in Go needs no framework to install and no configuration file. The tool is go test, the library is the testing package in the standard library, and the convention is small enough to learn in ten minutes. This article writes a first test, runs it, breaks it on purpose and uses the output to explain what the command looks for and why it sometimes seems to do nothing. It is the start of the testing series, which continues with table-driven tests, subtests and parallel tests and the overview in Go testing: table tests, fuzzing and benchmarks. Everything ran on Go 1.25.6.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The code under test

A package with two small functions:

sum.goGo
package main

// Sum adds two integers.
func Sum(a, b int) int { return a + b }

// Abs returns the absolute value of n.
func Abs(n int) int {
	if n < 0 {
		return -n
	}
	return n
}
main.goGo
package main

import "fmt"

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

The package is main only because the lab needs a runnable program; tests work the same in any package. In a library you would have package sum in sum.go.

The test file

sum_test.goGo
package main

import "testing"

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

func TestAbs(t *testing.T) {
	if got := Abs(-4); got != 4 {
		t.Errorf("Abs(-4) = %d, want 4", got)
	}
}

func helperNotATest(t *testing.T) { t.Fatal("never runs: the name does not start with Test") }

The rules go test applies:

  1. File name. A file ending in _test.go is a test file. Go ignores it in normal builds, so test code and test-only dependencies never ship in your binary. Put it next to the code, in the same directory.
  2. Package. The file usually declares the same package as the code (package main here), which gives it access to unexported identifiers. A file can instead declare package main_test (the package name plus _test) to test only the public API from the outside, which is the right choice for checking that your exported surface is usable.
  3. Function name. A function is a test if it is named Test followed by something that does not start with a lower-case letter, and takes one parameter, *testing.T. TestSum and TestAbs qualify. helperNotATest does not start with Test, so it never runs by itself; it is a helper that a test could call.
  4. Failing. A test fails when it calls t.Error, t.Errorf, t.Fatal or t.Fatalf (or panics). Errorf marks the test failed and continues, so you see every problem in one run. Fatalf marks it failed and stops that test at once, which is the right choice when later lines would panic (for example, after a failed setup). A test that finishes without either has passed.

There are no assertions in the standard library, only if and t.Errorf. The convention for the message is FunctionName(inputs) = got, want expected, because anyone reading a failure needs those three things.

Running it

The run script executes the commands a developer types and prints the output, with the timings removed so the result is the same every time.

Shell
strip() { sed -E 's/[[:space:]]+[0-9.]+s$//; s/ \([0-9.]+s\)//'; }
echo '$ go test'
go test . 2>&1 | strip
echo '$ go test (again, nothing changed)'
go test . 2>&1 | strip
echo '$ go test -count=1 -v'
go test -count=1 -v . 2>&1 | strip
echo '$ go test -run TestAbs -v'
go test -count=1 -run TestAbs -v . 2>&1 | strip
echo '--- break Sum on purpose'
cp sum.go sum.go.bak
sed -i.tmp 's/return a + b/return a - b/' sum.go
echo '$ go test'
go test -count=1 . 2>&1 | strip
mv sum.go.bak sum.go; rm -f sum.go.tmp
echo '--- add a test whose name does not start with Test followed by a capital'
cp sum_test.go sum_test.go.bak
printf '\nfunc Testlowercase(t *testing.T) { t.Fatal("this would fail if it ran") }\n' >> sum_test.go
echo '$ go test'
go test -count=1 . 2>&1 | strip
mv sum_test.go.bak sum_test.go
Text
$ go test
ok  	example.test/lab
$ go test (again, nothing changed)
ok  	example.test/lab	(cached)
$ go test -count=1 -v
=== RUN   TestSum
--- PASS: TestSum
=== RUN   TestAbs
--- PASS: TestAbs
PASS
ok  	example.test/lab
$ go test -run TestAbs -v
=== RUN   TestAbs
--- PASS: TestAbs
PASS
ok  	example.test/lab
--- break Sum on purpose
$ go test
--- FAIL: TestSum
    sum_test.go:8: Sum(2, 3) = -1, want 5
FAIL
FAIL	example.test/lab
FAIL
--- add a test whose name does not start with Test followed by a capital
$ go test
# example.test/lab
# [example.test/lab]
./sum_test.go:20:6: Testlowercase has malformed name: first letter after 'Test' must not be lowercase
FAIL	example.test/lab [build failed]
FAIL

go test found the package, compiled the code and the test files into a test binary, ran it and printed ok with the package path. With no flags it prints nothing about individual tests when they pass. That is deliberate: silence means success.

The second run says (cached). go test caches successful results keyed by the test binary and its inputs (files read, environment variables consulted, command-line flags). When nothing has changed, it replays the previous result without running anything. This is why a test that depends on something outside that tracked set, such as the current time or a network service, can seem to pass forever. Run with -count=1 to disable the cache for a run, which is the standard way to force a real execution; CI should use it, or you can clear the cache with go clean -testcache.

-v prints each test as it runs: === RUN TestSum, then --- PASS: TestSum (with its duration, which the script strips). Use it when you want names, or when a test logs with t.Log, which is hidden unless the test fails or -v is on.

-run TestAbs ran only TestAbs. The argument is a regular expression matched against the test name, so -run 'Sum|Abs' runs both and -run '^TestSum$' is exact. (Running a single test or subtest covers the subtest syntax.)

What a failure looks like

The script then rewrites Sum to subtract, and runs again:

Text
--- FAIL: TestSum
    sum_test.go:8: Sum(2, 3) = -1, want 5
FAIL
FAIL	example.test/lab
FAIL

The line sum_test.go:8 is where t.Errorf was called, with the file and line. That is why failure messages should carry the inputs and both values: the line tells you which check, the message tells you what it saw. The exit code of go test is non-zero, so CI stops on it.

The name check

The last experiment adds func Testlowercase(t *testing.T) to the test file. You might expect Go to skip it, as it skips helperNotATest, but the run printed:

Text
./sum_test.go:20:6: Testlowercase has malformed name: first letter after 'Test' must not be lowercase
FAIL	example.test/lab [build failed]

go test runs a small subset of go vet automatically before running tests, and one of those checks is on test function names. A function that looks like a test but has the wrong shape would otherwise be silently skipped, which is exactly how a test that never runs ends up in a repository for years. The same check catches wrong parameter types and mismatched names for examples and benchmarks. It is a helpful guard, and a reason to run go vet ./... in CI as well: go test only runs the cheap checks.

Where tests are discovered

go test with no arguments tests the package in the current directory. go test ./... tests every package in the module, which is the command to use in CI. go test ./internal/... limits it to a subtree, and go test example.com/mod/pkg names a package. Packages with no test files report [no test files]. A directory named testdata is ignored by the tool, which makes it the conventional home for fixture files (golden files).

A few flags worth knowing on day one

  • -race turns on the race detector, which finds data races at run time. Use it in CI for any code with goroutines.
  • -cover reports statement coverage; -coverprofile=c.out writes a profile for go tool cover -html=c.out.
  • -failfast stops at the first failing test.
  • -timeout 30s changes the default 10 minute limit for the whole test binary.
  • -shuffle=on runs tests in random order, which finds hidden dependencies between tests.
  • -short sets testing.Short() to true so slow tests can skip themselves.

Habits to start with

Write the test file at the same time as the code. Name tests after the thing tested (TestParseDate, not TestFunction1). Keep each test small enough that its failure message identifies the problem. Run the whole suite before you commit. When the test grows beyond a few checks of the same shape, move to a table (table-driven tests). The command reference is in the go test documentation and the testing package.

Also inBasics
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.