Test Organization and Coverage

Updated

September 13, 2026

Test Organization and Coverage

Keep tests next to the code they cover. Use package desk when you must see unexported functions. Use package desk_test when you want to test the public API like a caller. Put fixtures in testdata/. Measure with go test -cover. The boring default is external tests for the API plus a few internal tests for the awkward bits.

Mental model

Same directory:

File Package clause Sees unexported names?
desk.go package desk — (production)
desk_test.go package desk yes (white-box / internal test)
api_test.go package desk_test no (black-box / external test)

Go compiles package desk_test as a separate package that imports desk. That is the external test package. Naming the file desk_internal_test.go does not change that — the package clause does. Prefer package desk_test and a filename that says what it tests.

testdata/ is ignored as a Go package. It is a folder of files your tests read.

Coverage is the fraction of statements executed. 100% is not a goal. Uncovered error branches you care about are.

Worked examples

Case 1: Internal test (package desk)

normalize is unexported. Only an internal test can call it directly.

In an empty directory:

go mod init desk

Save as desk.go:

// desk.go
package desk

import "strings"

func normalize(name string) string {
    return strings.TrimSpace(strings.ToLower(name))
}

func RosterHas(roster []string, name string) bool {
    want := normalize(name)
    for _, n := range roster {
        if normalize(n) == want {
            return true
        }
    }
    return false
}

Save as desk_test.go:

// desk_test.go
package desk

import "testing"

func TestNormalize(t *testing.T) {
    tests := []struct {
        in, want string
    }{
        {in: "Amina", want: "amina"},
        {in: "  Bo  ", want: "bo"},
        {in: "", want: ""},
    }
    for _, tt := range tests {
        t.Run(tt.in, func(t *testing.T) {
            got := normalize(tt.in)
            if got != tt.want {
                t.Fatalf("normalize(%q) = %q, want %q", tt.in, got, tt.want)
            }
        })
    }
}

Run:

go test

Output (the duration varies):

PASS
ok      desk    0.002s

Case 2: External test (package desk_test)

This file cannot mention normalize. It imports the module and uses RosterHas. That is the API a waiter would call.

Save as api_test.go:

// api_test.go
package desk_test

import (
    "testing"

    "desk"
)

func TestRosterHas(t *testing.T) {
    roster := []string{"Amina", "Bo"}
    tests := []struct {
        name string
        want bool
    }{
        {name: "amina", want: true},
        {name: "  BO ", want: true},
        {name: "Chen", want: false},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := desk.RosterHas(roster, tt.name)
            if got != tt.want {
                t.Fatalf("RosterHas(%q) = %t, want %t", tt.name, got, tt.want)
            }
        })
    }
}

Run:

go test

Output (the duration varies):

PASS
ok      desk    0.002s

The import path "desk" matches go mod init desk. In a real repo it would be the module path (github.com/you/desk). Both test packages run in one go test.

Case 3: testdata/ fixtures and -cover

A menu file the test must not invent inline — it is a fixture, so it lives in testdata/.

Save as testdata/menu.txt (plain text, not Go):

soup 400
tea 300
pie 800

Save as menu.go:

// menu.go
package desk

import (
    "bufio"
    "fmt"
    "os"
    "strings"
)

func SumMenuFile(path string) (int, error) {
    f, err := os.Open(path)
    if err != nil {
        return 0, err
    }
    defer f.Close()

    sum := 0
    sc := bufio.NewScanner(f)
    for sc.Scan() {
        line := strings.TrimSpace(sc.Text())
        if line == "" {
            continue
        }
        var name string
        var cents int
        if _, err := fmt.Sscanf(line, "%s %d", &name, &cents); err != nil {
            return 0, fmt.Errorf("%s: %w", line, err)
        }
        sum += cents
    }
    if err := sc.Err(); err != nil {
        return 0, err
    }
    return sum, nil
}

Save as menu_test.go:

// menu_test.go
package desk

import "testing"

func TestSumMenuFile(t *testing.T) {
    got, err := SumMenuFile("testdata/menu.txt")
    if err != nil {
        t.Fatalf("SumMenuFile: %v", err)
    }
    if got != 1500 {
        t.Fatalf("SumMenuFile() = %d, want 1500", got)
    }
}

Run:

go test -cover

Output (the duration varies; coverage is for this case’s files alone):

PASS
coverage: 81.8% of statements
ok      desk    0.002s  coverage: 81.8% of statements

go test sets the working directory to the package directory, so "testdata/menu.txt" resolves. Do not put fixtures in /tmp and hope CI agrees.

If you only have the files from this case in the directory, coverage of menu.go is high; the error returns for a missing file are still untested. That is fine. Add a case with a bad path when you care.

The trap

Testing unexported details and nothing else. When normalize is rewritten as a one-liner inside RosterHas, a pile of internal tests go red and no test still describes waiter-facing behavior.

The fix is Case 2: at least one external test per exported function. Keep internal tests for parsers and bit-twiddling that are painful through the public API.

A second trap: chasing 100% coverage by testing fmt.Println. Coverage is a flashlight, not a score.

The boring rule

  • Default: package desk_test against exported names.
  • package desk only for unexported helpers that are actually subtle.
  • Fixtures in testdata/. Never generate golden files in the package root without a good reason.
  • go test -cover to see holes. Write a test for a hole you care about, not for a number.
  • One module path. External tests import that path.

Try this

  1. From package desk_test, try to call normalize. Read the compiler error. That is the boundary working.
  2. Add TestSumMenuFileMissing that calls SumMenuFile("testdata/nope.txt") and fatals if err == nil.
  3. Run go test -coverprofile=cover.out then go tool cover -func=cover.out. Read which functions are under 100%. Do not “fix” them unless a branch matters.