The feedback loop starts with go test
You push a commit. The CI runner spins up. Ten minutes later, a red cross appears. You refresh. The build failed on a test that passed locally. Or worse, it passed locally but failed in CI because of a race condition that only triggers under high load. The feedback loop is broken. Go projects can build and test in seconds, but a misconfigured pipeline turns a quick check into a waiting game. You need a CI setup that runs fast, catches real bugs, and gives you coverage data without slowing down the team.
Continuous Integration for Go
Continuous Integration runs your tests automatically on every change. The goal is to catch regressions before they merge. Go helps here. The toolchain is designed for speed. Modules handle dependencies deterministically. You don't need a virtual environment like Python or a lockfile dance like Node. You just need to tell the runner to fetch modules, compile, and test. The trick is caching. Without caching, every build downloads the entire internet of Go modules. With caching, the second build takes seconds.
Go treats tests as first-class citizens. Test files live alongside production code in _test.go files. The compiler runs the same type checks on test code as it does on library code. If your test uses the wrong type, the build fails before execution. This strictness prevents tests from silently passing due to copy-paste errors.
Minimal pipeline setup
Here's the bare minimum to run tests in a GitHub Actions workflow. This configuration checks out the code, installs the Go toolchain, and executes the full test suite.
name: Test
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
# Fetch the repository code
- uses: actions/checkout@v4
# Install Go toolchain from official action
- uses: actions/setup-go@v5
with:
go-version: '1.21'
# Run tests across all packages
- run: go test ./...
The go test ./... command is the workhorse. The ./... pattern tells Go to recurse into every subdirectory. If you have a cmd/ directory with a main.go but no tests, Go skips it silently. If a package has tests, Go runs them and reports pass or fail. The exit code determines whether the CI job succeeds. A non-zero exit code fails the job.
Trust the exit code. If go test fails, the job fails.
What happens under the hood
When the runner executes go test ./..., the tool performs three distinct phases. First, it resolves dependencies using go.mod. If the modules are cached, it skips the network download. Second, it compiles the test binaries. Go compiles tests as separate programs, which means the compiler catches type errors in your test code just like it does for production code. Third, it executes the binaries and prints the results.
Go runs packages in parallel by default. If you have ten packages with tests, Go will launch multiple test processes simultaneously, limited by the number of CPU cores available. This parallelism happens automatically. You don't need to configure it. The tool respects GOMAXPROCS, so on a CI runner with eight cores, tests will utilize all eight cores.
The compiler also runs go vet automatically during go test. This catches suspicious constructs like incorrect printf formatting or unreachable code. If you pass a string to a %d format specifier, the compiler rejects the program with vet: printf: format %d reads arg 1, have only 0 args. This static analysis runs for free with every test invocation.
Parallel execution speeds up the suite. Run go test and let the tool manage concurrency.
Production-ready pipeline
Production pipelines do more than just run tests. They check formatting, run the race detector, and generate coverage reports. Here's a workflow that adds caching, linting, and coverage.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Cache module downloads to skip network fetch on subsequent runs
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
# Key changes when go.mod changes, invalidating stale caches
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}
- uses: actions/setup-go@v5
with:
go-version: '1.21'
# Verify go.mod is tidy and imports are clean
- run: go mod tidy
- run: go mod verify
The cache step targets ~/go/pkg/mod, which is the GOMODCACHE directory. This stores downloaded modules. The cache key includes a hash of go.mod. If you update a dependency, the hash changes, the cache misses, and the runner downloads fresh modules. This ensures the cache never serves stale versions.
Run go mod tidy in CI to enforce module hygiene. If the command modifies go.mod or go.sum, the build should fail. This prevents drift between the repository state and the resolved dependency graph.
The next block adds the race detector and coverage output.
# Run tests with race detection and coverage output
# -race finds data races; -coverprofile writes coverage data
- run: go test -race -coverprofile=coverage.out ./...
# Upload coverage data as an artifact for later processing
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
The -race flag enables the race detector. This instruments the code to detect concurrent access to shared memory without synchronization. It catches data races that unit tests might miss. The race detector adds overhead, slowing tests by 5 to 10 times. Run it in CI even if you skip it locally. The race detector finds bugs your tests can't see. Run it or risk production panics.
The -coverprofile flag writes coverage data to a file. This file contains line-by-line execution counts. You can upload it as an artifact and process it later to generate badges or enforce thresholds.
Optimizing speed and reliability
Caching modules is only half the battle. Go also caches build artifacts in GOCACHE. This directory stores compiled object files. If you cache GOCACHE alongside GOMODCACHE, the runner can skip recompilation for unchanged packages. This reduces build time significantly on incremental changes.
# Cache build artifacts to skip recompilation of unchanged packages
- name: Cache Go build
uses: actions/cache@v4
with:
path: ~/.cache/go-build
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.mod') }}
The build cache key should also depend on go.mod. If dependencies change, the build cache might need invalidation, though Go's build cache is robust and often handles dependency changes gracefully. Caching both directories turns a 30-second build into a 5-second build on subsequent runs.
Cache the modules, save the minutes.
Some projects have long-running integration tests that query a database or call external APIs. Running these on every commit slows the pipeline. Use the -short flag to skip long tests. Tests can check testing.Short() to decide whether to run.
func TestIntegration(t *testing.T) {
// Skip this test when the -short flag is passed
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Run expensive setup and assertions
}
In CI, you can run go test -short ./... for fast feedback on unit tests, and trigger the full suite including integration tests only on pull requests to the main branch. This balances speed with thoroughness.
Coverage thresholds enforce quality, but they don't replace reading the code.
Common pitfalls
Flaky tests are the enemy of CI. A flaky test passes sometimes and fails other times. This usually happens when a test depends on time, random values, or non-deterministic ordering. If a test fails in CI but passes locally, the team loses trust in the pipeline. Fix the test. Don't retry the test. Retrying hides the bug and wastes compute resources.
Race conditions are another trap. Code that works on a single-core machine might fail on a multi-core CI runner. The race detector catches these issues. If the race detector reports a race, the test fails with a stack trace showing the conflicting reads and writes. Treat race detector failures as critical bugs. They indicate undefined behavior that can corrupt data in production.
Coverage merging can be tricky if you split tests across multiple jobs. Running go test -coverprofile per package creates separate files. You need a tool to merge them. Running go test ./... merges coverage automatically into a single file. Stick to a single job for coverage generation unless the suite is massive.
Formatting drift causes noise. Run gofmt -l . in CI to ensure formatting matches the tool's output. If the command prints filenames, the formatting is off. Fail the build to enforce consistency. Most editors run gofmt on save, so this check catches manual edits or tooling mismatches.
The worst test bug is the one that never logs. Ensure tests output useful information on failure. Use t.Log to record context that appears only when a test fails. This makes debugging CI failures faster.
Decision matrix
Use go test ./... when you need to verify the entire codebase on every push.
Use go test ./pkg when you want to target a specific package for faster feedback during development.
Use go test -race when you want to detect data races in concurrent code, even if it slows the run.
Use go test -cover when you need a quick percentage of line coverage without generating a file.
Use go test -coverprofile=coverage.out when you need to upload coverage data to a dashboard or badge service.
Use go test -short when you want to skip long integration tests for rapid iteration.
Use go vet when you want to catch suspicious constructs like incorrect printf formatting or unreachable code.
Use gofmt -l . when you want to enforce formatting consistency across the team.
Use go mod tidy when you want to ensure the module graph is clean and imports are minimal.