Skip to content

Moved the Tests, Coverage Went Up

Adityo Guni Waluyo

Moving Go tests into a central tree as external _test packages forces rewrites through the public API, deletes dead helpers, and coverage rises.

TL;DR

Moving sixteen test files into a central tree looked purely mechanical, yet coverage rose instead of staying flat. Renaming packages to entityadmin_test forced black-box testing through public APIs, exposing dead helpers and internal-only tests along the way. Now _test packages are my default, export_test.go stays an emergency valve, and "mechanical" commit messages get more skepticism.

That morning I ran go test ./... after moving sixteen test files out of internal/entityadmin/ into the central tree at api/tests/unit/entityadmin/. Not one line of production code changed. My assumption was reasonable: this was file-moving work, so coverage would sit still.

The guess missed in two places. First, in the modules whose migration finished earlier, coverage went up after the move, not down, not flat. Second, this string of move tests to central tree commits turned out to be far from mechanical. The commit messages say mechanical test relocation, but the diff tells another story: zero exported symbols, while tests that used to call internal functions like mapError, clampDays, or normalizeStatus had to be rewritten through public paths.

The package declaration that changes the rules

It comes down to one line: the moved tests no longer open with package entityadmin but with package entityadmin_test.

package entityadmin_test // not package entityadmin

import (
	"testing"

	"github.com/example/kota/internal/entityadmin"
)

In Go, tests in a package suffixed with _test may only use identifiers exported by the package under test. The official documentation calls this pattern black box testing [1], and Google's Go style guide reserves the _test suffix for tests that only touch a package's public API [2]. The compiler is the enforcer. Once the declaration changes, every window into internals closes automatically, with no extra linter and no promises made in code review.

The side effect showed up immediately. Handler tests now drive the real chi router through RegisterAdminRoutes, with requests built by httptest [3][5].

Small case tests like error mapping or status normalization stay covered too, this time as soon as the public versions of Create and Get get called. Dead helpers like routeCtx simply disappeared, because nothing calls them anymore.

Here is the part that ran against my intuition: coverage rose because of the cleanup. Paths that used to be reached through internal helpers turned out to be reachable from the public API, so coverage did not collapse even with tests restricted. The dead test code that got dropped also cut maintenance load. The forced package boundary doubled as an architecture cleaner for the test suite.

The concrete example that convinced me this was not cosmetic: small functions like slugify or isEmailFormat, which used to have their own test files, are now exercised through the public svc.Create and svc.CreateCategory entry points. The validation matrix stays intact, but everything enters through the same door a real caller would use.

Integration tests moved to api/tests/integration/ with their own build tags, so the daily go test ./... stays light while the heavy suite is invoked only when needed.

The emergency valve when internals really need testing

One case cannot be solved with good intentions alone: heavy internal logic that is simply unreachable from the public API. The Go standard library ships an official answer in the form of the export_test.go file, a test file inside the package that re-exports internals explicitly for testing purposes [4]. The bufio package in the standard library carries one; its content is just aliases to internal symbols.

I like this pattern because it is honest. One small file states the intent, is easy to find, and is easy to delete when the internal changes. The difference from the old white-box tests: the old ones scattered everywhere, while this one concentrates at a single auditable point.

What I took home

Once every module had gone through, the api/tests/ tree held one place for all tests, package names consistently suffixed with _test, and not a single production symbol opened up just for tests.

This kind of migration is cheap to review because production code stays untouched, but do not expect a plain git mv. The moment a package declaration turns into foo_test, the compiler forces tests to behave like an external client, and any test touching internals gets exposed at once.

For my next repository, the _test package becomes the default for unit tests, export_test.go stays an emergency valve only, and commit messages claiming mechanical will get a more cynical read from me.

Sources

Related articles