skip to content

Your package's tests all live in `package foo` and break on every internal refactor. What do you gain and lose by moving them to `package foo_test`?

level: seniorimportance: should knowfreq 42%

answer

  1. the suite is coupled to what it can see
  2. restrict the view, restrict the coupling
  3. the test becomes the first consumer
  4. the internals you still need cost a seam
  5. split, do not purge, and move per package

basics

~20 s

You gain tests bound to the exported contract, so behaviour-preserving refactors stop breaking them, plus honest API feedback and no test-only import cycles. You lose direct access to internal state, which returns only through a narrow seam.

solid answer

~40 s

Moving to `package foo_test` changes what the tests are allowed to know. They can only call the exported API, so renaming an unexported helper, splitting an internal type or replacing a scan with an index no longer breaks anything: the suite fails only when observable behaviour changes. The tests also become the package's first consumer, so awkward construction or ordering requirements surface immediately, and they can import shared fixture packages that themselves import the package without a cycle. The cost is real: internal invariants such as cache eviction or an error branch behind an internal counter are no longer reachable, and getting at them needs a small `export_test.go` seam or a few tests kept in `package foo`. I would move the contract tests, keep a short internal file, and go package by package.

go deeper

for a junior

Know the mechanical difference first: an external test package can only call exported names, so it cannot depend on internals that a refactor might rename or delete.

for a middle

Explain both directions of the tradeoff with concrete examples of what becomes unreachable, and know that a small test-only file declaring the package can re-expose specific internals.

for a senior

Own the migration plan: move per package, let the compile errors identify the coupled tests, decide each one, and treat the size of the seam file as an ongoing signal rather than a one-time setup.

for a principal

Argue it as a contract decision. The external suite is the first consumer of an API other teams will depend on, and repeated pressure to observe internals is evidence the exported surface is missing something operators also need.

## What the symptom is telling you "Tests break on every refactor" is rarely a testing-discipline problem; it is a coupling problem with a structural cause. A `_test.go` file that declares `package foo` is compiled into foo, so every unexported function, field and package-level variable is in scope. Nothing stops a test from asserting on them, and over months tests drift into doing so — one commit reaches for an internal counter because it was convenient, the next asserts the shape of an unexported struct. The result is a suite that fails on changes that did not change behaviour, and a team that learns to read a red build as noise. The external test package removes the temptation by removing the access. ## What you gain **Refactor tolerance.** With `package foo_test`, the only things the tests can name are the exported ones. Renaming `normalize`, merging two unexported types, changing an algorithm — none of it is visible to the suite. A failure now means observable behaviour changed, which is the only kind of failure worth paging a developer for. **Design feedback.** The external test is written the way a caller writes code: full import path, exported names only, no privileged construction. If a test needs four steps and a magic ordering to reach a useful state, real consumers will need the same and will get it wrong. This is the cheapest API review a package ever gets, and it happens before anyone depends on it. **Freedom from test-only cycles.** Shared fixture packages must import the package under test to speak in its types. An internal test file importing such a fixture closes a cycle and will not build; an external test package is downstream of both and cannot be part of a cycle, because nothing can import it. If your organisation is heading toward shared fixtures, this alone decides the question. **Better documentation.** `ExampleXxx` functions written externally are real user code and read as such. ## What you lose **Observation of internals.** Anything you could previously assert directly — the length of an internal cache, whether an index rebuilt, the value of a private counter — becomes invisible. Some of those assertions were valuable. **Provocation of internal states.** Error branches that only trigger when an unexported limit is exceeded, or when an internal call fails, were easy to reach by setting a variable. From outside you must either construct a genuinely large input, or reintroduce a hook. **Migration cost and churn.** Every reference gains a package qualifier, and some tests simply will not compile after the move, which forces a judgment call per test rather than a mechanical edit. ## How to spend those losses well The practical shape most Go codebases converge on is a split, not a purge: - The **majority of tests external**, covering the exported contract. - A **short internal file** (`package foo`) for tests of genuinely internal machinery with many moving parts — a parser state machine, a lock-ordering invariant, a hand-rolled hash. Testing those through the public API produces obscure tests that fail far from the cause. - A **narrow `export_test.go`** — declaring `package foo`, so test-only — for the few internals the external suite must reach: an alias to an unexported function, an observer returning internal cache size, a swappable hook for fault injection. Its size is a health metric. Two or three entries is a seam; fifty is internal testing with extra steps, and the honest response is to move those tests back inside. A further question worth asking each time you reach for the seam: if the tests want to observe this, would an operator want to observe it too? Sometimes the answer is that the package is missing a legitimate exported accessor, a metric or a status method, and adding it serves both. ## Doing the move Do it per package, not as a repo-wide sweep, and do it when you are already touching the package. Change the package clause on the contract tests, add qualifiers, and see what fails to compile — that list is precisely the set of tests that were coupled to internals, and each one is a decision: keep it internal, express it through the API, or add a seam. Because both forms coexist in one directory and link into one test binary, there is no intermediate broken state: you can move three files today and the rest next month, and coverage numbers are unaffected either way. ## The argument you will hear against it "Black-box tests give worse coverage of edge cases." Sometimes true, and worth answering concretely rather than dismissing: if a branch is unreachable through the exported API and no operator can ever observe it, ask whether it should exist. If it should, that is exactly what a seam or an internal test file is for. What you do not want is that conclusion applied to the whole suite by default, which is where you started.

  • How would you decide which existing tests stay in `package foo`?
    Move everything, then keep internal only what refuses to compile for a good reason: tests of internal machinery with many moving parts, where driving the behaviour through the exported API would make the test obscure and the failure far from the cause. Anything else either uses the public API or earns a named seam.
  • What signal tells you the export_test.go seam has grown too large?
    When the external tests reference more test-only exports than real API, or when adding an internal to that file stops feeling like a decision. At that point the tests are internal tests with extra indirection, and the honest fix is to move those specific ones into a package foo file.
  • Does the split change coverage reporting or how tests are run?
    No. Both packages link into one test binary for the directory, so -run filters, -race, parallelism and coverage of the package under test all behave the same. Nothing about the split needs new tooling or a separate command.

saying these in an interview costs you the question

  • Treats it as a style rule with no stated tradeoff
  • Claims internal tests are always wrong
  • Plans a repo-wide sweep instead of per-package moves
  • Forgets that unexported internals become unreachable
  • Replaces every internal access with a new test-only export