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 ioHow 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, notindexUtils. - 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 beforemain, 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.sumrecords 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(ago.workfile) lets you edit several modules together withoutreplacelines.- Since Go 1.24,
tooldirectives ingo.modpin build-time tools, run withgo 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 importTesting#
// 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.
| Kind | Signature | Run with |
|---|---|---|
| Test | func TestXxx(t *testing.T) | go test ./... |
| Benchmark | func BenchmarkXxx(b *testing.B) — loop with for b.Loop() { ... } (Go 1.24+) | go test -bench . -benchmem |
| Fuzz test | func FuzzXxx(f *testing.F) | go test -fuzz FuzzXxx |
| Example | func ExampleXxx() with an // Output: comment | Checked 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: badPrintfverbs, copied locks, lost cancellations.go testruns a subset automatically.gofmt— formatting.go fix ./...— since Go 1.26, a set of “modernizers” that rewrite old idioms to current ones.staticcheckandgolangci-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
-racein CI andgo vetalways.
Try it#
- Run
measure.go. Why does one version allocate hundreds of times and the other about ten? - Create a module with a package and a
_test.gofile containing the cosine table test and aBenchmarkCosineusingb.Loop(). Rungo test -bench . -benchmem. - Add a fuzz test asserting that
Cosinenever returns a value outside [−1, 1] or NaN. Does the fuzzer find a counter-example?
Check yourself#
- What makes an identifier visible outside its package?
- Why does a new major version change the import path?
- What does
-racedetect, and why run it in CI?