Share Test Helpers Across Go Packages Without Cycles
Share test helpers across Go packages in an internal testutil package, see the import cycle error when it imports the code it helps test, and the way out.

The first shared test helper is easy. The second package that needs it is where the trouble starts: where does it live, and who imports it? The wrong answer produces an error that looks alarming and has a plain cause.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article builds a shared helper package on Go 1.25.6, triggers the import cycle on purpose, and shows the ways out. It sits alongside the unexported functions article, which covers the in-package and external test packages that the cycle story depends on.
A helper package that works
package app
type Store interface {
Get(id string) (string, bool)
}
type Service struct{ Store Store }
func (s Service) Name(id string) string {
if v, ok := s.Store.Get(id); ok {
return v
}
return "unknown"
}// Package testutil holds helpers shared by tests. It must not import the packages it helps test.
package testutil
import "testing"
// MapStore is an in-memory store that satisfies any package's Get(id) (string, bool) interface.
type MapStore map[string]string
func (m MapStore) Get(id string) (string, bool) { v, ok := m[id]; return v, ok }
func Equal[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Fatalf("got %v, want %v", got, want)
}
}package app
import (
"testing"
"example.test/lab/internal/testutil"
)
func TestName(t *testing.T) {
s := Service{Store: testutil.MapStore{"1": "ada"}}
testutil.Equal(t, s.Name("1"), "ada")
testutil.Equal(t, s.Name("2"), "unknown")
t.Log("helper shared without a cycle")
}The package under test, app, has a Service that depends on a Store interface with one method. The helper package internal/testutil provides a MapStore, an in-memory type with that method, and a generic Equal helper that calls t.Helper() and fails with a clear message. The test in package app imports testutil and uses both.
go test -count=1 -v ./... 2>&1 | grep -E '^(---|ok|\s+app_test)' | sed -E 's/ \([0-9.]+s\)//; s/\t[0-9.]+s//; s#^ +[a-z_]+\.go:[0-9]+: # #'
echo
echo "# now make testutil import the package it helps test"
cat > internal/testutil/testutil.go <<'EOF'
package testutil
import (
"testing"
app "example.test/lab"
)
func NewService() app.Service { return app.Service{} }
func Equal[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Fatalf("got %v, want %v", got, want)
}
}
type MapStore map[string]string
func (m MapStore) Get(id string) (string, bool) { v, ok := m[id]; return v, ok }
EOF
go test -count=1 ./... 2>&1 | head -8 | sed -E 's/\t[0-9.]+s//'
echo
echo "# the escape: move the test to the external package"
sed -i.bak 's/^package app$/package app_test/; s#^import (#import (\n\tapp "example.test/lab"#; s/Service{Store/app.Service{Store/' app_test.go && rm app_test.go.bak
go test -count=1 -v ./... 2>&1 | grep -E '^(---|ok|\s+app_test)' | sed -E 's/ \([0-9.]+s\)//; s/\t[0-9.]+s//; s#^ +[a-z_]+\.go:[0-9]+: # #' helper shared without a cycle
--- PASS: TestName
ok example.test/lab
# now make testutil import the package it helps test
# example.test/lab
package example.test/lab
imports example.test/lab/internal/testutil from app_test.go
imports example.test/lab from testutil.go: import cycle not allowed in test
FAIL example.test/lab [setup failed]
? example.test/lab/internal/testutil [no test files]
FAIL
# the escape: move the test to the external package
helper shared without a cycle
--- PASS: TestName
ok example.test/labThe first block passes: helper shared without a cycle. The reason it works is the direction of the arrows. app_test imports testutil, and testutil imports only testing. Nothing points back at app, so the graph has no cycle.
Three choices made it work. The helpers live under internal/, so only this module can import them and they never become public API you must support. The package imports nothing from the module. And the fake satisfies Store implicitly, by having the right method, without importing the package that defines the interface, which is the idiomatic Go way described in dependency injection.
Triggering the cycle
The second block changes testutil to import app, for instance to offer a NewService() constructor that returns a configured app.Service. That looks harmless and is the mistake. The test in package app imports testutil, and testutil imports app: a cycle.
The toolchain stops before compiling and prints the chain:
package example.test/lab
imports example.test/lab/internal/testutil from app_test.go
imports example.test/lab from testutil.go: import cycle not allowed in testRead it from the top. The package under test is example.test/lab. Its test file app_test.go imports testutil. testutil.go imports the package under test. The words in test matter: the cycle exists only because the test is compiled into the same package as the code. A non-test build of the program is fine, because production code does not import testutil.
The way out
There are three, in order of preference.
1. Keep helpers independent of the code they help test. Write them against the standard library and against interfaces the consumer defines. MapStore needs nothing from app. If a helper wants to return an app.Service, it does not belong in testutil; put that constructor in the test file, or in a _test.go file in app, where it can use the package directly.
2. Move the test to the external package. The third block of the lab changes package app to package app_test and qualifies the types as app.Service. An external test package is compiled separately and imports both app and testutil; since it is not part of app, there is no cycle. The test passes again. This is the standard answer when a helper must know about the package, and it is the reason many teams default to external tests; see black box tests. The cost is that the external package cannot reach unexported names, which is rarely needed for tests that use shared helpers.
3. Split the helper by the package it serves. When one helper package keeps needing imports of many packages, it becomes a hub that depends on the whole module. Replace it with several, each beside the package it supports: app/apptest, store/storetest. A package named apptest may import app, because production code never imports apptest, and tests of other packages import it freely. This is the layout the standard library uses: net/http/httptest, testing/iotest, testing/fstest.
Layout that scales
| Need | Where it goes |
|---|---|
| A generic assertion or builder with no dependencies | internal/testutil |
| A fake of an interface defined in one package | Beside that package, as xxxtest, or in the consumer's _test.go |
| A fake used by many packages | internal/fakes, depending on interfaces only |
| Helpers that start a container or a database | internal/testdb, in its own package so the import is only paid by tests that use it |
| Fixtures (data files) | The test's own testdata/ directory, or a shared one at the module root |
| Golden helpers | A function in testutil that takes a path |
Two further habits keep the arrangement sound. Mark helper functions with t.Helper() so failures report the caller's line, as Equal does. And never put production code in a test helper package, or test-only code in a production package, so build tags and dead code stay out of the binary.
Exporting helpers from a public module
If your module is a library and users want your fakes, the helper package should be public: yourmodule/yourtest. Then it is API, with the compatibility promise that goes with it. Keep it small, document it with examples, and do not export anything you are not ready to support. If only your own repository needs it, keep it under internal.
Testing the helpers
Helpers deserve tests when they have logic. A fake store that behaves differently from the real one is a trap. Run the same set of behaviour tests against the fake and the real implementation, with the real one in an integration tier, so they cannot drift. A generic assertion needs a test that it fails when it should, which takes a fake *testing.T; the testing package offers testing.TB as an interface precisely so helpers can accept something you can substitute, and a helper that takes testing.TB is easier to test.
Limits
A shared package is a coupling point. A change to a helper can break tests across the module, so changes are reviewed like API changes. A large testutil collects unrelated functions, and importing it pulls in everything it depends on; split it before it grows a dependency on the database driver. And helpers hide behaviour: a test that calls testutil.SetUp(t) and does six things is harder to read than one that shows them. Prefer small helpers that do one thing and are named for it.
The rules for internal packages and import cycles are in the Go command documentation, and the design of the standard library's test helper packages can be seen in net/http/httptest. The wider layout of a test suite is covered in the testing strategy.


