14 KiB
Go: Packages, Testing, and Tools
Learning Objectives
- Understand how Go packages and modules organize code and dependencies.
- Learn the visibility rules that shape API design in Go.
- Write unit tests, table-driven tests, handler tests, benchmarks, and fuzz tests.
- Use the Go toolchain to format, vet, benchmark, and inspect code.
- Understand common package layout patterns for growing services.
- Recognize how tooling discipline keeps Go codebases maintainable in production.
Packages and Modules: Two Different Layers
One of the easiest ways to get confused in Go is to blur packages and modules together. They are connected, but they are not the same thing.
Package
A package is a unit of code organization and namespace.
Examples:
fmtnet/httptime- your own package like
internal/store
All files in a directory usually belong to the same package and compile together.
Module
A module is the versioned dependency unit defined by go.mod.
One module can contain many packages.
This distinction matters in real projects because:
- packages organize design inside the codebase
- modules organize dependency and version boundaries across codebases
Why Go Organizes Code This Way
Go wants dependency structure to stay visible and simple.
Packages make it easy to answer questions like:
- what code belongs together
- what API surface is exported
- what dependencies are allowed here
Modules make it easy to answer questions like:
- what external libraries does this project depend on
- what version of a dependency are we building against
- how can another project import this code reproducibly
In large backend systems, those questions are not administrative details. They directly affect build speed, deploy safety, and team comprehension.
Package Naming and Visibility
Package Names
Go package names are usually short, lower-case, and simple.
Good examples:
storeauthqueueconfig
Less ideal examples:
storeutilscommonhelpersmyAmazingPackage
Why the simplicity matters:
- package names appear at every call site
- short names keep code readable
- packages should represent clear concepts, not junk drawers
Exported vs Unexported Identifiers
Go uses capitalization to control visibility.
- identifiers starting with an uppercase letter are exported
- identifiers starting with a lowercase letter are package-private
package store
type User struct {
ID int64
Name string
}
type repository struct {
db *sql.DB
}
func New(db *sql.DB) *repository {
return &repository{db: db}
}
Why this rule exists:
- it keeps visibility obvious at the point of declaration
- it avoids separate access modifiers like
publicandprivate - it encourages small package APIs
Internal APIs with internal/
Go has a special internal directory rule. Packages inside internal/ can only be imported by code within the parent module tree.
This is useful when you want:
- separation between application-private code and reusable libraries
- freedom to refactor internals without pretending they are public APIs
That makes internal/ a strong default for service code.
Common Package Layout Patterns
There is no single mandatory project structure in Go. That is intentional. The language tries to avoid framework-enforced layout.
A common service layout looks like this:
graph TD
A[payments-service] --> B[go.mod]
A --> C[cmd/api/main.go]
A --> D[internal/httpapi]
A --> E[internal/service]
A --> F[internal/store]
A --> G[internal/config]
A --> H[internal/worker]
cmd/
Holds entry points for executables.
If one repository produces multiple binaries, cmd/ keeps them separate cleanly.
internal/
Holds application-private packages.
For most services, this is where the real code lives.
pkg/
Some repositories use pkg/ for packages intended to be imported by other modules. This is a convention, not a language rule. Use it only if it creates actual clarity.
Keep Layout Earned, Not Decorative
Beginners often copy large directory trees too early. If a project has only one executable and a few packages, keep it smaller until complexity genuinely arrives.
Modules, Dependencies, and Reproducibility
go.mod
go.mod declares the module path and dependency requirements.
module example.com/payments
go 1.25.0
require (
github.com/google/uuid v1.6.0
)
go.sum
go.sum records checksums for module content so builds can verify dependency integrity.
Why this matters:
- catches unexpected module tampering
- helps keep builds reproducible
- gives the module downloader integrity data
How Version Resolution Works
Go uses module version selection designed to be reproducible and understandable. The details can get deep, but the practical takeaway is:
- dependency versions are declared in
go.mod - the toolchain resolves one version per module in the build graph
- commands like
go mod tidykeep the graph clean
Useful commands:
go get github.com/google/uuid@latest
go mod tidy
go list -m all
Why Import Cycles Are Forbidden
Go does not allow cyclic package imports.
That may feel restrictive, but it enforces architectural clarity. Cycles usually signal packages that were split at the wrong boundary or abstractions that are too tangled.
In large systems, this restriction prevents dependency graphs from collapsing into spaghetti.
Designing Package Boundaries Well
Group by Responsibility, Not by Type Name Alone
Better:
authstorehttpapibilling
Weaker:
modelsutilshelpers
Packages should usually represent behavior or domain areas, not just a pile of vaguely related structs.
Define Interfaces Where They Are Consumed
Instead of putting every interface next to every implementation, define small interfaces at the consumer boundary when that improves decoupling and testing.
Keep APIs Small
If another package imports yours, what do they truly need? In Go, smaller package surfaces are easier to maintain and easier to refactor safely.
Testing in Go: A Built-In Culture
Testing is part of normal Go workflow, not an optional afterthought.
Where Tests Live
Tests live in files ending with _test.go.
store/
store.go
store_test.go
Functions that begin with Test are discovered by go test.
func TestParsePort(t *testing.T) {
port, err := parsePort("8080")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if port != 8080 {
t.Fatalf("got %d want %d", port, 8080)
}
}
Why Go's Testing Model Is Simple
The standard library's testing package is deliberately small. That simplicity means:
- the barrier to writing tests is low
- teams do not need a huge framework to get started
- test execution integrates naturally with the language toolchain
Table-Driven Tests
This is one of the most common idioms in Go.
func TestParsePort(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{name: "valid", input: "8080", want: 8080},
{name: "invalid", input: "abc", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parsePort(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("got %d want %d", got, tt.want)
}
})
}
}
Why this pattern is so useful:
- test cases stay compact
- edge cases become easy to enumerate
- adding new scenarios becomes mechanical rather than repetitive
Testing HTTP Handlers
For APIs, net/http/httptest is essential.
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d want %d", rec.Code, http.StatusOK)
}
}
Why this matters:
- lets you test handlers without a real network listener
- keeps tests fast and deterministic
- makes request/response behavior easy to inspect
Benchmarks
Performance-sensitive code can be benchmarked with the same toolchain.
func BenchmarkParsePort(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = parsePort("8080")
}
}
Run it with:
go test -bench=. -benchmem ./...
Why Benchmarks Matter
In backend systems, intuition about performance is often wrong. Benchmarks let you compare alternatives with data.
Be careful though:
- microbenchmarks can miss real I/O behavior
- unrealistic inputs produce misleading results
- one benchmark is not a system profile
Allocation Awareness
-benchmem shows allocation counts and bytes per operation. This is particularly helpful in Go because excess allocation often increases garbage collection pressure.
Fuzz Testing
Modern Go supports fuzz testing through the standard toolchain.
func FuzzParsePort(f *testing.F) {
f.Add("8080")
f.Add("abc")
f.Fuzz(func(t *testing.T, input string) {
_, _ = parsePort(input)
})
}
Why fuzzing is valuable:
- explores unexpected input combinations
- finds parser and validation edge cases
- is especially useful for network protocols, text parsing, and serialization code
Tooling That Should Be in Your Daily Workflow
go fmt
go fmt ./...
Standard formatting is one of Go's biggest team-level productivity wins.
go test
go test ./...
Run it constantly. Fast feedback is part of idiomatic Go engineering.
go test -race
go test -race ./...
This detects many data races in concurrent code. If you write goroutines and shared state, this is a critical tool.
go vet
go vet ./...
go vet looks for suspicious constructs that compile but are likely wrong.
go test -cover
go test -cover ./...
Coverage is useful as a signal, not a religion. High coverage does not guarantee meaningful tests, but very low coverage may show untested risk.
Useful External Tools
staticcheckfor deeper linting and bug findinggovulncheckfor known vulnerability detection in dependencies and reachable codegoimportsfor formatting plus import cleanuppproffor CPU and memory profiling
These are not built into the core language, but they fit naturally into Go's tooling culture.
Build and Release Workflows
Go makes builds operationally simple.
Build a Binary
go build ./cmd/api
Cross-Compile
GOOS=linux GOARCH=amd64 go build ./cmd/api
This is a practical reason Go is so common in infrastructure tools. Cross-platform binaries are relatively easy to produce.
Why Single-Binary Delivery Matters
In deployment pipelines, simplicity is leverage.
- containers become smaller and simpler
- startup is predictable
- dependency packaging becomes easier
- CI artifacts are easier to reason about
Documentation as Part of the API Surface
Go places real value on package and symbol documentation.
Exported identifiers should usually have comments when the package is intended for reuse.
Why this matters:
- tooling can surface docs automatically
- package APIs become easier to consume without reading implementation details
- maintainers communicate intent, not just mechanics
Real-World Usage Patterns
Service Repository Layout
A production service often has:
- one or more
cmd/entry points - internal packages for handlers, business logic, storage, and config
- tests next to each package
- benchmark and race-check jobs in CI
Testing Strategy
Healthy Go services usually combine:
- unit tests for pure logic
- handler tests with
httptest - integration tests for DB and network boundaries
- benchmarks for hot paths
- race detection for concurrent subsystems
Tooling in CI
A practical CI pipeline often runs:
go fmtor formatting checksgo test ./...go test -race ./...go vet ./...- optional static analysis and vulnerability scanning
Common Mistakes and Misconceptions
Mistake: Creating Huge Project Layouts Too Early
Structure should reflect real complexity. Decorative folders make learning and maintenance harder.
Mistake: Treating pkg/ as Mandatory
It is only a convention. Many good Go services never use it.
Mistake: Exporting Too Much
Large public package surfaces make refactoring harder and couple packages unnecessarily.
Mistake: Ignoring Import Cycles Until Late
If your packages keep wanting to import each other, the boundaries are probably wrong.
Mistake: Writing Only Happy-Path Tests
Most production failures happen in error paths, edge inputs, and timeout scenarios.
Mistake: Optimizing from Guesswork Instead of Benchmarks
Measure before changing code for performance reasons.
Mistake: Treating Coverage as the Goal
Coverage can be useful, but well-chosen tests matter more than inflated percentages.
Summary
Go packages and modules keep code organization and dependency management explicit. The testing package and standard toolchain make quality checks part of ordinary development rather than an extra framework burden.
The big practical lessons are:
- packages shape design boundaries
- modules shape dependency boundaries
- small APIs and clean imports keep code maintainable
- table-driven tests and
httptestare core testing patterns - benchmarks, race checks, and tooling provide objective feedback
- operational simplicity is one of Go's biggest strengths
The next step is to bring all of this together in production system design: HTTP servers, request lifecycles, context propagation, timeouts, graceful shutdown, observability, and distributed systems patterns in Go.