Testing Fundamentals

Updated

September 13, 2026

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 desk to see unexported names, or package desk_test to see only the public API — next chapter).
  • func TestXxx(t *testing.T) is a test. The name after Test is capital.
  • t.Fatalf fails and stops that test function (or that t.Run subtest). t.Errorf fails 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 test

Each 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 test

Output (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 test

Output (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 test

Output (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 test

It 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 with t.Run(tt.name, ...).
  • t.Fatalf for a broken case. t.Helper() on helpers.
  • Assert got and want in 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

  1. Add a case negative to TestTotal with prices: []int{-100, 250} and want: 150. Run go test.
  2. In TestLead, add a case for a single-name roster. Use assertLead.
  3. Break Clamp so n > hi returns hi - 1. Run go test and read the FAIL line. Restore it.