๐Ÿงช Unit Testing: Commands vs Mocks in Go ๐Ÿ”—

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))
}

๐Ÿ“‘ Table of Contents ๐Ÿ”—


โš ๏ธ The Problem ๐Ÿ”—

This Benchmark function does several things:

  1. Builds a binary.
  2. Reads a source file.
  3. Loops to edit the source, measure build time, and revert the file.
  4. Writes the final report.

Testing this directly is hard because it relies on four external dependencies:

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.


1๏ธโƒฃ Approach 1: Move Code to 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.


2๏ธโƒฃ Approach 2: Dependency Injection (Mocks/Fakes) ๐Ÿ”—

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.


3๏ธโƒฃ Approach 3: The Command Pattern ๐Ÿ”—

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() ...
}

๐Ÿ’ก Why Commands are Better for Testing ๐Ÿ”—

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")
	}
}

๐ŸŽฏ Summary ๐Ÿ”—

By turning function calls into data structures (Commands), you decouple your business logic from its side effects.


๐Ÿ“š Download My Free Ebook ๐Ÿ”—

Get the curated offline edition of Kaue's Way of Lifeโ€”a practical field guide to optimizing ๐Ÿ Health, ๐Ÿ’ฐ Wealth, โค๏ธ Love, and ๐Ÿง˜ Meaning.

Kaue's Way of Life

Kaue's Way of Life
Minimum price
Free!
Suggested price
$0.00

๐Ÿ“ฌ Join My Mailing List ๐Ÿ”—

Get notified when I publish new essays, book summaries, and practical guides on living well. No spamโ€”unsubscribe anytime.


๐Ÿ’ฌ Comments & Discussion ๐Ÿ”—

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.