Files
Computer-Fundamentals/go/04_packages_testing_and_tools.md
tarun-elango be31df2d44 more text
2026-04-26 14:09:04 -04:00

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:

  • fmt
  • net/http
  • time
  • 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:

  • store
  • auth
  • queue
  • config

Less ideal examples:

  • storeutils
  • commonhelpers
  • myAmazingPackage

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 public and private
  • 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 tidy keep 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:

  • auth
  • store
  • httpapi
  • billing

Weaker:

  • models
  • utils
  • helpers

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

  • staticcheck for deeper linting and bug finding
  • govulncheck for known vulnerability detection in dependencies and reachable code
  • goimports for formatting plus import cleanup
  • pprof for 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 fmt or formatting checks
  • go 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 httptest are 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.