Integration tests

Run integration tests by building with coverage flags, executing with GOCOVERDIR, and analyzing results with go tool covdata.

When isolation breaks

You spend three days writing unit tests. Every function returns the expected value. Every edge case is covered. You deploy to staging and the application crashes on the first request. The database driver times out. The JSON unmarshaler panics on a field your mock never included. The code works in isolation. It falls apart when the pieces touch.

What integration tests actually do

Integration tests verify that separate components work together. They replace mocks with real databases, real HTTP servers, or real file systems. The goal is not to test your logic in a vacuum. The goal is to catch the friction that happens when your code talks to the outside world. Think of it like testing a bicycle. Unit tests check if the chain moves and if the brakes squeeze. Integration tests check if pedaling actually moves the bike forward without the chain slipping off.

In Go, integration tests live alongside your regular tests but run differently. They often require external services, temporary directories, or network sockets. The standard library gives you the tools to manage them. You just need to know where to draw the line between fast unit tests and slower integration tests. Keep the boundary explicit. Your CI pipeline will thank you.

The minimal test structure

Here is the skeleton of an integration test that starts a real HTTP server and verifies the response.

package main

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

// TestIntegrationHandler verifies the HTTP handler against a real server.
func TestIntegrationHandler(t *testing.T) {
    // httptest creates a real TCP listener in the background.
    // It avoids mocking the transport layer entirely.
    server := httptest.NewServer(http.HandlerFunc(myHandler))
    defer server.Close() // ensures the listener shuts down after the test

    // Send a real HTTP request to the temporary server address.
    resp, err := http.Get(server.URL)
    if err != nil {
        t.Fatalf("request failed: %v", err)
    }
    defer resp.Body.Close() // prevents file descriptor leaks

    // Assert the status code matches the expected behavior.
    if resp.StatusCode != http.StatusOK {
        t.Errorf("expected 200, got %d", resp.StatusCode)
    }
}

The test runs against a live server. It does not stub the http package. It does not fake the network stack. The only difference from production is that the server runs on a random local port instead of 0.0.0.0:8080. Name your test files with an _integration_test.go suffix or use a build tag. The Go community expects clear separation between fast unit tests and slower integration tests.

How the test runner executes your code

When you run go test ./..., the compiler builds a test binary for each package. It links your package code, the testing package, and any test files ending in _test.go. The runtime calls TestMain if it exists, then runs each Test* function. If TestMain is present, it receives a testing.M pointer. You use it to set up shared resources like a temporary database or a test container. The function returns an exit code that go test translates into pass or fail.

Integration tests often need to skip when running in CI without the right environment, or when a developer just wants fast feedback. Go handles this with build constraints. You place //go:build integration at the top of the file. The compiler ignores the file unless you pass -tags=integration to go test. This keeps your default test suite fast while keeping integration tests in version control.

The testing.T object provides t.Cleanup for resource management. It runs in reverse order after the test finishes, even if the test fails. It is safer than defer because the cleanup runs at the exact right moment in the test lifecycle. Use t.Parallel() only when tests are fully isolated. A single hanging integration test will block the entire suite if you run them sequentially. Trust the test runner. Isolate your state.

Collecting coverage from a real binary

Coverage collection behaves differently for integration tests. The standard go test -cover flag instruments the code at compile time and writes a single coverage profile. That works fine when the test binary runs the code directly. It breaks when you build a standalone binary and run it separately. The test runner and the application become two different processes. The coverage data never merges.

Go solves this with GOCOVERDIR. You build the binary with -cover, run it with the environment variable set, and let the runtime write per-function coverage data to disk. You merge the results afterward.

Here is the workflow for collecting coverage from a running binary.

# Create a clean directory for coverage data files.
# The runtime writes one file per function or package.
mkdir -p ./covdata

# Build the binary with coverage instrumentation enabled.
# The -cover flag tells the compiler to inject tracking code.
go build -cover -o ./myapp .

# Run the binary with GOCOVERDIR pointing to the output folder.
# The process writes coverage counters to disk on exit.
GOCOVERDIR=./covdata ./myapp

# Merge the scattered files into a single percentage report.
# The covdata tool reads the directory and aggregates results.
go tool covdata percent -i=./covdata

The runtime writes small files as functions execute. When the process exits cleanly, the counters flush to disk. If the process crashes, the last few counters might be lost. That is acceptable for integration testing. You are measuring broad coverage, not line-by-line precision.

You can also feed the merged data into go tool cover -html to generate a browser report. The toolchain expects the raw profile format, so you run go tool covdata textfmt -i=./covdata -o=coverage.out first. The HTML report highlights which lines executed and which stayed cold. Coverage tools measure execution paths, not code quality. Aim for high coverage on critical paths, not 100 percent on boilerplate.

Pitfalls and runtime failures

Integration tests introduce state. State introduces flakiness. The most common failure comes from shared resources. Two tests run in parallel and both try to create the same database table. The second one fails with table already exists. The fix is simple. Use unique table names or transaction rollbacks. Never assume the database is empty at the start of a test.

Another trap is environment mismatch. Your local machine uses Go 1.22. The CI runner uses Go 1.21. The standard library changes slightly between minor versions. A test that passes locally might fail in CI because of a subtle change in error formatting or TLS defaults. Pin your Go version in CI. Run integration tests against the same version you ship.

The compiler and runtime will catch mistakes early. If you forget to mark a test function with t *testing.T, the compiler rejects it with cannot use t as type testing.T in argument to TestMain. If you try to run a test that imports a package without using it, you get the familiar imported and not used error. If your test panics, the runtime prints panic: runtime error: invalid memory address or nil pointer dereference and marks the test as failed. Read the stack trace. Integration tests fail loudly when dependencies are missing.

Coverage tools also have quirks. The GOCOVERDIR approach only tracks code executed by the binary you built. It does not track code in external dependencies unless you build them with coverage too. You will see lower percentages than unit tests. That is normal. Integration tests verify behavior, not code density.

Keep test data out of version control. Store fixtures in a testdata directory. The Go toolchain ignores testdata during compilation, which keeps your build artifacts clean. Read files using filepath.Join("testdata", "input.json") so the path works regardless of the current working directory. Never hardcode absolute paths in tests. Relative paths travel with the repository.

Choosing the right test level

Use unit tests when you need to verify pure logic, validate edge cases, and run feedback in under a second. Use integration tests when you need to verify that your code interacts correctly with databases, message queues, or external APIs. Use end-to-end tests when you need to verify the complete user journey across multiple services and the browser. Use compiler-level tests when you are writing a custom Go toolchain extension or verifying low-level code generation behavior. Use build tags when you want to keep slow tests in the repository but exclude them from default CI runs.

Where to go next