A binary built with `go build -cover` wrote nothing to GOCOVERDIR after an end-to-end run. Why?
answer
- the binary, not the test runner
- an environment variable must reach the process
- counters are written on the way out
- kill -9 discards everything measured
- flush explicitly, or exit gracefully
basics
~20 sAn instrumented binary writes its counter files when the process terminates normally, and only if GOCOVERDIR is set in its environment. A container or script that kills the server, or a crash, discards every counter — the run happened but nothing was recorded.
solid answer
~40 s`go build -cover` produces a normally-runnable binary with coverage counters compiled in. Two conditions must hold for data to appear: `GOCOVERDIR` must name an existing directory in the *binary's* environment, not the test runner's, and the process must reach a normal exit, because the runtime writes the meta-data and counter files on the way out. An end-to-end script that finishes by `kill -9`-ing the server, or a container the orchestrator SIGKILLs, throws the counters away and every path the script exercised reports zero. The fixes are to shut the server down gracefully, or to flush explicitly from the process using `runtime/coverage.WriteCountersDir`. Once files exist, `go tool covdata percent -i=dir` summarises them and `go tool covdata textfmt -i=dir -o=cover.out` converts them into a profile `go tool cover -func` and `-html` can read.
code
text · 7 linesgo build -cover -o ./bin/server ./cmd/server
mkdir -p /tmp/covdata
GOCOVERDIR=/tmp/covdata ./bin/server &
# ... run the end-to-end script, then SIGTERM the server and wait for it
go tool covdata percent -i=/tmp/covdata
go tool covdata textfmt -i=/tmp/covdata -o=cover.out
go tool cover -func=cover.outgo deeper
Know that coverage is not limited to go test: a binary can be built with go build -cover and it writes data into the directory named by GOCOVERDIR.
Explain the mechanics — meta-data plus per-run counter files, written when the process exits — and that go tool covdata converts them into a profile go tool cover can render.
Diagnose lost measurement: check that GOCOVERDIR reaches the process, that teardown sends SIGTERM rather than SIGKILL, and reach for runtime/coverage.WriteCountersDir when the service must keep running.
Decide whether integration coverage is worth the harness complexity at all, and insist the pipeline fails loudly on empty counter directories rather than silently reporting a number that understates reality.
## Coverage outside `go test` `go test -cover` measures what a test binary executes. It cannot measure what a *deployed* binary executes — a server started by a docker-compose-style harness, driven by an end-to-end script that speaks HTTP to it. For that the toolchain offers a second path: ``` go build -cover -o ./bin/server ./cmd/server ``` The result is an ordinary binary you can ship into a test environment, with counters compiled into every basic block. It behaves normally; the only difference is that on exit it emits coverage data. ## Where the data goes, and when The binary writes into the directory named by the **`GOCOVERDIR`** environment variable. Two files are produced per run: a meta-data file describing the packages and blocks (stable across runs of the same binary) and a counter file holding this run's counts. Several runs accumulate several counter files in the same directory, which is exactly the intended shape — start the server, run scenario A, restart, run scenario B, and the directory holds both. Two failure modes fall directly out of that design, and they are what the question is really about: 1. **`GOCOVERDIR` is not set in the binary's environment.** It is easy to export it in the shell that runs the test script while the server itself is started by a container runtime or a process supervisor that does not inherit it. The binary then runs happily and emits a warning to stderr about coverage data not being written — a line nobody reads in a busy CI log. 2. **The process never terminates normally.** The write happens on the way out of the process. A harness that ends with `kill -9`, an orchestrator that SIGKILLs the container at teardown, or a hard crash all skip it. The end-to-end run genuinely exercised the code; the evidence is simply gone, and the report shows zeros for paths you watched execute. The second one is the classic. The symptom — 'the script demonstrably hit that endpoint and the coverage report says the handler never ran' — points at measurement, not at the tests. ## Fixing it **Shut down gracefully.** Give the server a signal handler for SIGTERM that returns from `main` (or calls `os.Exit`) after draining, and have the harness send SIGTERM and wait rather than SIGKILL. This is the smallest change and it is usually a good idea for its own sake. **Or flush explicitly.** The `runtime/coverage` package lets an instrumented process write its own data mid-life: ```go if err := coverage.WriteCountersDir(os.Getenv("GOCOVERDIR")); err != nil { log.Printf("coverage flush failed: %v", err) } ``` Call it from an admin endpoint or a signal handler, and the counters are on disk before anything can kill the process. `coverage.WriteMetaDir` writes the meta-data file, and `coverage.ClearCounters` resets counters so you can attribute a following scenario separately. This is the approach for a long-running service you deliberately never restart during the test window. ## Reading the result The files in `GOCOVERDIR` are a binary format, not the text profile `go tool cover` consumes. `go tool covdata` bridges the two: ``` go tool covdata percent -i=/tmp/covdata go tool covdata func -i=/tmp/covdata go tool covdata merge -i=/tmp/run1,/tmp/run2 -o=/tmp/merged go tool covdata textfmt -i=/tmp/merged -o=cover.out go tool cover -html=cover.out -o cover.html ``` `percent` and `func` give you numbers directly. `merge` combines several directories — which is how you fold three scenario runs into one result, and notably something the text profile format cannot do. `textfmt` converts to the familiar profile so the HTML renderer and any existing tooling still work. ## Why this matters operationally A coverage number that mixes unit tests and end-to-end runs is only trustworthy if the end-to-end half is actually being captured. Silent loss is the dangerous case: nobody notices a missing counter file, the total quietly drops, and the team responds by writing more unit tests for code the integration suite already covers. Making the harness fail loudly when `GOCOVERDIR` ends up empty is a cheap guard, and it turns an invisible measurement bug into a build failure.
- How do you turn the files in GOCOVERDIR into an HTML report?They are a binary counter format, so convert first: `go tool covdata textfmt -i=/tmp/covdata -o=cover.out`, then `go tool cover -html=cover.out -o cover.html`. For a quick number without the conversion, `go tool covdata percent -i=/tmp/covdata` prints per-package percentages directly.
- You ran three end-to-end scenarios against three server restarts. How do you get one combined result?Point each run at its own GOCOVERDIR, then `go tool covdata merge -i=/tmp/run1,/tmp/run2,/tmp/run3 -o=/tmp/merged` and read the merged directory. Merging is a genuine advantage of the counter-data format — the older text profile has no merge operation, so combining runs there means running one command over everything.
- How do you make this failure loud instead of silent?Have the harness assert that GOCOVERDIR is non-empty after the run and fail the job if it is not, and check the server's stderr for the toolchain's warning about coverage data not being written. Silent loss is worse than no measurement, because the number keeps being reported and quietly understates reality.
- Can you collect coverage from a server you never restart during the test window?Yes — expose an internal endpoint or signal handler that calls `runtime/coverage.WriteCountersDir` with the GOCOVERDIR path, and optionally `ClearCounters` between scenarios so each one is attributable. That removes the dependency on process exit entirely, which is the right shape for a long-lived service.
The odometer only writes its total to disk when you switch the engine off properly. Yank the battery at the end of the trip and the drive never happened.
saying these in an interview costs you the question
- Assumes coverage data is written continuously during the run
- Sets GOCOVERDIR in the harness shell, not the server's environment
- Ends the harness with kill -9 and trusts the report
- Tries to open the GOCOVERDIR files with go tool cover directly
- Concludes the endpoint was never hit rather than never recorded