Below the API

Packages, Modules and Testing

Foundations Beginner 50 min Difficulty 2/5

Prerequisites 01–04

The idea in one minute#

A package is a directory of .go files compiled together; it is Go’s unit of code and of visibility. A module is a tree of packages with a go.mod file that names it and pins its dependencies; it is the unit of versioning. Testing is part of the language toolchain: a file ending in _test.go with functions named TestXxx is a test, and go test runs it.

There is no build file to write, no test framework to choose, and the dependency graph is reproducible from two small files.

An analogy#

A package is a workshop room: everything inside can reach everything else, and only tools with a capital letter on the label may be lent out. A module is the building, with a registry at the front desk listing exactly which outside suppliers it uses and which batch of each.

A picture#

flowchart TB
  subgraph MOD["module example.com/embedsvc  (go.mod, go.sum)"]
    MAIN["cmd/embedsvc/main.go<br/>package main"]
    API["api/<br/>package api, exported handlers"]
    INT["internal/index/<br/>importable only inside this module"]
    TST["internal/index/index_test.go<br/>tests, benchmarks, fuzz targets"]
  end
  MAIN --> API --> INT
  TST -.->|"go test"| INT
  MOD -->|"require github.com/x/y v1.4.2"| DEP["Module cache<br/>verified against go.sum"]
  class MAIN,API compute
  class INT memory
  class TST queue
  class DEP io

How it really works#

Packages#

  • One directory, one package. The package name is the last element of the import path by convention, short and lower-case: index, not indexUtils.
  • Exported identifiers start with an upper-case letter. Everything else is package-private. There is no per-file or per-type privacy.
  • A directory named internal/ can be imported only by code rooted at its parent. It is how a module keeps its implementation out of its public API.
  • Import cycles are a compile error. This forces a layered design and is a large part of why Go builds are fast.
  • init() functions run before main, in dependency order. Use them rarely; explicit initialization is easier to test.

Modules#

module example.com/embedsvc

go 1.24                      // minimum language version; gates behaviour changes

require (
    golang.org/x/sync v0.10.0
)
  • go.sum records a cryptographic hash of every dependency version, so a build fetches exactly the same bytes everywhere.
  • Versions are semantic (v1.4.2). A breaking change needs a new major version, which changes the import path: example.com/lib/v2. Two major versions can coexist in one build.
  • Go picks the minimum version that satisfies every requirement (“minimal version selection”): builds do not change because a new release appeared.
  • go work (a go.work file) lets you edit several modules together without replace lines.
  • Since Go 1.24, tool directives in go.mod pin build-time tools, run with go tool name.

A common layout:

cmd/<binary>/main.go     one directory per executable; keep main small
internal/...             the real code
<public packages>/       only what other modules should import

Testing#

// index_test.go
package index

import "testing"

func TestCosine(t *testing.T) {
    tests := []struct {
        name string
        a, b []float32
        want float64
    }{
        {"identical", []float32{1, 0}, []float32{1, 0}, 1},
        {"orthogonal", []float32{1, 0}, []float32{0, 1}, 0},
    }
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            if got := Cosine(tc.a, tc.b); math.Abs(got-tc.want) > 1e-9 {
                t.Errorf("Cosine = %v, want %v", got, tc.want)
            }
        })
    }
}

This table-driven shape is the idiom: one loop, one sub-test per case, a new case is one line.

KindSignatureRun with
Testfunc TestXxx(t *testing.T)go test ./...
Benchmarkfunc BenchmarkXxx(b *testing.B) — loop with for b.Loop() { ... } (Go 1.24+)go test -bench . -benchmem
Fuzz testfunc FuzzXxx(f *testing.F)go test -fuzz FuzzXxx
Examplefunc ExampleXxx() with an // Output: commentChecked by go test, shown in docs

Flags worth knowing: -run Regex, -race (data race detector — use it in CI), -cover, -count=1 (bypass the test cache), -shuffle=on, -v.

Helpers: t.Helper(), t.Cleanup(fn), t.TempDir(), t.Context(), t.Parallel(). testing/synctest (Go 1.25) runs concurrent code inside a “bubble” with a fake clock, so tests of timeouts and tickers finish instantly and deterministically.

Other quality gates#

  • go vet ./... — bugs the compiler accepts: bad Printf verbs, copied locks, lost cancellations. go test runs a subset automatically.
  • gofmt — formatting.
  • go fix ./... — since Go 1.26, a set of “modernizers” that rewrite old idioms to current ones.
  • staticcheck and golangci-lint — widely used third-party linters.
  • govulncheck — reports known vulnerabilities your code actually reaches.

Code#

testing.Benchmark and testing.AllocsPerRun work in an ordinary program, which lets a lesson measure itself. The rest of this course uses them constantly.

// measure.go — a table-driven check, a benchmark and an allocation count, from main.
package main

import (
	"fmt"
	"math"
	"strings"
	"testing"
)

func Cosine(a, b []float32) float64 {
	var dot, na, nb float64
	for i := range a {
		dot += float64(a[i]) * float64(b[i])
		na += float64(a[i]) * float64(a[i])
		nb += float64(b[i]) * float64(b[i])
	}
	if na == 0 || nb == 0 {
		return 0
	}
	return dot / math.Sqrt(na*nb)
}

func joinPlus(words []string) string { // quadratic: each += copies everything so far
	s := ""
	for _, w := range words {
		s += w + " "
	}
	return s
}

func joinBuilder(words []string) string {
	var b strings.Builder
	for _, w := range words {
		b.WriteString(w)
		b.WriteByte(' ')
	}
	return b.String()
}

func main() {
	// 1. A table-driven test, by hand.
	tests := []struct {
		name string
		a, b []float32
		want float64
	}{
		{"identical", []float32{1, 0}, []float32{1, 0}, 1},
		{"orthogonal", []float32{1, 0}, []float32{0, 1}, 0},
		{"opposite", []float32{1, 2}, []float32{-1, -2}, -1},
		{"zero vector", []float32{0, 0}, []float32{1, 1}, 0},
	}
	for _, tc := range tests {
		got := Cosine(tc.a, tc.b)
		status := "PASS"
		if math.Abs(got-tc.want) > 1e-9 {
			status = "FAIL"
		}
		fmt.Printf("%s  %-12s got %5.2f want %5.2f\n", status, tc.name, got, tc.want)
	}

	// 2. A benchmark and an allocation count.
	words := strings.Fields(strings.Repeat("token ", 500))
	fmt.Println()
	for _, c := range []struct {
		name string
		f    func([]string) string
	}{{"s += w", joinPlus}, {"strings.Builder", joinBuilder}} {
		r := testing.Benchmark(func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				_ = c.f(words)
			}
		})
		allocs := testing.AllocsPerRun(20, func() { _ = c.f(words) })
		fmt.Printf("%-16s %8d ns/op  %5.0f allocs/op\n", c.name, r.NsPerOp(), allocs)
	}
}

Remember this#

  • Package = directory = unit of visibility. Capital letter = exported.
  • Module = go.mod + go.sum = reproducible dependencies; major versions live in the import path.
  • Tests, benchmarks, fuzzing and examples are built into go test. Table-driven is the idiom.
  • Run -race in CI and go vet always.

Try it#

  1. Run measure.go. Why does one version allocate hundreds of times and the other about ten?
  2. Create a module with a package and a _test.go file containing the cosine table test and a BenchmarkCosine using b.Loop(). Run go test -bench . -benchmem.
  3. Add a fuzz test asserting that Cosine never returns a value outside [−1, 1] or NaN. Does the fuzzer find a counter-example?

Check yourself#

  1. What makes an identifier visible outside its package?
  2. Why does a new major version change the import path?
  3. What does -race detect, and why run it in CI?

↑↓ navigate ↵ open