Collect Coverage From Integration Tests and Whole Binaries
Build a Go program with go build -cover, run it several times with GOCOVERDIR, then merge the runs and read the percentages with go tool covdata.

go test -cover measures the code that unit tests execute. A lot of the code in a service is exercised by something else: an end-to-end test that starts the real binary and sends it requests, a smoke test in CI, a manual session. Before Go 1.20 that execution was invisible to the coverage tool. Since Go 1.20 you can build any program with coverage instrumentation, run it as often and as differently as you like, and ask what it executed.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article does it for a small command line program on Go 1.25.6, merges runs from two separate sessions and reads the result. The unit test side is in the coverage article, and the testing hub is the Go testing guide.
The program
package main
import (
"fmt"
"os"
)
func greet(name string) string { return "hello, " + name }
func farewell(name string) string { return "goodbye, " + name }
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: app hello|bye NAME")
os.Exit(2)
}
switch os.Args[1] {
case "hello":
fmt.Println(greet(os.Args[2]))
case "bye":
fmt.Println(farewell(os.Args[2]))
default:
fmt.Fprintln(os.Stderr, "unknown command")
os.Exit(2)
}
}The program has two commands and two error paths. greet and farewell are the logic; main dispatches and exits with status 2 on bad input. It is small enough to count the branches by eye, which is the point: you can check the tool's numbers against what you know.
Build, run, merge
go build -cover -o app .
mkdir -p cov1 cov2 merged
GOCOVERDIR=cov1 ./app hello gopher
echo "-- one run:"
go tool covdata percent -i=cov1
GOCOVERDIR=cov2 ./app bye gopher
GOCOVERDIR=cov2 ./app nope x 2>/dev/null || true
echo "-- a second set of runs:"
go tool covdata percent -i=cov2
go tool covdata merge -i=cov1,cov2 -o=merged
echo "-- merged:"
go tool covdata percent -i=merged
go tool covdata textfmt -i=merged -o=c.txt
go tool cover -func=c.txthello, gopher
-- one run:
example.test/lab coverage: 40.0% of statements
goodbye, gopher
-- a second set of runs:
example.test/lab coverage: 60.0% of statements
-- merged:
example.test/lab coverage: 80.0% of statements
example.test/lab/main.go:8: greet 100.0%
example.test/lab/main.go:10: farewell 100.0%
example.test/lab/main.go:12: main 75.0%
total: (statements) 80.0%The key steps are three.
Build with -cover. go build -cover -o app . produces an instrumented binary. It is somewhat slower and larger than a normal build, so build it only for testing; never ship it. You can also restrict what is instrumented with -coverpkg, which matters for a real service: -coverpkg=./... instruments every package in the module, not only main.
Set GOCOVERDIR. When the instrumented binary exits normally, it writes its counters to files in the directory named by this variable. With no variable set, it prints a warning and writes nothing. The directory must exist. Run the program any number of times against the same directory and the files accumulate, one set per process.
Read the data. go tool covdata has several subcommands. percent -i=dir gives the statement percentage across all runs in the directory. The first run, which did hello gopher, covered 40.0%: it reached the hello case and greet, and nothing else. The second set of runs used a different directory: bye gopher and an unknown command, which added farewell and the default case, for 60.0%.
merge -i=cov1,cov2 -o=merged combines directories into one, and percent on it gives 80.0%. This is how you combine the coverage of separate jobs: an end-to-end suite, a smoke test and a scenario script each write to their own directory, and a final step merges them. Merging unit test coverage and binary coverage is possible too, because both use the same counters. Run the unit tests with go test -cover -args -test.gocoverdir=dir to write their data into a directory in the same format.
Which branch is missing
textfmt converts the directory to the older text profile, c.txt, which go tool cover understands. go tool cover -func=c.txt is the per function table at the end of the output. greet and farewell are at 100%. main is at 75.0%, and since it has four statements that matter, one is untouched: the usage message for too few arguments. No run passed fewer than three arguments. The HTML view, go tool cover -html=c.txt, shows the same thing in red.
That is the practical use. After an end-to-end suite you can see which code the suite never reached, and add a scenario for it, or accept that it is covered at a lower level. Compared with unit test coverage, the questions differ: a unit test reaches the usage error with a direct call to a function, and an end-to-end test needs a process invocation with the wrong arguments. A branch uncovered by the suite and covered by units is fine. One covered by neither is a gap.
Servers and long-running processes
A program that runs forever never exits normally, so it never writes counters. Three approaches work.
- Stop it with a clean exit. If the server handles
SIGTERMby shutting down and returning frommain, the counters are written whenmainreturns. This is the case for a program with graceful shutdown, and one more reason to have it. - Write on demand. The
runtime/coveragepackage hasWriteCountersDirandWriteMetaDir. A debug-only endpoint or a signal handler that calls them writes the data without stopping the process. - Clear between scenarios.
coverage.ClearCountersresets the counters, so you can measure what a single scenario covered.
os.Exit skips deferred functions and, for an instrumented binary, the normal exit hook is the thing that writes the counters. Go handles the common os.Exit path, and a process killed with SIGKILL or a crash writes nothing. If a test run produced no files, check how the process ended before blaming the flag.
Docker and CI
Build the instrumented binary in the same image your tests use, mount a volume at the GOCOVERDIR path, and collect the files after the container stops. In a container the process often runs as PID 1 and may ignore SIGTERM unless it handles it, so confirm the files appear. File permissions on the mounted directory are another usual cause of an empty result.
In CI, keep the steps in order: build with -cover, run the end-to-end suite with GOCOVERDIR set to a clean directory, covdata percent for the log, and covdata textfmt plus go tool cover -func for a table to upload as an artefact. Clean the directory before each run; counter files belong to the build that wrote them, and mixing files from different builds in one directory gives confusing results.
What to read into the number
Integration coverage is a coarser instrument than unit coverage. A request that reaches a handler counts every statement it passes, and whether the response was correct is, as ever, not part of the number. The statement-versus-assertion gap in the previous article applies without change. Use the figure to find code that no scenario ever touched, not to certify the scenarios that did.
The Go team's blog post on code coverage for Go integration tests describes the design, and the runtime/coverage package documents the functions for writing counters from inside a process.


