| name | functional-testing |
| description | Write functional tests that test public interfaces only (HTTP APIs via requests, libraries via exported functions, CLIs via command execution). Never test internal functions directly. Expose observability APIs for internal behavior testing. |
Functional Testing Philosophy
Core Principle
ALWAYS test the public interface of a project. Tests should interact with the system the same way end users or consumers would, never calling internal/private functions directly.
The "public interface" varies by project type:
- HTTP APIs: Test via HTTP requests to running server
- Libraries: Test via exported package functions
- CLIs: Test via command execution with captured output
CLI Testing Pattern
Code Structure
Structure CLI applications to be testable by making the main() function a thin wrapper:
package main
import (
"context"
"os"
"github.com/your/project"
)
func main() {
ctx := context.Background()
os.Exit(project.Run(ctx, os.Argv[1:], project.RunOptions{
Stdout: os.Stdout,
Stderr: os.Stderr,
}))
}
Run Function Signature
The Run() function should accept:
context.Context- for cancellation (servers) or timeout control. May be omitted for simple CLIs.[]string- command arguments (os.Argv[1:])- Options struct - for dependency injection (stdout, stderr, config, etc.)
Examples:
// Simple CLI (no context needed)
func Run(args []string, opts RunOptions) int
// Server CLI (needs context for shutdown)
func Run(ctx context.Context, args []string, opts RunOptions) int
// RunOptions provides testable dependencies
type RunOptions struct {
Stdout io.Writer // Defaults to os.Stdout if nil
Stderr io.Writer // Defaults to os.Stderr if nil
// ... other dependencies
}
Test Implementation
Tests call Run() with test arguments and capture output:
package cmd_test
import (
"bytes"
"context"
"testing"
"yourproject/cmd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSubCommand(t *testing.T) {
var stdout bytes.Buffer
exitCode := cmd.Run([]string{"sub-command", "-f", "filename.ext"}, cmd.RunOptions{
Stdout: &stdout,
})
require.Equal(t, 0, exitCode)
assert.Contains(t, stdout.String(), "expected output")
}
func TestSubCommandWithContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var stdout bytes.Buffer
exitCode := cmd.Run(ctx, []string{"server", "start"}, cmd.RunOptions{
Stdout: &stdout,
})
require.Equal(t, 0, exitCode)
}
HTTP API Testing Pattern
Code Structure
Structure HTTP services with a testable server lifecycle:
type Server struct {
// ... server state
}
func NewServer(opts ServerOptions) *Server {
return &Server{...}
}
func (s *Server) Start(ctx context.Context, addr string) error {
// Start HTTP server
}
func (s *Server) Shutdown(ctx context.Context) error {
// Graceful shutdown
}
Test Implementation
Tests start a real server and make HTTP requests:
package api_test
import (
"context"
"net/http"
"testing"
"yourproject/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateUser(t *testing.T) {
server := api.NewServer(api.ServerOptions{
// ... actual or mocked dependencies
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go server.Start(ctx, "localhost:0")
defer server.Shutdown(context.Background())
// Make actual HTTP request
resp, err := http.Post(
"http://localhost:8080/users",
"application/json",
strings.NewReader(`{"name":"Alice"}`),
)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
}
Library Testing Pattern
Code Structure
Export public functions that encapsulate business logic:
package converter
// Public API
func Convert(input []byte, opts ConvertOptions) (*Result, error) {
// Implementation
}
// Internal functions (not tested directly)
func parseSchema(data []byte) (*schema, error) {
// Internal implementation
}
Test Implementation
Tests call exported functions only:
package converter_test
import (
"testing"
"yourproject/converter"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConvert(t *testing.T) {
for _, test := range []struct {
name string
input string
expected string
}{
{
name: "simple conversion",
input: "input data",
expected: "expected output",
},
} {
t.Run(test.name, func(t *testing.T) {
result, err := converter.Convert([]byte(test.input), converter.ConvertOptions{
PackageName: "testpkg",
})
require.NoError(t, err)
assert.Equal(t, test.expected, string(result.Output))
})
}
}
Testing Internal Behavior via Observability
The Problem
Some important code paths execute without direct user interaction. Examples:
- Background workers (cache eviction, garbage collection)
- Periodic operations (WAL writes, checkpoints, compaction)
- Performance optimizations (query plan caching, connection pooling)
If these behaviors are important enough to test, they're important enough to expose to users.
The Solution: Expose Statistics and Observability
Instead of testing internal functions, expose public APIs that make internal behavior observable.
Example: Database WAL Writes
Bad Approach - Testing internal functions:
// DON'T DO THIS
package db
func (db *DB) writeWAL() error { ... }
// Test calls internal function
func TestWriteWAL(t *testing.T) {
db := NewDB()
err := db.writeWAL() // Testing internal function
require.NoError(t, err)
}
Good Approach - Expose statistics API:
package db
type Stats struct {
WALWriteCount int64
WALLastWriteTime time.Time
DirtyPages int64
PagesFlushedToWAL int64
PagesFlushedToDB int64
}
func (db *DB) Stats() Stats {
return db.stats.snapshot()
}
// Internal function - not tested directly
func (db *DB) writeWAL() error {
// Implementation
db.stats.walWriteCount++
db.stats.walLastWriteTime = time.Now()
}
Test using public Stats API:
package db_test
func TestWALPeriodicWrite(t *testing.T) {
db := NewDB(DBOptions{
WALFlushInterval: 100 * time.Millisecond,
})
defer db.Close()
// Perform operations
require.NoError(t, db.Insert("key", "value"))
// Poll statistics until expected behavior occurs
require.Eventually(t, func() bool {
stats := db.Stats()
return stats.WALWriteCount > 0 && stats.DirtyPages == 0
}, time.Second, 10*time.Millisecond)
stats := db.Stats()
assert.Greater(t, stats.PagesFlushedToWAL, int64(0))
}
Benefits of Statistics APIs
- Users benefit: Statistics are useful for monitoring, debugging, and optimization
- Tests verify real behavior: Tests observe actual system behavior, not mocked internals
- No test-only code: The statistics API is production code that serves real users
- Better diagnostics: Users can troubleshoot issues using the same APIs tests use
Decision Tree for Untestable Code
If code cannot be reached via the public interface:
Can this code be reached via public interface?
├─ No → Is this code important to project correctness?
│ ├─ No → Remove the code (it's dead code)
│ └─ Yes → Expose observability (statistics, metrics, debug APIs)
└─ Yes → Test via public interface
When to Extract Internal Packages
The Problem Domain Principle
Not all internal functionality should remain buried in a single package. When internal code represents a distinct problem domain that's part of your deliverable product, it should be extracted into its own internal package with a well-defined public interface.
Identifying Extraction Candidates
Ask: "Is this internal functionality part of the observable product contract?"
Examples of problem domains worth extracting:
- Database page format: Users expect format consistency across versions
- Wire protocol encoding: The encoding scheme is part of the API contract
- Configuration file parsing: The config format is part of the user interface
- Log format serialization: Log structure is an observable product feature
Counter-examples (keep as private functions):
- Helper functions for string manipulation
- Internal cache eviction algorithms
- Temporary data structure transformations
Example: Database Page Marshalling
Before - Monolithic package:
package db
type DB struct {
file *os.File
}
// Tightly coupled to DB internals
func (db *DB) writePage(page *page) error {
data := marshalPage(page) // Private function
return db.file.Write(data)
}
func marshalPage(p *page) []byte {
// Complex marshalling logic
}
func unmarshalPage(data []byte) (*page, error) {
// Complex unmarshalling logic
}
// Can't test marshalling without testing entire DB
After - Extracted internal package:
// internal/pageformat/format.go
package pageformat
// Marshal converts a Page into wire format
func Marshal(p *Page) ([]byte, error) {
// Complex marshalling logic
}
// Unmarshal converts wire format into a Page
func Unmarshal(data []byte) (*Page, error) {
// Complex unmarshalling logic
}
// internal/pageformat/format_test.go
package pageformat_test
import (
"testing"
"yourproject/internal/pageformat"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMarshalUnmarshal(t *testing.T) {
for _, test := range []struct {
name string
page *pageformat.Page
}{
{
name: "empty page",
page: &pageformat.Page{ID: 1, Data: nil},
},
{
name: "page with data",
page: &pageformat.Page{ID: 42, Data: []byte("content")},
},
} {
t.Run(test.name, func(t *testing.T) {
data, err := pageformat.Marshal(test.page)
require.NoError(t, err)
result, err := pageformat.Unmarshal(data)
require.NoError(t, err)
assert.Equal(t, test.page, result)
})
}
}
func TestUnmarshalInvalidData(t *testing.T) {
for _, test := range []struct {
name string
data []byte
wantErr string
}{
{
name: "empty data",
data: []byte{},
wantErr: "insufficient data",
},
{
name: "corrupt header",
data: []byte{0xFF, 0xFF, 0xFF},
wantErr: "invalid header",
},
} {
t.Run(test.name, func(t *testing.T) {
_, err := pageformat.Unmarshal(test.data)
require.ErrorContains(t, err, test.wantErr)
})
}
}
// db.go - now uses the extracted package
package db
import "yourproject/internal/pageformat"
type DB struct {
file *os.File
}
func (db *DB) writePage(page *pageformat.Page) error {
data, err := pageformat.Marshal(page)
if err != nil {
return err
}
return db.file.Write(data)
}
Benefits of Extraction
- Testable Boundaries: Complex logic gets its own focused test suite
- Clear Interfaces: Package boundaries force explicit API design
- Tight Coupling, Loose Organization: Code remains tightly coupled (it should be) but with clean separation
- Reusability: Other parts of the system can use the same package
- Easier Reasoning: Each package has a single, well-defined responsibility
Testing Internal Packages
Apply the same functional testing principles:
- Tests in
package XXX_testto enforce public interface testing - Test the exported functions only
- No access to internal package details
- Comprehensive coverage of the public API surface
The fact that it's an internal/ package doesn't change testing strategy - you still test the public interface of that package.
When NOT to Extract
Don't extract packages for:
- Simple helpers: Pure utility functions without domain meaning
- Implementation details: Truly private algorithms that aren't part of product contract
- Premature abstraction: Wait until the domain boundary is clear
If internal code doesn't represent a distinct problem domain that users depend on (even implicitly), keep it as private functions.
Key Principles
- Test Behavior, Not Implementation: Tests should verify what the system does, not how it does it
- Tests Are End-Users: If a test needs to call internal functions, the code structure is wrong
- Unreachable Code Path: If code cannot be tested via public interface, either remove it or expose observability
- Dependency Injection: Use options structs to inject testable dependencies (stdout, http clients, etc.)
- Real Execution: Tests should execute real code paths, not mocks of the main logic
- Package Separation: Tests MUST be in
package XXX_testto enforce public interface testing - Problem Domain Extraction: When internal logic represents a distinct problem domain, extract it to an internal package with testable boundaries
When to Deviate
Never test internal functions directly. Instead:
- If internal logic is complex but not important: Remove or simplify it
- If internal logic is complex and important: Expose it as a public API or extract to separate package
- If internal behavior is important but implicit: Add statistics/observability APIs
Benefits
- Tests verify actual user experience
- Refactoring internal code doesn't break tests
- Tests serve as usage examples
- Higher confidence in deployments
- Forces good API design