Free Handbook · Every example compiled & verified

Packages, Modules & Testing

Organise Go code into packages and modules, control what is exported, manage dependencies with go.mod, and test it with go test, table-driven tests and benchmarks.

0 / 134 lessons🔥 0 day streak
ShareXLinkedIn

Module 09 · what you'll be able to do

  • Split code into packages and control visibility with capitalised names and internal/ directories
  • Create a module with go mod init, add and tidy dependencies with go get and go mod tidy, and read go.mod and go.sum
  • Predict the order in which package variables, init functions and main run
  • Write unit tests, table-driven tests with t.Run subtests, examples and benchmarks
  • Measure coverage with go test -cover and catch bugs early with go vet
01

Packages and visibility

Every Go file starts with a package clause. A package is all the .go files in one directory that share that clause: they compile together, and anything declared at the top level of one file is visible in all the others without an import. package main is special — it builds an executable, and its main function is where the program starts.

Go has no public, private or protected keywords. Visibility is decided by the first letter of the name: Discount, User and User.Name start with a capital, so they are exported and other packages can use them. percentOf and password are unexported — visible only inside their own package. The rule applies to functions, types, variables, constants, struct fields and methods alike.

gomain.go
package main

import (
	"encoding/json"
	"fmt"
)

// User is exported (capital U); so are Name and Email.
// password starts with a lower-case letter, so it is unexported:
// other packages, including encoding/json, cannot see it.
type User struct {
	Name     string
	Email    string
	password string
}

func newUser(name, email, pw string) User { // unexported helper
	return User{Name: name, Email: email, password: pw}
}

func main() {
	u := newUser("ana", "[email protected]", "hunter2")
	fmt.Println(u.password) // fine: same package

	data, _ := json.Marshal(u)
	fmt.Println(string(data)) // password is missing
}
Outputcompiled & run with real Go
hunter2
{"Name":"ana","Email":"[email protected]"}

encoding/json is a different package, so it cannot read unexported fields — a common surprise when an API response comes back missing data.

Your turn

Add a field age int and set it in newUser. Does it appear in the JSON? Rename it to Age and try again.

gointernal/pricing/pricing.go
// Package pricing computes prices. It lives under internal/,
// so only code inside example.com/shop can import it.
package pricing

// Discount is exported: other packages can call it.
func Discount(cents, percent int) int {
	return cents - percentOf(cents, percent)
}

// percentOf is unexported: only code in package pricing can call it.
func percentOf(cents, percent int) int {
	return cents * percent / 100
}

One exported function, one unexported helper. Code outside package pricing can call pricing.Discount but not pricing.percentOf.

Error you will hit

name percentOf not exported by package pricing

go
// main.go — in the same module as internal/pricing/pricing.go above
package main

import (
	"fmt"

	"example.com/shop/internal/pricing"
)

func main() {
	fmt.Println(pricing.percentOf(2000, 25))
}
$ go run .
# example.com/shop
./main.go:10:22: name percentOf not exported by package pricing
Why the compiler said that

percentOf starts with a lower-case letter, so it belongs to package pricing alone. The compiler will not let another package reach it, even inside the same module.

The fix

Call the exported API (pricing.Discount). If other packages really need the helper, rename it with a capital letter and document it — exporting is a promise to keep it working.

go
package main

import (
	"fmt"

	"example.com/shop/internal/pricing"
)

func main() {
	fmt.Println(pricing.Discount(2000, 25)) // 1500
}
Doc comments
A comment directly above an exported name, starting with that name (// Discount returns ...), is its documentation. go doc, editors and pkg.go.dev all show it. Write one for every exported identifier.
02

Modules: go mod init, go get, go mod tidy

A module is a tree of packages versioned and shipped together. Its root holds a go.mod file naming the module path — usually where the code lives, such as github.com/you/shop; for code nobody will download, any name like example.com/shop works. Every import path inside the module starts with that path: the directory internal/pricing is imported as example.com/shop/internal/pricing.

bash
$ mkdir shop && cd shop
$ go mod init example.com/shop
go: creating new go.mod: module example.com/shop
$ cat go.mod
module example.com/shop

go 1.27.1

To use a third-party package, import it in your code and run go get (or go mod tidy). Go downloads the module, records the version in go.mod and writes cryptographic checksums to go.sum, so every build of your project uses exactly the same bytes. Commit both files.

bash
$ go get github.com/google/uuid
go: downloading github.com/google/uuid v1.6.0
go: added github.com/google/uuid v1.6.0
$ go mod tidy
$ cat go.mod
module example.com/shop

go 1.27.1

require github.com/google/uuid v1.6.0
$ head -2 go.sum
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

Right after go get the requirement was marked // indirect; go mod tidy saw that main.go imports it directly and cleaned that up.

CommandWhat it does
go mod init <path>create go.mod in the current directory
go get [email protected]add or change a dependency version (@latest to upgrade)
go mod tidyadd missing requirements, remove unused ones, update go.sum — run it before every commit
go list -m alllist every module in the build, including indirect ones
go build ./... / go test ./...build or test every package in the module (./... means "this directory and below")
Error you will hit

no required module provides package

go
// main.go — go.mod has no require line for uuid yet
package main

import (
	"fmt"

	"github.com/google/uuid"
)

func main() {
	fmt.Println(len(uuid.NewString()))
}
$ go run .
main.go:6:2: no required module provides package github.com/google/uuid; to add it:
	go get github.com/google/uuid
Why the compiler said that

Importing a package is not enough — the module that provides it must be listed in go.mod. Go never downloads dependencies silently during a build, which keeps builds reproducible.

The fix

Run the command the error suggests, go get github.com/google/uuid, or go mod tidy to add everything your imports need.

go
$ go get github.com/google/uuid
$ go run .
36
Semantic import versioning
Versions follow vMAJOR.MINOR.PATCH. From v2 onwards the major version is part of the import path (github.com/you/lib/v2), so a breaking release is literally a different package and v1 and v2 can live in one build.
03

Imports, internal/ and project layout

The package name is normally the last element of its import path: net/url gives you url.Parse. You can rename an import with an alias (useful when two packages share a name), and a blank import import _ "image/png" runs a package's init purely for its side effects, such as registering a database driver or image decoder.

gomain.go
package main

import (
	"fmt"
	"net/url"
	"path/filepath"
	str "strings" // an alias: refer to the package as str
)

func main() {
	// The package name is the last element of the import path.
	u, err := url.Parse("https://solutiongigs.in/learn?tab=go")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(u.Host, u.Query().Get("tab"))

	fmt.Println(filepath.Ext("report.final.pdf"))
	fmt.Println(str.ToUpper("aliased"))
}
Outputcompiled & run with real Go
solutiongigs.in go
.pdf
ALIASED
Your turn

Import path as well as path/filepath and print path.Base("/a/b/c.txt"). Both package names end differently even though the paths overlap.

A directory named internal is enforced by the go tool: packages under a/b/internal/ can be imported only by code rooted at a/b/. Use it for code you want to share inside your module without letting the rest of the world depend on it.

text
shop/
├── go.mod                 module example.com/shop
├── go.sum
├── cmd/
│   ├── api/main.go        package main  -> go build ./cmd/api
│   └── worker/main.go     package main  -> go build ./cmd/worker
├── internal/
│   ├── pricing/           importable only inside example.com/shop
│   └── store/
└── billing/
    ├── billing.go         package billing (public API)
    └── internal/tax/      importable only inside billing/

A common layout for a service. Start flat (one main.go) and add directories only when the code asks for them.

Error you will hit

use of internal package not allowed

go
// main.go at the module root, importing billing/internal/tax
package main

import (
	"fmt"

	"example.com/shop/billing/internal/tax"
)

func main() {
	fmt.Println(tax.VAT(1000))
}
$ go run .
package example.com/shop
	main.go:6:2: use of internal package example.com/shop/billing/internal/tax not allowed
Why the compiler said that

tax lives under billing/internal/, so only packages inside billing/ may import it. The module root is outside that tree.

The fix

Call an exported function in billing that uses tax internally, or move tax up to the module-level internal/ if the whole module should share it.

go
// billing/billing.go
package billing

import "example.com/shop/billing/internal/tax"

func Total(cents int) int { return cents + tax.VAT(cents) }

// main.go
package main

import (
	"fmt"

	"example.com/shop/billing"
)

func main() {
	fmt.Println(billing.Total(1000)) // 1200
}
Error you will hit

import cycle not allowed

go
// a/a.go
package a

import "example.com/cyc/b"

func A() int { return b.B() }

// b/b.go
package b

import "example.com/cyc/a"

func B() int { return a.A() }
$ go build ./...
package example.com/cyc/a
	imports example.com/cyc/b from a.go
	imports example.com/cyc/a from b.go: import cycle not allowed
Why the compiler said that

Packages form a directed graph with no loops, which is part of why Go compiles fast. If a imports b, b can never import a, directly or through other packages.

The fix

Move the shared code into a third package both can import, or have one side depend on a small interface instead of the other package (Module 06).

go
// shared/shared.go
package shared

func Value() int { return 1 }

// a/a.go and b/b.go both import "example.com/cyc/shared"
// and neither imports the other.
04

init() and initialisation order

Before main runs, Go initialises every imported package (each exactly once, dependencies first), then the main package itself. Inside a package, package-level variables are initialised first — in dependency order, not simply top to bottom — then every init() function runs in the order it appears. A package may have several init functions; they take no arguments and cannot be called by name.

gomain.go
package main

import "fmt"

// Package-level variables are initialised first, in dependency order:
// total needs base, so base is set before total even though it comes later.
var total = base * 2
var base = trace("base", 21)

func trace(name string, v int) int {
	fmt.Println("init var", name)
	return v
}

// init functions run after all variables, in the order they appear.
func init() {
	fmt.Println("init() #1, total =", total)
}

func init() {
	fmt.Println("init() #2")
}

func main() {
	fmt.Println("main starts")
}
Outputcompiled & run with real Go
init var base
init() #1, total = 42
init() #2
main starts
Your turn

Add var greeting = trace("greeting", 1) between the two init functions. Where does its line appear in the output, and why?

Keep init boring
init cannot return an error, runs on import (including in tests) and hides work from readers. Good uses: registering a driver or codec, pre-computing a lookup table. Avoid: reading config files, opening database connections, calling the network. Do those in main or an explicit constructor that returns an error.
05

Unit tests with go test

Tests live next to the code in files ending _test.go, which are compiled only by go test. A test is a function named TestXxx(t *testing.T). There is no assertion library in the standard library: you compare with plain if and report with t.Errorf (mark the test failed, keep going) or t.Fatalf (mark it failed and stop this test now). Here is a small package and its tests.

gocalc.go
// Package calc does simple arithmetic on integers.
package calc

import "errors"

// ErrDivideByZero is returned by Divide when b is 0.
var ErrDivideByZero = errors.New("divide by zero")

// Add returns a + b.
func Add(a, b int) int { return a + b }

// Divide returns a / b, or ErrDivideByZero.
func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, ErrDivideByZero
	}
	return a / b, nil
}

// Clamp limits n to the range [lo, hi].
func Clamp(n, lo, hi int) int {
	if n < lo {
		return lo
	}
	if n > hi {
		return hi
	}
	return n
}
gocalc_test.go
package calc

import (
	"errors"
	"testing"
)

func TestAdd(t *testing.T) {
	got := Add(2, 3)
	if got != 5 {
		t.Errorf("Add(2, 3) = %d, want 5", got)
	}
}

func TestClamp(t *testing.T) {
	tests := []struct {
		name      string
		n, lo, hi int
		want      int
	}{
		{"below", -5, 0, 10, 0},
		{"inside", 7, 0, 10, 7},
		{"above", 99, 0, 10, 10},
		{"on edge", 10, 0, 10, 10},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Clamp(tt.n, tt.lo, tt.hi); got != tt.want {
				t.Errorf("Clamp(%d, %d, %d) = %d, want %d", tt.n, tt.lo, tt.hi, got, tt.want)
			}
		})
	}
}

func TestDivideByZero(t *testing.T) {
	_, err := Divide(1, 0)
	if !errors.Is(err, ErrDivideByZero) {
		t.Fatalf("Divide(1, 0) error = %v, want ErrDivideByZero", err)
	}
}
text
$ go test
PASS
ok  	example.com/calc	1.108s

$ go test -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestClamp
=== RUN   TestClamp/below
=== RUN   TestClamp/inside
=== RUN   TestClamp/above
=== RUN   TestClamp/on_edge
--- PASS: TestClamp (0.00s)
    --- PASS: TestClamp/below (0.00s)
    --- PASS: TestClamp/inside (0.00s)
    --- PASS: TestClamp/above (0.00s)
    --- PASS: TestClamp/on_edge (0.00s)
=== RUN   TestDivideByZero
--- PASS: TestDivideByZero (0.00s)
PASS
ok  	example.com/calc	0.369s

Real output. -v lists every test and subtest; spaces in subtest names become underscores.

When a test fails, the message is only as useful as you make it. The convention is Func(args) = got, want want, so the failure reads like a sentence. Here Clamp was broken on purpose to return hi - 1:

text
$ go test
--- FAIL: TestClamp (0.00s)
    --- FAIL: TestClamp/above (0.00s)
        calc_test.go:29: Clamp(99, 0, 10) = 9, want 10
FAIL
exit status 1
FAIL	example.com/calc	0.674s
Same package or _test package?
A test file can declare package calc (white-box: sees unexported names) or package calc_test (black-box: imports example.com/calc like any user would). Both can sit in the same directory. Prefer black-box tests for the public API; use white-box for tricky internals.
06

Table-driven tests and subtests

Go's signature testing style is the table-driven test: a slice of anonymous structs, one per case, and a single loop that checks each. Adding a case is one line, and every case gets the same careful error message. TestClamp above is one. Here is the same shape outside the test runner so you can see it run:

gomain.go
package main

import "fmt"

// The same idea as a table-driven test, run by hand:
// a slice of cases, one loop, one check.
func clamp(n, lo, hi int) int {
	if n < lo {
		return lo
	}
	if n > hi {
		return hi
	}
	return n
}

func main() {
	tests := []struct {
		name      string
		n, lo, hi int
		want      int
	}{
		{"below", -5, 0, 10, 0},
		{"inside", 7, 0, 10, 7},
		{"above", 99, 0, 10, 10},
	}
	for _, tt := range tests {
		got := clamp(tt.n, tt.lo, tt.hi)
		status := "PASS"
		if got != tt.want {
			status = "FAIL"
		}
		fmt.Printf("%-6s %s got=%d want=%d\n", tt.name, status, got, tt.want)
	}
}
Outputcompiled & run with real Go
below  PASS got=0 want=0
inside PASS got=7 want=7
above  PASS got=10 want=10
Your turn

Add an "on edge" case where n equals hi, then a deliberately wrong case, and check that exactly one line says FAIL.

In a real test, wrap each case in t.Run(tt.name, func(t *testing.T) {...}). Each case becomes a named subtest: it is reported separately, a t.Fatalf stops only that case, and you can run one case alone with -run, which takes a regular expression per level separated by /.

text
$ go test -run 'TestClamp/above' -v
=== RUN   TestClamp
=== RUN   TestClamp/above
--- PASS: TestClamp (0.00s)
    --- PASS: TestClamp/above (0.00s)
PASS
ok  	example.com/calc	0.403s
VisualizeHow TestClamp runs its tableStep 1 / 7
package calc
import (
"errors"
"testing"
)
func TestAdd(t *testing.T) {
got := Add(2, 3)
if got != 5 {
t.Errorf("Add(2, 3) = %d, want 5", got)
}
}
func TestClamp(t *testing.T) {
tests := []struct {
name string
n, lo, hi int
want int
}{
{"below", -5, 0, 10, 0},
{"inside", 7, 0, 10, 7},
{"above", 99, 0, 10, 10},
{"on edge", 10, 0, 10, 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Clamp(tt.n, tt.lo, tt.hi); got != tt.want {
t.Errorf("Clamp(%d, %d, %d) = %d, want %d", tt.n, tt.lo, tt.hi, got, tt.want)
}
})
}
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(1, 0)
if !errors.Is(err, ErrDivideByZero) {
t.Fatalf("Divide(1, 0) error = %v, want ErrDivideByZero", err)
}
}
Line 15

go test finds every TestXxx function and calls TestClamp with a fresh *testing.T.

Variables now

nothing yet

All 7 steps as a table
StepLineWhat happenedVariables now
115go test finds every TestXxx function and calls TestClamp with a fresh *testing.T.
216The table is built: four cases, each with inputs and the expected result.len(tests) = 4
326First iteration of the loop.tt.name = "below" tt.n = -5
427t.Run starts a subtest named TestClamp/below with its own t.
528Clamp(-5, 0, 10) returns 0, which equals want, so nothing is reported and the subtest passes.got = 0 tt.want = 0
626The loop moves on through "inside", "above" and "on edge" the same way.tt.name = "inside"
729Only if a result differed would this line run, marking that subtest (and its parent) FAILED with a precise message.
Helpers and parallel tests
Call t.Helper() at the top of a test helper function so failures point at the caller's line, not the helper's. Add t.Parallel() as the first line of a subtest to run cases concurrently — and run the suite with go test -race ./... in CI (Module 08).
07

Benchmarks, examples, coverage and go vet

Three more kinds of function live in _test.go files. A benchmark BenchmarkXxx(b *testing.B) loops over the code being measured; since Go 1.24 the loop is written for b.Loop() { ... } and the framework decides how many iterations give a stable time. Benchmarks run only when you pass -bench.

gobench_test.go
package calc

import "testing"

func BenchmarkClamp(b *testing.B) {
	for b.Loop() {
		Clamp(42, 0, 10)
	}
}
text
$ go test -bench . -run '^$'
goos: darwin
goarch: arm64
pkg: example.com/calc
cpu: Apple M1
BenchmarkClamp-8   	257884204	         4.782 ns/op
PASS
ok  	example.com/calc	1.694s

-run '^$' skips the ordinary tests. -8 is GOMAXPROCS; then the iteration count and the time per operation. Your numbers will differ. Add -benchmem to see allocations.

An example ExampleXxx() is documentation that is also a test: go test runs it and compares what it prints with the // Output: comment. It shows up on pkg.go.dev next to the function it names.

goexample_test.go
package calc_test

import (
	"fmt"

	"example.com/calc"
)

func ExampleDivide() {
	q, err := calc.Divide(10, 3)
	fmt.Println(q, err)
	_, err = calc.Divide(1, 0)
	fmt.Println(err)
	// Output:
	// 3 <nil>
	// divide by zero
}
text
$ go test -v -run Example
=== RUN   ExampleDivide
--- PASS: ExampleDivide (0.00s)
PASS
ok  	example.com/calc	0.630s

Coverage tells you which statements your tests executed. With only calc_test.go, the success path of Divide was never run; adding the example covered it. Coverage finds untested code — it does not prove the tested code is right.

text
# with calc_test.go only
$ go test -cover
PASS
coverage: 88.9% of statements
ok  	example.com/calc	0.685s

# after adding example_test.go
$ go test -coverprofile=c.out
$ go tool cover -func=c.out
example.com/calc/calc.go:10:	Add		100.0%
example.com/calc/calc.go:13:	Divide		100.0%
example.com/calc/calc.go:21:	Clamp		100.0%
total:				(statements)	100.0%

go tool cover -html=c.out opens a colour-coded view of the source in your browser.

Error you will hit

go vet: fmt.Printf format %s reads arg #2, but call has 1 arg

go
package main

import "fmt"

func main() {
	total := 1500
	fmt.Printf("total: %d cents for %s\n", total)
}
$ go vet .
main.go:7:34: fmt.Printf format %s reads arg #2, but call has 1 arg

$ go run .    # compiles and runs anyway
total: 1500 cents for %!s(MISSING)
Why the compiler said that

The compiler cannot check format strings, so this builds and quietly prints %!s(MISSING). go vet runs static checks for mistakes that compile but are almost certainly bugs: bad Printf verbs, copying a mutex, unreachable code, a wrong errors.As target. go test runs a subset of vet automatically.

The fix

Pass an argument for every verb. Run go vet ./... in CI next to go test ./....

go
package main

import "fmt"

func main() {
	total := 1500
	fmt.Printf("total: %d cents for %s\n", total, "ana")
}
Package
The .go files in one directory sharing a package clause; the unit of compilation and visibility.
Exported identifier
A name starting with an upper-case letter; usable from other packages.
Module
A versioned collection of packages rooted at a go.mod file.
go.sum
Checksums of every dependency version, verified on download so builds are reproducible and tamper-evident.
internal/
A directory whose packages can be imported only by code in the tree rooted at its parent.
init()
A function run automatically after package variables are initialised and before main.
Table-driven test
A test that loops over a slice of cases, usually running each as a t.Run subtest.
go vet
A static analyser that reports code that compiles but is probably wrong.
Quick check

Package store declares type item struct{ Name string } and func Load() []item. What can another package do?

Quick check

In a test, what is the difference between t.Errorf and t.Fatalf?

Frequently asked questions

What is the difference between a package and a module in Go?
A package is one directory of .go files compiled together. A module is a versioned collection of packages defined by a go.mod file; you download and version modules, and you import packages.
How do I make a function private in Go?
Start its name with a lower-case letter. Go has no access keywords: lower-case identifiers are visible only within their package, upper-case ones are exported. Put a package under an internal/ directory to limit who can import it at all.
Should I commit go.sum?
Yes. go.sum records the expected checksum of every dependency so that every machine builds with identical code, and go mod tidy keeps it in sync with go.mod.

Finish the Go handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.