Three Seams an External Test Package Forces
Moving tests out of the package is not a file operation. Three Go modules forced three seam shapes: a TTL state reader, injected sleep, a mirror oracle.
TL;DR
Moving Go tests outside their package turned out to be a contract audit: every hidden internal needed an exported seam. Three flavors emerged—a status reader (StoredTTL) to skip sleeps, dependency injection (RetryConnect's swappable sleep), and a mirror oracle (AllModules) to prevent tautological tests. Production behavior stayed completely unchanged.
The plan was simple: move the test files to a centralized tree, clean up the import paths, and be done. I initially assumed moving cache_test.go into the centralized unit-test tree was merely a directory change. The reality was immediate compilation failure. The file still carried the package cache clause in its new location, and Go compiles all files within a single directory into one package [2][3]. From an external package, unexported identifiers are completely invisible. This exact pattern repeated across the database and scope modules. Three modules, three contracts previously hidden behind the package boundary, now forcing concrete design decisions one by one.
The consequence was a shift in testing discipline. Every assertion must now pass through an exported API. The resulting decisions were not uniform. This single relocation produced three distinct flavors of testing seams.
Status Reader Replacing Sleep
The TTL test in the cache module had a contract with no read door. Items inside go-cache have a maximum age, and the only behavioral way to verify this was to sleep past it. Sleep-based tests are inherently neither fast nor reliable [1].
The solution was to export Cache.StoredTTL(key string) (time.Duration, bool). The remaining TTL is read directly from the item's Expiration minus the current time in UnixNano. The source of truth remains singular. It uses the same data structure as the cache runtime, eliminating any duplication of expiration logic in the test.
Two properties of go-cache are verified directly in its source. First, the Items method only copies unexpired items. Second, the Expiration field is stored as a UnixNano timestamp where zero means no limit [4]. The beneficial side effect is that once an item expires, StoredTTL automatically reports failure via its boolean return value. The test becomes instant and deterministic without a single real-time delay.
Dependency Injection for Retry Without Waiting
The database module encountered a different problem. The connection retry function demands a live MySQL instance. Every failed connection attempt incurs a slow network round-trip. There was no cheap public path to test this, and copying the retry logic into the test would only create two implementations to keep in sync.
Its pure helper was exported as RetryConnect, accepting parameters for the connection opener function, timeout, interval, and a sleep function of type func(time.Duration).
The parameter list is long, but that is precisely where the testability lies. Production passes time.Sleep, while the test passes an empty function. The same retry circuit, with identical timeouts and intervals, is tested instantly. The code change required only one line at the caller: updating the helper name to its exported form.
Mirror Oracle Anti-Tautology
The scope module faced the most subtle risk. If ModulesFor(ALL) is tested by comparing its result against the exact same logic, the test proves absolutely nothing. To prevent this, AllModules() was exported as a direct copy of the unexported allModules.
The assertion now has a separate code path for comparison. The result of ModulesFor(ALL) is matched against the set from AllModules(), not against itself. Without this separation, a test could pass indefinitely even if the filtering logic is flawed, because it would merely be comparing a mirror of itself. If the module list changes in the future, both paths must change together, making any mismatch immediately visible.
What changed deserves underlining, and so does what did not. Production behavior remained entirely unchanged. The only addition in the cache module is a twelve-line read method, and all legacy assertions, from TTL limits to eviction, are preserved exactly as they were. What changed is solely the read path, shifting from peeking at internal structures to calling a provided door.
One implementation note requires attention. This reader copies all unexpired items every time it is called, adhering to the Items() behavior in go-cache [4]. For a test path, this is cheap and sufficient. For a hot production path, this pattern is clearly not an example to follow, which is exactly where a testing seam differs from a regular API.
Seams like this are intentionally kept small. One read function, one pure helper, one oracle. Each is given documentation comments explaining the contract it opens and why the test requires it. The API remains honest because the seam documents what is exposed, rather than merely opening a door to let a test pass.
Moving tests outside the package is not a file operation. It is a contract audit. Every contract that could previously be peeked at from inside the package must choose a form: a status reader, dependency injection, or a mirror oracle. All three choices share the same criteria. Production behavior does not change, tests do not wait for real time, and what is exported is strictly enough for one contract only.