skip to content

Why does a _test.go file in `package geom` that imports a helper importing geom fail to compile, and how does `package geom_test` fix it?

level: seniorimportance: should knowfreq 36%

answer

  1. the package builds; only go test fails
  2. a test file's imports belong to the package
  3. follow the arrows back to where you started
  4. nothing can import an external test package
  5. go list -deps shows the back edge

basics

~20 s

Imports in a package foo test file count as imports of that package, so a helper importing foo closes a cycle the go command rejects. Package foo_test is downstream of both and nothing imports it, so no cycle exists.

solid answer

~50 s

When a `_test.go` file declares `package geom`, it is compiled *into* an augmented build of geom, so anything it imports is a dependency of geom itself for that build. If the shared fixture package it imports already imports geom, the graph is geom (test) to fixture to geom, and the go command refuses with an import-cycle error naming that path. The external test package breaks it structurally: `package geom_test` is a distinct package that imports geom and the fixture package, and nothing imports geom_test, so there is no back edge. The go command builds it as a third package in the same directory and links it into the same test binary. To confirm it rather than guess, `go list -deps` on the fixture package shows geom in its transitive import set. The standard library does exactly this: `net/http`'s tests are `package http_test` so they can use `net/http/httptest`, which imports `net/http`.

code

go · 8 lines
go
// internal/geomtest/fixtures.go
package geomtest

import "example.com/mod/geom"

func UnitSquare() geom.Rect {
	return geom.Rect{Min: geom.Point{}, Max: geom.Point{X: 1, Y: 1}}
}

go deeper

for a junior

Remember that Go rejects import cycles outright and that test files count: what a package foo test file imports, package foo imports. That single fact explains the error message.

for a middle

Trace the graph out loud in both configurations and show where the back edge disappears. Be able to say why an external test package can never be part of a cycle.

for a senior

Diagnose it rather than pattern-match: confirm the back edge with go list -deps, check which files are internal versus external, then choose between moving the test out, inlining the helper, and pushing shared types down.

for a principal

Read a test-only cycle as a boundary that has not been settled — two packages disagreeing about who owns the shared types. Decide whether the fix is a package clause or an unfinished extraction, and say what the team should do the next time it splits a package.

## What the compiler is actually complaining about Go rejects import cycles at compile time, and the rule applies to the *test build* of a package as much as to the shipped one. That is the part people miss. When `go test` builds a directory, the internal test files are not a separate unit: they are compiled together with the package's ordinary files into one augmented package that still occupies the position of geom in the dependency graph. So an import written in a `package geom` test file is, for the duration of that build, an import of geom. Now suppose a team has extracted a shared fixture package — call it `geomtest` — that builds sample rectangles, tolerant comparison helpers and a deterministic index. To do that it must reference the real types, so it imports geom. The moment a test file inside `package geom` imports `geomtest`, the graph contains geom to geomtest to geom, and the go command refuses to build it, printing an import-cycle error that lists the path it found. Nothing is wrong with either package individually; the cycle exists only in the test configuration, which is why the package builds fine and only `go test` fails. That asymmetry is the diagnostic clue. ## Why the external test package removes it `package geom_test` is a genuinely different package. It sits *below* both geom and geomtest in the graph: geom_test -> geom geom_test -> geomtest -> geom There is no edge back into geom_test, and there cannot be: an external test package has no importable identity — no other package can name it, because the go command builds it only as part of this directory's test binary. A package nothing imports can never participate in a cycle. So the same test code, unchanged except for its package clause and the `geom.` qualifiers it now needs, compiles. This is not a workaround; it is the reason the external form exists. The standard library relies on it: `net/http/httptest` imports `net/http`, so `net/http`'s own tests are written as `package http_test` in order to use `httptest`. ## Confirming it instead of guessing An import-cycle error prints the cycle, but on a large extraction the interesting question is *why* the fixture package depends on the package under test at all. Two commands answer it: - `go list -deps ./internal/geomtest` prints the full transitive import set of the fixture package; seeing the geom path in there confirms the back edge. - `go list -f '{{.TestGoFiles}} {{.XTestGoFiles}}' ./geom` shows which test files the tool considers internal and which external, which is how you verify that a file you *thought* you moved actually changed sides. ## The other ways out, and when to prefer them Moving to the external test package is usually the cheapest fix, but it is not the only one, and the choice matters when you are mid-extraction and pulling a package out of a large one: 1. **Keep the helper inside the package.** A helper used by exactly one package does not need to be a package. Put it in a `_test.go` file declaring `package geom`; it is then compiled only for tests, imports nothing new, and no cycle can form. This is right when the helper is not actually shared. 2. **Remove the back edge.** If the fixture package only needs a couple of types, sometimes those types belong lower in the graph — in a small `types` or `internal/geomtypes` package that both geom and the fixture import. This is the structurally cleanest answer and the most work. 3. **Move to the external test package.** Correct whenever the helper is genuinely shared by several packages' tests and must speak in the real types. 4. **Add a narrow `export_test.go` seam** if, after moving out, one or two assertions still need an internal. Note that this file declares `package geom` and imports nothing extra, so it does not reintroduce the cycle. What does *not* work is aliasing the import, dot-importing, or moving the test file to a subdirectory of the same package — the first two are naming tricks that leave the edge in place, and the third changes which package the file belongs to in a way that loses access to internals anyway. ## The reviewer's version of this When a package is being carved out of a monolithic one, cycles in the test configuration appear before cycles in the production graph, because tests are where two sides of a boundary are most tempted to reach for each other. Treat a test-only import cycle as early warning: it says the fixture package and the package under test have not yet agreed which one owns the shared types. Sometimes the honest answer is the external test package; sometimes it is that the extraction is not finished.

  • The fixture package cannot be changed. What options remain besides moving the test to geom_test?
    Inline the helper as a _test.go file inside package geom so no import is needed, or push the shared types down into a small package that both geom and the fixture import, removing the back edge entirely. The second is more work and is usually the right answer mid-extraction.
  • Why does the package itself build fine while only `go test` reports the cycle?
    The shipped package never contains _test.go files, so the offending import is absent from a normal build. It exists only in the augmented test build of the package, where it becomes an import of the package itself and closes the loop.
  • After moving the file to package geom_test, some assertions no longer compile. Why, and what do you do?
    They were touching unexported identifiers, which are invisible from a separate package. Either keep those specific tests in a package geom file, or add the few names they need to a small export_test.go that declares package geom and therefore adds no imports and no cycle.
  • Does putting the external test files in a subdirectory achieve the same thing?
    No, and it costs you things. A subdirectory is an ordinary package with its own name, it is built by every plain `go build ./...`, it cannot use an export_test.go seam, and its coverage is attributed elsewhere. The suffixed package clause in the same directory is the supported mechanism.

saying these in an interview costs you the question

  • Thinks test files are exempt from the import cycle rule
  • Tries to fix it with an import alias or a dot import
  • Believes moving tests to a subdirectory is equivalent
  • Says the cycle would also break a plain go build
  • Cannot explain why nothing can import geom_test