The two ways to write tests in Go
You are building a Go library that other teams will import. You write a helper function to sanitize user input. It works perfectly. Now you need to prove it works. You also need to verify that the public API behaves correctly when called from a completely different package. Go solves this with a single filename convention and a package declaration choice. The choice changes what the compiler lets you see, and it shapes how you design your tests.
Internal vs external test packages
Every test file in Go ends with _test.go. That suffix tells the go toolchain to compile the file only when you run go test. The package declaration at the top of the file decides the visibility rules. If you declare package mypkg, the test file joins the package during the test build. If you declare package mypkg_test, the test file lives in a separate package that imports mypkg. The difference mirrors how real code interacts with your library. An internal test sits inside the package boundary. An external test stands outside it.
Think of it like a factory floor. An internal test is a quality engineer walking the production line. They can open any machine, check internal wiring, and verify draft blueprints. An external test is a customer standing at the shipping dock. They only see the final boxed product. They verify that the box opens, the contents match the label, and the instructions make sense. Both perspectives matter. The internal test catches implementation bugs early. The external test guarantees the public contract holds up under real usage.
Minimal internal test
Here is the simplest internal test. The test file declares the same package name as the source code, so it can call unexported helpers directly.
package mathutil
import "testing"
// roundHalfUp is an unexported helper used by the public API.
func roundHalfUp(n float64) float64 {
// Add 0.5 and truncate to simulate standard rounding.
return float64(int(n + 0.5))
}
// TestRoundHalfUp verifies the private rounding logic.
func TestRoundHalfUp(t *testing.T) {
// Call the unexported function directly without importing anything.
result := roundHalfUp(2.5)
// Fail the test if the result does not match the expected value.
if result != 3.0 {
t.Errorf("roundHalfUp(2.5) = %f, want 3.0", result)
}
}
When you run go test, the compiler merges mathutil.go and mathutil_test.go into a single package build. The test function sees every symbol in the package, exported or not. This pattern is useful for verifying isolated logic that the public API does not expose. Internal tests are fast to write and give you full access to the package internals. They let you test edge cases in helper functions without building complex input structures for the public API.
Internal tests belong in the same directory as the source code. Keep them focused on one behavior per function. Test the internals only when the logic is nontrivial or when testing it through the public API would require excessive setup.
Minimal external test
Here is the simplest external test. The test file declares a package name ending in _test, which forces it to import the target package like any other consumer would.
package mathutil_test
import (
"testing"
"mathutil" // imports the package under test
)
// TestPublicAPI verifies the exported Add function.
func TestPublicAPI(t *testing.T) {
// Call only exported symbols through the package qualifier.
result := mathutil.Add(2, 3)
// Report a failure if the public contract is broken.
if result != 5 {
t.Errorf("Add(2, 3) = %d, want 5", result)
}
}
The compiler treats mathutil_test as a completely separate package. It compiles mathutil first, then compiles the test package against the compiled output. The test file can only call functions that start with a capital letter. This pattern simulates how downstream code will actually use your library. External tests enforce clean public boundaries and catch accidental reliance on implementation details. They also verify that your package exports are properly documented and that error messages make sense to callers.
External tests force you to design a stable API. If you find yourself writing convoluted setup code just to reach a public function, your API might be leaking implementation details. Refactor until the external test reads like documentation.
How the build and test runner handle them
The go test command does not run tests directly. It compiles a temporary test binary that includes your package, the test files, and the standard testing package. If your test file uses the same package name, the compiler links everything into one binary. If your test file uses the _test suffix package name, the compiler builds two binaries. One binary contains the package under test. The other binary contains the test package that imports it. The test runner then executes the binary, looking for functions named TestXxx, BenchmarkXxx, or ExampleXxx. Functions that do not match the naming convention are compiled but never executed.
The runner passes a *testing.T pointer to each test function. This pointer provides logging, failure tracking, and parallel execution controls. Call t.Log to print output only when the test fails or when you pass -v. Call t.Fatal or t.Errorf to mark the test as failed. The community convention is to keep test functions focused on one behavior. Table-driven tests handle multiple inputs cleanly. Each subtest should use t.Run to get isolated failure reporting and parallel execution support. The testing package also provides t.Cleanup for teardown logic that runs automatically after the test finishes, even if the test panics. Use it for temporary files, network listeners, or database connections.
The test runner respects the *_test.go suffix strictly. Files without the suffix are compiled into the production binary. Files with the suffix are excluded from production builds. This separation keeps test dependencies out of your deployed code.
Realistic scenario
Here is how the two patterns work together in a realistic codebase. The source package handles configuration parsing. The internal test verifies the private validation logic. The external test verifies the public parsing flow and error messages.
// config/config.go
package config
import "fmt"
// ValidatePort checks if a port number falls within the valid range.
func ValidatePort(port int) error {
if port < 1 || port > 65535 {
// Return a descriptive error for downstream callers.
return fmt.Errorf("port %d out of range", port)
}
return nil
}
// ParseConfig reads a port and validates it using the private helper.
func ParseConfig(port int) error {
// Delegate to the validation helper to keep the public API clean.
return ValidatePort(port)
}
// config/config_internal_test.go
package config
import "testing"
// TestValidatePort checks the unexported validation logic directly.
func TestValidatePort(t *testing.T) {
// Test the boundary condition that the public API wraps.
err := ValidatePort(0)
// Fail if the helper does not reject invalid ports.
if err == nil {
t.Error("ValidatePort(0) should return an error")
}
}
// config/config_external_test.go
package config_test
import (
"testing"
"config"
)
// TestParseConfig verifies the public API contract.
func TestParseConfig(t *testing.T) {
// Call the exported function exactly as a consumer would.
err := config.ParseConfig(8080)
// Fail if the public function returns an unexpected error.
if err != nil {
t.Errorf("ParseConfig(8080) = %v, want nil", err)
}
}
The internal test catches bugs in the validation logic before they leak into the public API. The external test ensures the exported function behaves correctly and returns errors that make sense to callers. Running both gives you coverage of the implementation details and the public contract. The community expects test files to live in the same directory as the source code they test. Moving them to a separate tests/ directory breaks the package boundary rules and forces awkward imports. Keep tests close to the code they verify.
Pitfalls and compiler boundaries
Mixing up the package declaration causes immediate compiler rejections. If you declare package mypkg_test but try to call an unexported function, the compiler rejects the file with undefined: helper. The external test package has no visibility into private symbols. If you declare package mypkg but forget to import a dependency that only the test needs, you get imported and not used because the compiler treats the test file as part of the main package. The Go compiler does not allow unused imports anywhere, including test files.
Naming matters more than you expect. The test runner only executes functions that start with Test, Benchmark, or Example followed by a capital letter. If you name a function testAdd or Testadd, the compiler accepts it, but the runner silently skips it. You will run go test and see 0 tests passed without any warning. The same rule applies to table-driven tests. Each subtest should use t.Run("name", func(t *testing.T) { ... }) to get isolated failure reporting and parallel execution support.
The testing package provides helpers that reduce boilerplate. Call t.Helper() at the top of a wrapper function so the test runner reports failures on the actual test line, not the helper line. Use t.Cleanup() to register teardown logic that runs automatically after the test finishes, even if the test panics. The convention is to keep test functions focused on one behavior. Table-driven tests handle multiple inputs cleanly. The community expects *_test.go files to live in the same directory as the source code they test. Moving them to a separate tests/ directory breaks the package boundary rules and forces awkward imports.
Goroutine leaks in tests are silent killers. If a test spawns a background goroutine that waits on a channel, and the test returns before the channel closes, the goroutine hangs forever. The test runner eventually times out or the process exits with a leak. Always provide a cancellation path. Pass a context.Context with a deadline, or close the channel explicitly before the test returns. The worst test bug is the one that passes but leaves resources dangling.
Which pattern to pick
Use an internal test package when you need to verify unexported helpers, private methods, or package-level variables. Use an internal test package when the logic is tightly coupled to the implementation and testing it through the public API would require excessive setup. Use an external test package when you want to enforce the public contract and ensure your API is usable without internal knowledge. Use an external test package when testing integration points, error messages, or how the package behaves when imported by downstream code. Stick to internal tests for pure logic functions and reach for external tests for API boundaries and integration flows.