When writing unit tests in Go, handling external dependencies like file systems, system clocks, or external commands can be tricky. This post explores three approaches to tackling this, ultimately showing why the Command Pattern offers a clean, testable alternative to complex mocks.
Let’s look at an example: a function designed to benchmark incremental builds.
func Benchmark(binary string, sourcePath string, resultPath string, numBuilds int) error {
if err := build(binary); err != nil {
return err
}
source, err := read(sourcePath)
if err != nil {
return err
}
deltas := []time.Duration{}
for i := 0; i < numBuilds; i++ {
if err := write(sourcePath, edit(source, i)); err != nil {
return err
}
start := now()
if err := build(binary); err != nil {
return err
}
delta := now().Sub(start)
deltas = append(deltas, delta)
if err := revert(sourcePath); err != nil {
return err
}
}
return write(resultPath, report(deltas))
}
This Benchmark function does several things:
Testing this directly is hard because it relies on four external dependencies:
build()read() and write()now()revert()Many developers might only test the self-contained edit and report functions, leaving the core orchestration untested. Here is how we can improve the design to make the entire process testable.
Main
๐Sometimes, you can push the dependencies up to the caller (often main).
func Benchmark(binary string, sourcePath string, source string, numBuilds int) (string, error) {
// ... core loop logic remains ...
// returning the report string instead of writing it
return report(deltas), nil
}
func Main() error {
// setup and initial build moved here
source, _ := read(sourcePath)
result, _ := Benchmark(binary, sourcePath, source, 3)
return write(resultPath, result)
}
Verdict: While this helps, it’s not enough here. The Benchmark loop still fundamentally needs to interact with the build system, clock, file system, and version control during every iteration.
The traditional object-oriented approach is Dependency Injection. You define an interface representing all external actions.
type Delegate interface {
Build(binary string) error
Read(path string) (string, error)
Write(path string, content string) error
Now() time.Time
Revert(path string) error
}
type BenchmarkRunner struct {
Delegate Delegate
}
func (b *BenchmarkRunner) Run(binary string, sourcePath string, resultPath string, numBuilds int) error {
// ... implementation using b.Delegate.Build(), b.Delegate.Now(), etc ...
}
Verdict: This works well and allows you to swap in a fake delegate for testing. However, it requires defining interfaces and mock implementations for every type of side effect, which can lead to interface bloat.
Instead of defining specific interfaces for every action, we can treat every side effect as a generic command to be executed by a central “Runner.”
First, define what it means to run a command:
type Runner interface {
Run(cmd Command) (Result, error)
}
Next, define your specific commands as simple structs (or constants):
type BuildCmd struct { Binary string }
type ReadCmd struct { Path string }
type WriteCmd struct { Path string; Content string }
type NowCmd struct {}
type RevertCmd struct { Path string }
Now, refactor the benchmark logic to return the commands it wants to run, or yield control to a runner:
type BenchmarkProcess struct {
runner Runner
}
func (p *BenchmarkProcess) Execute(binary, sourcePath, resultPath string, numBuilds int) error {
_, err := p.runner.Run(BuildCmd{Binary: binary})
if err != nil { return err }
readRes, err := p.runner.Run(ReadCmd{Path: sourcePath})
if err != nil { return err }
source := readRes.(string) // Type assertion needed here
// ... rest of the loop using p.runner.Run() ...
}
The real power of this pattern shines in unit tests. You only need to write one FakeRunner instead of implementing complex mock interfaces for every dependency.
The test becomes a simple list of expected commands and mocked responses:
func TestBenchmark(t *testing.T) {
// 1. Define the script: "When you see this command, return this result"
fakeRunner := NewFakeRunner([]Step{
{Cmd: BuildCmd{"binary"}, Err: nil},
{Cmd: ReadCmd{"source_path"}, Res: "source code", Err: nil},
{Cmd: WriteCmd{"source_path", "edited code"}, Err: nil},
{Cmd: NowCmd{}, Res: time.Unix(0, 0), Err: nil},
// ... and so on ...
})
p := BenchmarkProcess{runner: fakeRunner}
err := p.Execute("binary", "source_path", "result_path", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 2. Verify the runner consumed all expected steps
if !fakeRunner.IsDone() {
t.Fatalf("did not execute all expected commands")
}
}
By turning function calls into data structures (Commands), you decouple your business logic from its side effects.
Get the curated offline edition of Kaue's Way of Lifeโa practical field guide to optimizing ๐ Health, ๐ฐ Wealth, โค๏ธ Love, and ๐ง Meaning.
Get notified when I publish new essays, book summaries, and practical guides on living well. No spamโunsubscribe anytime.
Have a question, perspective, or suggestion? Share your thoughts below.
Updated on 2026 Oct 11.
DISCLAIMER: The content on this site is for informational and educational purposes only and does not constitute medical, psychological, financial, investment, tax, or legal advice. The ideas, curation, and opinions presented here are my own and do not represent those of my employer. Book, lecture, and article summaries are independent educational commentary and remain the intellectual property of their respective authors. AI tools may be used to assist with editing, formatting, and synthesis; while every effort is made to ensure accuracy, errors may occur. PRIVACY: This site uses third-party services for cookieless analytics (Google Analytics), optional newsletter subscriptions (Mailchimp), comments (Disqus), live chat (Tawk.to), and embedded media (YouTube), which may process visitor IP addresses and cookies in accordance with their respective privacy policies.