Skip to content
7 min read·Lesson 5 of 8

Modules, Testing, and Tooling

Project layout, modules, dependencies, testing, benchmarks, profiling, and the standard tooling every Go developer uses.

Go's tooling is unusual because most of it ships with the language. go test, go build, go vet, go mod, the profiler, and the race detector are all built in. This lesson covers the bits you use every day.

Project Layout

There is no enforced layout, but a widely accepted convention:

myapp/
├── go.mod
├── go.sum
├── cmd/
│   ├── server/
│   │   └── main.go          # binary: myapp-server
│   └── cli/
│       └── main.go          # binary: myapp-cli
├── internal/                 # private packages — Go enforces this
│   ├── store/
│   ├── handlers/
│   └── config/
├── pkg/                      # public packages (use sparingly)
├── api/                      # OpenAPI / protobuf schemas
├── deploy/                   # Helm charts, k8s manifests
└── Makefile

The internal/ directory has compiler-enforced visibility: anything inside internal/ can only be imported by code in the same module tree. Use it generously — most code should be in internal/.

Modules

A module is a collection of packages versioned together. go.mod declares the module path and dependencies; go.sum records cryptographic hashes for reproducible builds.

module github.com/acme/myapp

go 1.23

require (
    github.com/spf13/cobra v1.8.1
    github.com/aws/aws-sdk-go-v2 v1.32.4
    github.com/stretchr/testify v1.10.0
)

require (
    // indirect dependencies pulled in automatically
    github.com/davecgh/go-spew v1.1.1 // indirect
)

Common commands:

go mod init github.com/acme/myapp     # start a new module
go get github.com/spf13/cobra@v1.8.1  # add or upgrade a dep
go get -u ./...                       # upgrade all to latest minor/patch
go mod tidy                            # remove unused; add missing
go mod download                        # populate the module cache
go mod why github.com/foo/bar         # explain why a dep is needed

Testing

Tests live next to the code they test, in files ending _test.go:

// store/memory.go
package store

func (m *MemoryStore) Get(key string) (string, error) { ... }

// store/memory_test.go
package store

import "testing"

func TestGet(t *testing.T) {
    s := NewMemoryStore()
    s.Put("k", "v")
    got, err := s.Get("k")
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got != "v" {
        t.Errorf("got %q, want %q", got, "v")
    }
}
go test ./...                     # all packages
go test ./store -v                # one package, verbose
go test -run TestGet ./store      # one test by name
go test -race ./...               # with race detector
go test -cover ./...              # with coverage
go test -coverprofile=c.out ./...; go tool cover -html=c.out

Table-Driven Tests

The dominant idiom. One test function, a table of cases:

func TestDivide(t *testing.T) {
    cases := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr bool
    }{
        {"basic", 10, 2, 5, false},
        {"zero divisor", 1, 0, 0, true},
        {"negative", -10, 2, -5, false},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got, err := Divide(tc.a, tc.b)
            if (err != nil) != tc.wantErr {
                t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
            }
            if got != tc.want {
                t.Errorf("got %v, want %v", got, tc.want)
            }
        })
    }
}

t.Run creates a sub-test — each case shows up as its own pass/fail in the output. Easy to grep, easy to extend.

Testify and Other Assertion Libraries

The standard library deliberately omits assertions — the style is "compare and call t.Errorf." Many teams use testify for shorter assertions:

import "github.com/stretchr/testify/assert"

assert.Equal(t, "v", got)
assert.NoError(t, err)
assert.Contains(t, names, "alice")

Both styles are common; pick one per project and stick with it.

Benchmarks

func BenchmarkParse(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _, _ = Parse(input)
    }
}
go test -bench=. -benchmem ./...

Output includes ns/op and allocs/op. The b.N is auto-tuned by the framework. -cpuprofile, -memprofile, and -trace integrate with go tool pprof.

Profiling

For long-running services, expose pprof over HTTP:

import _ "net/http/pprof"

go func() { http.ListenAndServe("localhost:6060", nil) }()

Then attach:

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof http://localhost:6060/debug/pprof/heap
go tool pprof http://localhost:6060/debug/pprof/goroutine

Inside pprof: top, list FunctionName, web (renders a graph). pprof's flame graphs are also one of the better tools in the ecosystem.

Linting and Formatting

gofmt is built in and non-negotiable — every Go file in every repository is gofmt'd. Editors do this on save. goimports goes further and manages your import blocks.

For static analysis run go vet (built in) and golangci-lint (the meta-linter that bundles 50+ analyzers). A typical config:

# .golangci.yml
linters:
  enable:
    - errcheck
    - gosimple
    - staticcheck
    - revive
    - govet
    - ineffassign
    - unconvert
    - misspell

Wire it into CI and your IDE.

Building Binaries

go build ./cmd/server                          # produce ./server
go build -o /tmp/server ./cmd/server

# Smaller binary (strip debug info)
go build -ldflags="-s -w" ./cmd/server

# Inject a version
go build -ldflags="-X main.version=$(git describe)" ./cmd/server

# Cross-compile
GOOS=linux  GOARCH=amd64 go build ./cmd/server
GOOS=darwin GOARCH=arm64 go build ./cmd/server
GOOS=windows GOARCH=amd64 go build ./cmd/server

# Fully static (no cgo) — required for FROM scratch containers
CGO_ENABLED=0 GOOS=linux go build ./cmd/server

Container Images

Two-stage Dockerfile, the standard pattern:

FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/server"]

Final image is ~12-30 MB, no shell, no package manager, non-root.

Tools Worth Knowing

The toolchain is small and intentional. Once you internalise it, day-to-day Go development is one of the smoothest experiences in mainstream programming.

Key Takeaways

  • A module is the unit of versioning; go.mod / go.sum record the module and its dependencies.
  • The testing package is built in — no JUnit-style framework needed.
  • Table-driven tests are the dominant Go testing pattern.
  • go vet, staticcheck, golangci-lint, gofmt, and goimports are non-negotiable.
  • go build produces static binaries; cross-compile with GOOS / GOARCH.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →