Zero Percent Coverage While Every Test Passes
Moving tests to a centralized tree makes per-package coverage read zero percent; one -coverpkg flag on the coverage mode restores the number.
TL;DR
Moving Go tests into a centralized external package caused coverage to drop to 0.0% even though everything still passes, because go test only instruments the package under test by default. The fix is the -coverpkg flag, which tells the tool which packages to measure. Execution and coverage are separate paths, so CI needs no changes.
The final lines of a test report showed two contradictory things: every test passed, while the package holding nearly all the business logic was recorded as coverage: 0.0% of statements. No errors, no failing tests, just a zero standing where a full number belonged.
This followed one restructuring step: every test file moved from beside the code into a centralized test tree, split into unit and integration directories, with package names suffixed _test importing the module's packages as a black box. CI did not change and still runs go test ./..., so every test still executes. Only the number changed: from a reasonable figure straight to zero.
The Default Rule: Each Test Measures Its Own Package
The official semantics sit in the go test flag documentation: the patterns given to -coverpkg "Apply coverage analysis in each test to packages whose import paths match the patterns. The default is for each test to analyze only the package being tested" [1]. Mapped onto the situation above: once tests live in a different package, the package each test measures is no longer the package whose code runs.
The mechanism is at the instrumentation stage. When go test -cover runs, Go selects specific packages to receive counters; other packages that get called along the way are not instrumented at all [2]. A black-box test still executes the code under the internal tree from start to finish, but not a single line there is recorded. The zero is honest: as far as the tool knows, nothing there was measured.
Old habits hid this rule. While tests lived in the same package, the measured package and the tested package were the same object, so the default felt like it measured everything. Package-level coverage measurement is not a new feature; the mechanism has existed since Go 1.2 [4]. What changed in this case was only where the tests live.
A display change also makes this trap easier to recognize today. Before Go 1.22, a package with no test files of its own was reported as [no test files]. Since Go 1.22, functions in such a package are treated as uncovered and reported explicitly: coverage: 0.0% of statements [3]. In other words, this condition is not broken; it is an accurate report of an empty measurement.
A small demo on Go 1.27.1 settles any remaining doubt. One module contains one internal package with two functions and one external test package exercising them. Without the extra flag, the output matches the case exactly; with one flag, the final line changes.
$ go test -count=1 -cover ./...
demo.example/coverdemo/internal/greeter coverage: 0.0% of statements
ok demo.example/coverdemo/tests 0.003s coverage: [no statements]
$ go test -count=1 -cover -coverpkg=./internal/... ./...
demo.example/coverdemo/internal/greeter coverage: 0.0% of statements
ok demo.example/coverdemo/tests 0.003s coverage: 75.0% of statements in ./internal/...
Note that the 0.0% line for the tested package remains in the second run: that line reflects the absence of tests inside the package itself. The aggregate figure is the one that appears on the ok line, complete with a suffix naming the measurement scope.
One Flag on the Coverage Mode
The fix is not moving tests back; it is telling the tool which packages must receive counters. On the repository that motivated this migration, that is registered as its own mode in the runner script, separate from the daily test run:
go test -count=1 -p 1 -tags=integration -cover \
-coverpkg=./internal/... ./...
The number this mode prints can be trusted because of two details. First, the output shape is pinned by an official regression test in cmd/go since the fix for issue #58770, which ended misleading reports on Go 1.19 and 1.20 when every package was force-imported [5]. The in ./internal/... suffix on that percentage is part of the output contract, not decoration. Second, the -p 1 flag on the same command serves a different need: all integration tests share one test database, so tests must not run in parallel. The two are independent and may appear together.
To read the result per function, save a profile and render it:
go test -count=1 -tags=integration -p 1 -coverprofile=coverage.out \
-coverpkg=./internal/... ./...
go tool cover -func=coverage.out
The total: line of that last output is the number worth reporting to the team, not the per-package figure seen in a run without the flag.
Execution and Measurement Are Two Different Paths
CI needs no changes: go test ./... executes every test package wherever its files live. The moved tests keep running; what does not follow them is their measurement. That is why the coverage mode was split out rather than merged into the daily run: the plain run stays free of instrumentation compile cost, and that cost is only paid when the number is actually needed.
The check pattern is reusable in any repository that just moved tests out of their packages. When a suite is green but the number is zero, run the two demo commands above on the smallest representative module. If one flag turns 0.0% into a real figure, the diagnosis is complete without touching a single production line. If it does not, only then look for other scenarios, such as build tags hiding tests from the ./... pattern.