Testing private functions in Go
You are building a URL router. The public method ServeHTTP delegates to matchRoute. The helper matchRoute handles regex parsing, path normalization, and middleware chaining. Testing matchRoute through ServeHTTP requires constructing full HTTP requests, mocking response writers, and parsing headers just to verify a string comparison inside the helper. That is overhead. You want to test matchRoute directly.
In Python or JavaScript, you would import the helper and call it. In Go, the lowercase name blocks external access. The function is unexported. You cannot call it from another package.
Go solves this with package scope. Tests live inside the package they test. The compiler treats test files as part of the package, granting them access to every identifier, exported or not. The mechanism is simple: declare the test file with the same package name as the source file.
Package scope and the test boundary
Go does not have private or public keywords. Visibility is determined by the first letter of the name. A capital letter exports the identifier to other packages. A lowercase letter keeps it unexported, visible only within the same package.
This rule applies to everything: functions, types, variables, constants, and struct fields. The compiler enforces the boundary at compile time. If code outside the package tries to use an unexported name, the build fails.
Test files follow the same rules. The _test.go suffix is a convention for the tooling, not a compiler directive. The suffix tells go test to recognize the file as a test file. The package declaration determines the scope.
When you write package mypkg in a test file, the compiler includes that file in the mypkg compilation unit. The test code sees all unexported identifiers. When you write package mypkg_test, the compiler treats the test as a separate package that imports mypkg. The test code sees only exported identifiers.
Convention aside: Public names start with a capital letter. Private names start lowercase. There are no access modifiers. This keeps the language small and the rules consistent. The naming convention is the only thing that controls visibility.
Minimal example: white-box testing
Here is the setup: a private function and a test file in the same package.
// pkg/helper.go
package pkg
// doubleMultiplies returns twice the input value.
func doubleMultiplies(x int) int {
return x * 2
}
// pkg/helper_test.go
package pkg // Same package declaration grants access to unexported names.
import "testing"
func TestDoubleMultiplies(t *testing.T) {
// Call the unexported function directly. No reflection required.
got := doubleMultiplies(5)
if got != 10 {
t.Errorf("doubleMultiplies(5) = %d; want 10", got)
}
}
The test file declares package pkg. The compiler compiles helper_test.go into the pkg package alongside helper.go. The test function TestDoubleMultiplies can call doubleMultiplies because they share the package scope. The testing package is imported to provide the *testing.T type, but the visibility comes from the package declaration.
Run the test with go test ./pkg/.... The test runner executes TestDoubleMultiplies, calls doubleMultiplies, and verifies the result.
Walkthrough: what the compiler sees
The Go toolchain processes test files during the build phase. When you run go test, the compiler creates a temporary package that includes all source files and test files. The inclusion depends on the package declaration.
For package mypkg, the compiler merges the test file into the package. The symbol table for mypkg contains both exported and unexported names. The test functions are just regular functions that happen to start with Test and accept *testing.T. The test runner discovers them via reflection and calls them.
For package mypkg_test, the compiler creates a separate package. This package imports mypkg. The symbol table for mypkg_test contains only the names exported by mypkg. Unexported names are invisible. The test runner discovers the test functions in the separate package.
This design means tests are first-class code. They compile against the same package logic. They catch type errors, interface mismatches, and visibility violations just like production code.
If you try to call an unexported function from a black-box test, the compiler rejects the program with undefined: helper. The error appears because the separate package cannot see the name.
Realistic example: table-driven tests for private logic
Real code rarely has single-case tests. Private functions often contain complex logic that benefits from table-driven tests. Here is a private function tested with a table-driven approach.
// pkg/calc.go
package pkg
// calculateTax applies the rate based on region.
func calculateTax(amount float64, region string) float64 {
var rate float64
switch region {
case "US":
rate = 0.08
case "EU":
rate = 0.20
default:
rate = 0.0
}
return amount * rate
}
// pkg/calc_test.go
package pkg
import "testing"
func TestCalculateTax(t *testing.T) {
// Table-driven test covers multiple regions without repetition.
tests := []struct {
name string
amount float64
region string
want float64
}{
{"US tax", 100.0, "US", 8.0},
{"EU tax", 100.0, "EU", 20.0},
{"Unknown region", 100.0, "JP", 0.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Subtest isolates each case and reports failures by name.
got := calculateTax(tt.amount, tt.region)
if got != tt.want {
t.Errorf("calculateTax(%v, %q) = %v; want %v", tt.amount, tt.region, got, tt.want)
}
})
}
}
The test uses a slice of structs to define cases. The loop creates a subtest for each case using t.Run. Subtests allow you to run specific cases with go test -run TestCalculateTax/US_tax. The unexported function calculateTax is called directly in each iteration.
This pattern scales. You can add cases for edge values, empty strings, or special regions without duplicating the test structure. The table-driven approach is the standard way to test functions in Go, whether exported or not.
Pitfalls and the implementation trap
Testing private functions couples your test to the implementation. If you refactor the internal structure, your tests may break even if the public behavior is unchanged.
Imagine calculateTax is split into getRate and applyRate. The test for calculateTax breaks because the function no longer exists. You must update the test to call the new functions. This is the implementation trap. Tests become brittle when they depend on internal details that can change.
The trade-off is visibility. Testing through the public API can be expensive. You may need to construct complex inputs, mock dependencies, or parse outputs just to exercise a small piece of logic. Testing the private function directly isolates the logic and makes failures easier to diagnose.
Use judgment. Test private functions when the logic is complex enough to warrant isolation, or when the public API makes testing prohibitively expensive. Avoid testing trivial helpers that are obvious from their implementation. If a function is a one-liner, testing it directly adds little value.
Convention aside: The Go community accepts verbose error handling because it makes the unhappy path visible. Apply the same discipline to tests. Write clear error messages that show the input, the output, and the expected value. Use t.Errorf with formatted strings. Avoid silent failures.
Black-box testing: enforcing the public API
Sometimes you want to verify that your tests only touch the public interface. This ensures that refactoring internals does not break the contract. Use the black-box package pattern.
Here is a test file that imports the package and tests only exported symbols.
// pkg/calc_test_blackbox.go
package pkg_test // Note the _test suffix on the package name.
import (
"pkg"
"testing"
)
func TestPublicCalculate(t *testing.T) {
// Only exported symbols are visible here.
// calculateTax is invisible and cannot be called.
got := pkg.PublicCalculate(100, "US")
if got != 108.0 {
t.Errorf("PublicCalculate(...) = %v; want 108.0", got)
}
}
The package declaration is pkg_test. The compiler treats this as a separate package. The test imports pkg and accesses only exported names. The unexported calculateTax is invisible. If you try to call it, the compiler rejects the program with undefined: pkg.calculateTax.
This pattern is useful for library authors who want to ensure that users can only rely on the public API. It also helps catch accidental dependencies on internal details.
Test helpers and t.Helper
Test files often contain helper functions to reduce duplication. These helpers are unexported functions within the test package. They follow the same visibility rules.
Convention aside: Use t.Helper() to mark test helper functions. This tells the test runner to report failures at the caller's line number, not the helper's line number. Without t.Helper(), error messages point to the helper, making it harder to find the failing test case.
Here is a test helper example.
// pkg/test_helpers.go
package pkg
import "testing"
// assertEqual checks values and fails the test on mismatch.
func assertEqual(t *testing.T, got, want int) {
t.Helper() // Report the caller's line number, not this function.
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
The helper assertEqual is unexported. It is visible to all test files in the package. The t.Helper() call improves error reporting. This is a standard convention in Go tests.
Decision matrix: choosing the test strategy
Use package mypkg when you need to test complex internal logic that is hard to exercise via the public API. Use package mypkg_test when you want to verify the public interface and ensure refactoring internals does not break the contract. Use table-driven tests when the private function has multiple input variations. Use t.Helper() when writing test helper functions to point errors to the caller line. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Where to go next
- How to Write Fuzz Tests in Go (Go 1.18+)
- How to Use mockgen for Generating Mock Implementations
- How to Skip Tests Conditionally in Go
Tests are code. Treat them with the same discipline as production logic. Private functions are implementation details. Test them only when the cost of indirect testing is too high. The compiler enforces visibility. The test package declaration chooses the boundary.