Dependency injection
main is hard to test because it produces side effects: actions that reach outside a function’s own scope. Common side effects include:
- Reading from
os.Argsor environment variables - Writing to
os.Stdoutoros.Stderr - Calling
os.Exit - Reading or writing files directly
You can’t capture what was written to stdout in a test, and you can’t stop os.Exit from killing your test process. Dependency injection solves this by making those dependencies explicit parameters rather than global calls. Go doesn’t need a framework for it. Interfaces, structs, and explicit parameters are enough.
Decoupling main
Move the core logic of main into a run function and collect all external dependencies into an env struct. Pass real dependencies from main and controlled substitutes in tests.
- Declare a
runfunction next tomain. - Move the core logic into
run. Return an error instead of callingos.Exit. - Create an
envstruct to hold external dependencies: writers, args, and any other I/O. - Pass
envtorun. - In
main, constructenvwith real OS values and pass it torun.
This confines all side effects to main. run never reads from os.Args, writes to os.Stdout, or calls os.Exit directly.
Before
This main is tightly coupled to global dependencies:
parseArgsreads directly fromos.Args.os.Exitterminates the process on error.fmt.Printfwrites output directly to stdout.
func main() {
c := config{
n: 100,
c: 1,
}
if err := parseArgs(&c, os.Args[1:]); err != nil {
os.Exit(1)
}
fmt.Printf(
"%s\n\nSending %d requests to %q (concurrency: %d)\n",
logo, c.n, c.url, c.c)
}
None of these can be redirected or substituted in a test.
After
First, create an env struct to hold the external dependencies:
stdoutaccepts output so tests can capture it instead of it going to the terminal.stderraccepts error output for the same reason.argsaccepts a string slice instead of readingos.Argsdirectly.
type env struct {
stdout io.Writer // 1
stderr io.Writer // 2
args []string // 3
dryRun bool
}
Next, move the logic from main into run. It accepts *env and returns an error instead of calling os.Exit:
func run(e *env) error {
c := config{
n: 100,
c: 1,
}
if err := parseArgs(&c, e.args[1:], e.stderr); err != nil {
return err
}
fmt.Fprintf(
e.stdout,
"%s\n\nSending %d requests to %q (concurrency: %d)\n",
logo, c.n, c.url, c.c,
)
return nil
}
main constructs env with real OS values and passes it to run:
func main() {
if err := run(&env{
stdout: os.Stdout,
stderr: os.Stderr,
args: os.Args,
}); err != nil {
os.Exit(1)
}
}
Pass env.stderr to parseArgs so flag errors go to the injected writer, not directly to the terminal:
func parseArgs(c *config, args []string, stderr io.Writer) error {
fs := flag.NewFlagSet("hit", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "usage: %s [options] url\n", fs.Name())
fs.PrintDefaults()
}
// ...
}
Testing
Pass controlled substitutes to run in place of the real OS values. bytes.Buffer implements io.Writer, so it captures anything written to stdout or stderr:
var out bytes.Buffer
var errOut bytes.Buffer
e := &env{
stdout: &out,
stderr: &errOut,
args: []string{"cmd", "-n", "10", "https://example.com"},
}
err := run(e)
After run returns, inspect out.String() and errOut.String() to assert on what was written. The test process is never at risk from os.Exit.