Overview

5 Dependency injection

This chapter explains how to make a Go command-line tool testable by isolating and injecting its dependencies. It begins with the core challenge: the main function behaves like a black box that depends on global state (standard streams, process arguments, and exits), which leads to flaky, hard-to-parallelize tests. The remedy is to structure the program so that business logic is exercised independently of globals, using interfaces to observe side effects and verify behavior deterministically.

The refactor centers on extracting main’s logic into a run function that accepts an env struct holding dependencies: stdout and stderr as io.Writer, the argument slice, and a dryRun flag. With this, code prints via Fprintf to injected Writers, reads arguments from env.args, and returns errors instead of exiting. The flag parser is similarly decoupled by accepting a Writer to route usage and error output. Writer implementations are swapped by context: os.Stdout and os.Stderr when running normally, and strings.Builder during tests. A small dryRun branch prevents real HTTP work while keeping the flow intact, illustrating a pragmatic approach where a simple conditional can replace more elaborate abstractions when appropriate.

Testing is streamlined by capturing output with strings.Builder through a testEnv that mirrors env’s outputs, plus a testRun helper that wires arguments, enables dryRun, and invokes run. CLI tests validate end-to-end behavior by asserting what appears on stdout and stderr for valid and invalid inputs, while unit tests focus on the parser with table-driven cases and io.Discard to silence usage text. The outcome is code that is observable, deterministic, and safe for parallel execution, demonstrating how dependency injection and interface-driven design turn a previously untestable CLI into a maintainable, well-tested component—an approach that generalizes beyond CLIs to any code that needs to shed hidden global dependencies.

Uncontrolled external dependencies of our program. The main function interacts with global variables that are out of our direct control and reach.
Moving main's code into a run function that accepts an *env struct. This allows main to inject real globals to run, while tests can inject controlled fakes. The env struct has fields for us to inject the global variables, such as the standard error.

Summary

  • CLI tests ensure that the command-line program runs correctly as a whole, while unit tests focus on specifics. They are complementary to each other.
  • Testing a CLI tool should be no different from testing any other code.
  • The main function is like a black box with no input or output parameters.
  • A flaky test randomly passes or fails between runs.
  • Parallel testing code that uses globals is not trivial.
  • We can make a function testable by decoupling it from its dependencies.
  • Refactoring should only change the structure of the code, not the behavior.
  • io.Writer standardizes and abstracts writing to any stream. It has a single Write method that writes a []byte to an underlying stream.
  • os.Stdout and os.Stderr are *os.File variables (an io.Writer).
  • FlagSet.SetOutput sets the FlagSet's output.
  • FlagSet.Output returns the FlagSet's output.
  • FlagSet writes to os.Stderr by default.
  • Go generally favors the most straightforward solution.
  • strings.Builder is a Writer and a Reader. Its methods, like WriteString, String, and Len are declared on pointer receivers (*strings.Builder).
  • Variadic functions can take an arbitrary number of the same type. Passing a slice to a variadic function requires using ellipsis after the slice (e.g., sliceName...).
  • io.Discard is an io.Writer that throws aways the bytes written to it.

FAQ

What makes testing a CLI tool’s main function challenging?The main function is a black box: it takes no parameters and returns no values, while relying on global state such as os.Args, os.Stdout, os.Stderr, and os.Exit. These globals are hard to control, make tests flaky, and complicate parallel testing. The fix is to avoid testing main directly and instead move its logic into a function that accepts explicit dependencies.
How does refactoring to a run function improve testability?Extract main’s core logic into run(*env) and pass all inputs via an env struct. main still wires real globals, but tests can inject controlled fakes. run returns an error instead of calling os.Exit and writes to injected io.Writer streams, making behavior observable and easy to assert.
What is the env struct and what does it carry?env groups external dependencies: stdout io.Writer, stderr io.Writer, args []string, and dryRun bool. In production, env uses os.Stdout, os.Stderr, and os.Args; in tests, you pass fakes (for example, strings.Builder and custom args) to observe outputs and control inputs.
Why use io.Writer for output, and which implementations are used?io.Writer abstracts “where bytes go,” enabling code to write to stdout, files, buffers, or networks uniformly. Production uses *os.File values (os.Stdout/os.Stderr). Tests use strings.Builder to capture output in memory for assertions. Both satisfy io.Writer, so the same code path works in prod and tests.
How is the flag parser decoupled from globals?parseArgs is changed to take an io.Writer for error/usage output. It sets that writer on its flag.FlagSet via SetOutput and thereafter uses fs.Output() to write messages. This removes direct dependence on os.Stderr and lets tests capture parser output.
How do I capture and assert CLI output in tests?Use strings.Builder. Link env.stdout and env.stderr to &tenv.stdout and &tenv.stderr (Builder fields on a testEnv). After running the tool, read tenv.stdout.String() or check tenv.stdout.Len() to assert what was written. Pass pointers (e.g., *strings.Builder) where an io.Writer is required.
What are testEnv and testRun, and how do they help?testEnv holds strings.Builder fields to observe output. testRun(args ...string) creates a testEnv, constructs an env with args (prefixed with "hit"), wires stdout/stderr to the builders, enables dryRun, calls run, and returns both the testEnv and any run error. This keeps CLI tests short and clear.
What is dryRun and why might I prefer a conditional over an interface?The dryRun flag on env tells run to skip invoking runHit (the future HTTP client). It allows testing the CLI without performing network work. A simple conditional is often the most idiomatic and pragmatic solution; introduce interfaces only if you need to simulate varying behaviors or swap implementations.
How do I unit test the flag parser effectively?Use table-driven tests with a helper struct holding name, args, and expected config. Call parseArgs(&cfg, args, io.Discard). For valid cases, compare cfg to expected values. For invalid cases (bad syntax, zero/negative values, missing URL), assert that parseArgs returns an error. io.Discard keeps test logs clean.
Any gotchas with strings.Builder, method sets, and copying stateful values?- strings.Builder satisfies io.Writer only via its pointer receiver; pass &builder where an io.Writer is needed. Using zero values is convenient, but avoid copying stateful types (like strings.Builder or sync.Mutex), which can cause panics or subtle bugs. Also remember that using &tenv.stdout lets Print/format functions call the appropriate methods automatically.

pro $24.99 per month

  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose one free eBook per month to keep
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime

lite $19.99 per month

  • access to all Manning books, including MEAPs!

team

5, 10 or 20 seats+ for your team - learn more


choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free
choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free
choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free