Testing Fundamentals
Testing Fundamentals
Tests in Go are ordinary functions in files named *_test.go. You run them with go test. The boring default is a table-driven test: a slice of cases, t.Run per case, t.Fatalf on the first mismatch. No framework.
Mental model
- Production code lives in
desk.go(package desk). - Tests live in
desk_test.go(package deskto see unexported names, orpackage desk_testto see only the public API — next chapter). func TestXxx(t *testing.T)is a test. The name afterTestis capital.t.Fatalffails and stops that test function (or thatt.Runsubtest).t.Errorffails and continues.t.Helper()marks a function so failure line numbers point at the caller, not the helper.
Put both files in an empty directory, then:
go mod init desk
go testEach listing below is a complete file. Copy the pair that the case names.
Worked examples
Case 1: A package and a table-driven test
Total adds ticket prices in cents. The test names each case, runs it as a subtest, and fatals with got / want.
Save as desk.go:
// desk.go
package desk
func Total(prices []int) int {
sum := 0
for _, p := range prices {
sum += p
}
return sum
}Save as desk_test.go:
// desk_test.go
package desk
import "testing"
func TestTotal(t *testing.T) {
tests := []struct {
name string
prices []int
want int
}{
{name: "empty", prices: nil, want: 0},
{name: "one ticket", prices: []int{450}, want: 450},
{name: "three tickets", prices: []int{450, 800, 250}, want: 1500},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Total(tt.prices)
if got != tt.want {
t.Fatalf("Total() = %d, want %d", got, tt.want)
}
})
}
}Run (from the directory that contains both files):
go mod init desk
go testOutput (the duration varies):
PASS
ok desk 0.002s
t.Run gives you --- FAIL: TestTotal/three_tickets when something breaks. Without it, you only know that TestTotal failed.
If go.mod already exists, skip go mod init. go test compiles the package plus the tests; it does not run main. There is no package main here.
Case 2: t.Helper so line numbers tell the truth
An assertion helper without t.Helper() reports its own line. With t.Helper(), go test points at the call in the test.
Save as shift.go:
// shift.go
package desk
func Lead(roster []string) (string, bool) {
if len(roster) == 0 {
return "", false
}
return roster[0], true
}Save as shift_test.go (same module, same directory as desk.go from Case 1, or a fresh desk module that contains only these two files):
// shift_test.go
package desk
import "testing"
func assertLead(t *testing.T, roster []string, want string, wantOK bool) {
t.Helper()
got, ok := Lead(roster)
if ok != wantOK || got != want {
t.Fatalf("Lead(%q) = %q, %t; want %q, %t", roster, got, ok, want, wantOK)
}
}
func TestLead(t *testing.T) {
assertLead(t, []string{"Amina", "Bo"}, "Amina", true)
assertLead(t, nil, "", false)
}Run:
go testOutput (the duration varies):
PASS
ok desk 0.002s
Delete t.Helper() and fail a case on purpose: the file:line in the failure will be inside assertLead, not TestLead. Put t.Helper() back.
Case 3: t.Fatalf vs t.Errorf
Fatalf is for “this case is done.” Errorf is for “collect more mismatches.” In a table with t.Run, Fatalf is the default: one assertion, stop, next subtest still runs.
Save as clamp.go:
// clamp.go
package desk
func Clamp(n, lo, hi int) int {
if n < lo {
return lo
}
if n > hi {
return hi
}
return n
}Save as clamp_test.go:
// clamp_test.go
package desk
import "testing"
func TestClamp(t *testing.T) {
tests := []struct {
n, lo, hi, want int
}{
{n: 5, lo: 1, hi: 10, want: 5},
{n: 0, lo: 1, hi: 10, want: 1},
{n: 99, lo: 1, hi: 10, want: 10},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
got := Clamp(tt.n, tt.lo, tt.hi)
if got != tt.want {
t.Fatalf("Clamp(%d,%d,%d) = %d, want %d", tt.n, tt.lo, tt.hi, got, tt.want)
}
})
}
}Run:
go testOutput (the duration varies):
PASS
ok desk 0.002s
Empty subtest names are legal and ugly. Prefer name fields as in Case 1. t.Run("", ...) is here only to keep the table loop’s fatals from skipping later rows.
The trap
A test that hard-codes one happy path and uses panic when it fails. You lose go test’s reporting, parallel subtests, and the habit of tables.
Save as trap_test.go (with desk.go from Case 1):
// trap_test.go
package desk
import "testing"
func TestTotalHappyOnly(t *testing.T) {
if Total([]int{450, 800, 250}) != 1500 {
panic("total is wrong")
}
}Run:
go testIt passes today. Tomorrow Total returns 1501 and you get a panic stack instead of TestTotalHappyOnly: Total() = 1501, want 1500. Write the table. Call t.Fatalf. Cover empty input.
The boring rule
- One module directory.
go mod init desk.go test. TestXxx(t *testing.T)in*_test.go.- Tables:
name, inputs,want. Loop witht.Run(tt.name, ...). t.Fatalffor a broken case.t.Helper()on helpers.- Assert
gotandwantin the message. Do not panic in tests. - Tests that compile but never fail are souvenirs, not tests. Include an empty or error case.
Try this
- Add a case
negativetoTestTotalwithprices: []int{-100, 250}andwant: 150. Rungo test. - In
TestLead, add a case for a single-name roster. UseassertLead. - Break
Clampson > hireturnshi - 1. Rungo testand read theFAILline. Restore it.