Test Organization and Coverage
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 deskSave 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 testOutput (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 testOutput (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, ¢s); 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 -coverOutput (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_testagainst exported names. package deskonly for unexported helpers that are actually subtle.- Fixtures in
testdata/. Never generate golden files in the package root without a good reason. go test -coverto 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
- From
package desk_test, try to callnormalize. Read the compiler error. That is the boundary working. - Add
TestSumMenuFileMissingthat callsSumMenuFile("testdata/nope.txt")and fatals iferr == nil. - Run
go test -coverprofile=cover.outthengo tool cover -func=cover.out. Read which functions are under 100%. Do not “fix” them unless a branch matters.