skip to content

What is an export_test.go file, and how does it let a `package foo_test` test reach unexported code?

level: middleimportance: nice to knowfreq 30%

answer

  1. the file name ends in _test.go
  2. but the package clause says foo
  3. so it ships with nothing and sees everything
  4. one-line aliases from lower case to upper case
  5. the escape hatch is listed in one reviewable file

basics

~20 s

A file declaring package foo whose name ends in _test.go, so go test compiles it but a normal build never does. It assigns unexported identifiers to exported names, giving external tests a seam absent from the shipped API.

solid answer

~40 s

`export_test.go` is a convention, not a tool feature. The file declares `package foo`, so it is inside the package and can see everything; its name ends in `_test.go`, so it is compiled only by `go test` and never becomes part of the package a consumer imports. Inside it you write one-line aliases such as `var Normalize = normalize`, or small exported hooks like `func CacheLen() int { return len(indexCache) }`. Test files in `package foo_test` then import the package and call `geom.Normalize` as if it were public, even though it is invisible in a normal build and absent from `go doc`. The value is that the escape hatch is explicit and enumerable: one file lists every internal a test is allowed to touch, so widening it is a reviewable decision instead of an accident.

code

go · 7 lines
go
// geom/export_test.go
package geom

// Test-only surface: compiled by go test, absent from a normal build.
var Normalize = normalize

func CacheLen() int { return len(indexCache) }

go deeper

for a junior

Know that a file ending in _test.go is never part of a normal build, and that this one still declares the real package name, which is why it can see unexported identifiers at all.

for a middle

Be able to write the file from memory: package clause, a couple of aliases from unexported to exported names, and an external test calling them through the import path. Explain both properties that make it work.

for a senior

Argue the design tradeoff. Show why funnelling every internal reach through one enumerable file makes coupling reviewable, and name the point at which the file is large enough that the tests should simply move inside the package.

for a principal

Treat additions to that file as API pressure. If tests keep needing to observe internal state, the question to ask is whether the exported API is missing an observation point that real operators also need.

## The problem it solves An external test package (`package foo_test`) deliberately sees only the exported surface of the package under test. That is what makes it useful, and occasionally it is exactly wrong: some behaviour cannot be observed or provoked from outside. An internal spatial index that rebuilds itself above a threshold, a normalisation table built lazily at first use, an error branch reachable only when an unexported counter wraps — the exported API by design gives you no handle on any of these. The standard-library answer is a file conventionally named `export_test.go`. ## How it works Two properties do all the work: 1. **It declares `package foo`.** It is compiled into the package itself, so every unexported identifier is in scope. 2. **Its name ends in `_test.go`.** The go tool excludes such files from an ordinary build and from `go doc`; they are compiled only when building a test binary for that directory. So you can create exported names that exist *only during a test build*: package geom var Normalize = normalize // alias an unexported function var IndexThreshold = &threshold // hand tests a pointer they can set func CacheLen() int { return len(indexCache) } A test in `package geom_test` imports the package and calls `geom.Normalize(3, 4)` or `geom.CacheLen()`. A consumer of the published package cannot: for them those identifiers do not exist, `go doc` never mentions them, and the package's real API is unchanged. The name `export_test.go` carries no special meaning to the toolchain — only the `_test.go` suffix does. The name is a convention so that a reader can find, in one file, the complete list of internals the tests are allowed to touch. The standard library uses it widely; `net/http` has an `export_test.go` that exposes internal transport and connection state to `package http_test`. ## What can go through the seam - **Aliases** to unexported functions or types you want to unit-test directly. - **Read-only observers** — an exported function returning the length of an internal cache, or a snapshot of internal state, so an external test can assert on an invariant it cannot see. - **Fault-injection hooks** — an exported variable holding a function the package calls, which a test swaps out and restores, to force an error path that is otherwise unreachable. - **Knobs** — a pointer to an internal tuning constant so a test can shrink a threshold and exercise resizing without building a million-element input. Because the file is `package foo`, it can also define exported *methods* on unexported types, which is otherwise impossible from outside. ## Why not just write the test internally? You can, and for some packages that is the right call. The reason to prefer external tests plus a narrow `export_test.go` is that it inverts the default. With internal tests, *every* internal is reachable and tests drift into asserting on implementation details one commit at a time; a behaviour-preserving refactor then breaks the suite and nobody can tell whether the failure is real. With external tests, reaching an internal requires editing a file whose entire purpose is to list such reaches. A PR that adds three new exports to `export_test.go` is visibly making a coupling decision, and a reviewer can ask whether the behaviour ought to be observable through the real API instead. ## Failure modes **Over-exporting.** If `export_test.go` grows to fifty aliases, the external tests are internal tests wearing a costume, and you have the coupling you were avoiding plus an extra indirection. That is the signal to move those specific tests into a `package foo` file and keep the external ones for the contract. **Hooks that leak.** A swappable function variable used for fault injection must be restored, ideally with `t.Cleanup`, or one test's injected failure contaminates the next. **Mistaking it for a build tag mechanism.** The file is not conditionally compiled by a constraint; it is excluded because of the `_test.go` suffix. Renaming it to `export.go` would publish every one of those identifiers to real consumers, which is the accident this pattern exists to prevent. **Confusing it with test helpers.** Shared fixtures and assertion helpers belong in a normal helper package or in the test files themselves. `export_test.go` is specifically about widening visibility, not about reusable test utilities.

  • Do the identifiers declared in export_test.go show up in `go doc` or in a consumer's build?
    Neither. Files ending in _test.go are excluded from a normal package build and from generated documentation, so those exported names exist only while a test binary for that directory is being compiled. A consumer importing the package cannot see or reference them.
  • Is the name export_test.go special to the go command?
    No. Only the _test.go suffix matters to the toolchain; the prefix is convention. Its value is human: one obvious file collects every internal the tests are allowed to reach, so growth in that file is visible in review rather than scattered across the suite.
  • When would you skip the seam and just write the test in package foo?
    When the thing under test is genuinely internal machinery with many moving parts — a parser state machine, a lock-ordering invariant — and the aliases would outnumber the assertions. Keep those tests internal and reserve the external package plus a thin export_test.go for the contract.

saying these in an interview costs you the question

  • Thinks export_test.go publishes those names to real consumers
  • Believes the file name is understood by the go command
  • Says a build constraint is what keeps it out of the build
  • Uses it as the home for shared test helpers and fixtures
  • Adds dozens of aliases and calls the tests black box