| 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:
func Run(args []string, opts RunOptions) int
func Run(ctx context.Context, args []string, opts RunOptions) int
type RunOptions struct {
Stdout io.Writer
Stderr io.Writer
}
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 {
}
func NewServer(opts ServerOptions) *Server {
return &Server{...}
}
func (s *Server) Start(ctx context.Context, addr string) error {
}
func (s *Server) Shutdown(ctx context.Context) error {
}
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{
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go server.Start(ctx, "localhost:0")
defer server.Shutdown(context.Background())
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
func Convert(input []byte, opts ConvertOptions) (*Result, error) {
}
func parseSchema(data []byte) (*schema, error) {
}
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:
package db
func (db *DB) writeWAL() error { ... }
func TestWriteWAL(t *testing.T) {
db := NewDB()
err := db.writeWAL()
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()
}
func (db *DB) writeWAL() error {
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()
require.NoError(t, db.Insert("key", "value"))
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
}
func (db *DB) writePage(page *page) error {
data := marshalPage(page)
return db.file.Write(data)
}
func marshalPage(p *page) []byte {
}
func unmarshalPage(data []byte) (*page, error) {
}
After - Extracted internal package:
package pageformat
func Marshal(p *Page) ([]byte, error) {
}
func Unmarshal(data []byte) (*Page, error) {
}
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, , },
wantErr: ,
},
} {
t.Run(test.name, {
_, err := pageformat.Unmarshal(test.data)
require.ErrorContains(t, err, test.wantErr)
})
}
}
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_test to 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_test to 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