Skip to content
gohacks.dev
Testing

Test Unexported Functions and Internal Packages in Go

Test unexported Go functions with an in-package test file or an export_test.go bridge, see when each fits, and why private helpers often need no direct test.

6 min read
Summarize in AI
Flat illustration of a test report with one failing test.

A package should expose a small API and keep the rest private. Tests complicate that, because some of the most interesting logic is in the private part: a rounding function, a parser, a state machine. There are three ways to reach it, and a fourth answer that is often better than all of them: do not.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The example is a small pricing package on Go 1.25.6, with one exported function that depends on unexported helpers. All three kinds of test file sit in one directory, and they compile together.

The package

pricing.goGo
package pricing

import "math"

func roundCents(x float64) int { return int(math.Round(x * 100)) }

// Total adds tax to a net amount in dollars and returns cents.
func Total(net, rate float64) int { return roundCents(net * (1 + rate)) }

type cache struct{ hits int }

func (c *cache) touch() { c.hits++ }

Option one: a test in the same package

internal_test.goGo
package pricing

import "testing"

// Same package: can reach unexported identifiers directly.
func TestRoundCents(t *testing.T) {
	for _, in := range []float64{0.005, 0.0049, 1.005, 2.675} {
		t.Logf("roundCents(%v) = %d", in, roundCents(in))
	}
}

func TestTouch(t *testing.T) {
	var c cache
	c.touch()
	c.touch()
	t.Logf("hits=%d", c.hits)
}

A file whose package clause is package pricing is compiled into the package for testing. It sees everything, exported or not, so TestRoundCents calls roundCents directly and TestTouch builds a cache and reads its field. It is the simplest approach and the most common in the standard library.

The recorded values show why a direct test is useful. roundCents(0.005) returns 1 and roundCents(0.0049) returns 0, as expected. But roundCents(1.005) returns 100, not 101, and roundCents(2.675) returns 268. The decimal 1.005 is stored as a binary number a hair below 1.005, so scaled by 100 it comes out below 100.5 and rounds down. A test through Total would have found this only with the right input. A test of the helper can feed it the awkward numbers on purpose. Money in floating point is a hazard; the decimal and money article covers the alternatives.

The cost of the in-package style is coupling. Tests that know the internals break when the internals change, even when behaviour does not. They also hide the question of whether the package is usable from outside, because the test can see more than a caller can.

Option two: the external package with an export bridge

export_test.goGo
package pricing

// Test-only export: visible to pricing_test, absent from the shipped package.
var RoundCents = roundCents
external_test.goGo
package pricing_test

import (
	"testing"

	pricing "example.test/lab"
)

func TestTotalPublic(t *testing.T) {
	t.Logf("Total(10, 0.0825) = %d", pricing.Total(10, 0.0825))
}

func TestViaExportFile(t *testing.T) {
	t.Logf("RoundCents(0.125) = %d", pricing.RoundCents(0.125))
}

Many teams prefer to put nearly every test in package pricing_test, which can only use the exported API, as a user does. When one test needs a private function, the bridge file export_test.go solves it. It is in package pricing and its name ends in _test.go, so it is compiled only during tests. It assigns the private function to an exported name: var RoundCents = roundCents. The external test file then calls pricing.RoundCents(0.125), which returned 13.

The go build of the package never sees export_test.go, so the shipped API is unchanged. The pattern is common in the standard library, where export_test.go files appear in many packages. Keep the file small and its names obvious, so a reader can tell test hooks from API. A bridge for a function is easy; for a type, an alias (type Cache = cache) or an exported constructor, func NewCache() *cache. The external package then reaches fields only through methods you also export, so the bridge usually grows with the thing it exposes, which is a sign to ask whether the internals are being tested too closely.

Option three: test through the exported function

TestTotalPublic calls pricing.Total(10, 0.0825) and gets 1083. That exercises roundCents as a caller would, with a realistic input and an observable result. This should be the default: the private function exists to serve a public one, so the public function's behaviour is the contract. Test the contract and the helper is covered, and the helper can be rewritten freely.

Reach for a direct test of a private function when the input space is awkward to reach through the API (as the rounding inputs are), when the function is complex enough to deserve its own table of edge cases, or when failures through the public path would be hard to diagnose. Otherwise, skip it. A private function with no direct test is not untested: check the coverage report, and if its statements run in the tests of its callers, that is enough.

Internal packages are a different thing

Go also has internal directories: a package under .../internal/... is importable only from the tree rooted at the parent of internal. This is a visibility rule between packages, enforced by the compiler, and unrelated to the capital letter rule. Code in internal/store is exported within its package as usual, and the test for it is an ordinary test of an exported API. Do not mix up the two when someone says "internal": one means unexported identifiers, the other a directory.

Choosing

SituationApproach
A helper with tricky inputs and its own edge casesIn-package test, table-driven
Most tests external, one needs a private hookexport_test.go bridge
A helper that only exists for one exported functionTest through the exported function
Code you would rather not have tests depend onChange the design, or move the logic to a small package with its own API

The last row deserves attention. A pile of private functions that all need testing is often a package that wants to be two packages, with the tricky part exported from a smaller one. That move makes the test natural, and the dependency explicit.

A note on naming the bridge

Name bridge variables after the thing they expose, with a leading capital: RoundCents, NewCache. Avoid inventing a second vocabulary such as TestOnlyRound, because the file name already says it is for tests. Put a one-line comment above each, saying which test file uses it, so a later clean-up can remove the ones nobody calls. And never put logic in export_test.go; it is an alias list, and logic in it is code that ships in no build and is read by no user.

Limits

An export bridge is invisible to users and visible to every test in the module, so it can become a back door that tests use to poke state they should not. Reviewing export_test.go changes carefully is cheap. Both package names can coexist in a directory, but one _test.go file cannot mix the two package clauses, and a symbol declared in export_test.go is not available to in-package test files under a different name. The language and tool rules are in the go command documentation on test packages, the idea of testing through the public API is part of the broader testing guide, and the table-driven form that suits a helper with many edge cases is in the table tests article. The Go source tree uses the bridge pattern widely; browse any export_test.go under the standard library to see how small these files are.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.