Skip to content
gohacks.dev
Testing

Test Command Line Programs in Go From Start to Finish

Test a Go command line program: move the work into run(args, stdin, stdout, stderr) int, drive it from a table, then check the built binary end to end.

7 min read
Summarize in AI
Flat illustration of a test report with one failing test.

A command line program has inputs and outputs that are not function arguments and return values: flags, standard input, files, standard output, standard error and an exit status. The usual first draft puts all of that in main, which cannot be called from a test, and the program goes untested except by hand.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The fix is mechanical. Move the work into a function that takes its inputs and outputs as parameters, and make main the thinnest wrapper that is practical. This article shows that on a small wc clone, with the tests and the built binary run on Go 1.25.6.

Move everything into run

wc.goGo
package main

import (
	"bufio"
	"flag"
	"fmt"
	"io"
	"os"
)

// run is the whole program. main only wires the real streams and exits.
func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("wc", flag.ContinueOnError)
	fs.SetOutput(stderr)
	lines := fs.Bool("l", false, "count lines only")
	if err := fs.Parse(args); err != nil {
		if err == flag.ErrHelp {
			return 0
		}
		return 2
	}

	in := stdin
	if name := fs.Arg(0); name != "" && name != "-" {
		f, err := os.Open(name)
		if err != nil {
			fmt.Fprintf(stderr, "wc: %v\n", err)
			return 1
		}
		defer f.Close()
		in = f
	}

	sc := bufio.NewScanner(in)
	if !*lines {
		sc.Split(bufio.ScanWords)
	}
	n := 0
	for sc.Scan() {
		n++
	}
	if err := sc.Err(); err != nil {
		fmt.Fprintf(stderr, "wc: %v\n", err)
		return 1
	}
	fmt.Fprintln(stdout, n)
	return 0
}

func main() {
	os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}

Look at the signature: run(args []string, stdin io.Reader, stdout, stderr io.Writer) int. The program reads its arguments from the slice, reads input from stdin, writes results to stdout, writes diagnostics to stderr and reports success or failure as an integer. There is no global state. main passes os.Args[1:] and the real streams and hands the result to os.Exit. The line os.Exit(...) is the only place the process ends, which matters because os.Exit skips deferred functions and kills the test binary if a test reaches it; testing os.Exit paths covers the cases where you cannot avoid it.

Two details make flag handling testable. First, flag.NewFlagSet("wc", flag.ContinueOnError) instead of the global flag.CommandLine. The default behaviour of the global set is ExitOnError, which calls os.Exit(2) on a bad flag from inside the parser. With ContinueOnError, Parse returns the error and run decides the exit code. Second, fs.SetOutput(stderr) routes usage and error text to the injected writer rather than to the real os.Stderr. Without it, a test would pass and the program would still print to the terminal during the run.

flag.ErrHelp is returned for -h, after the usage text has been printed. The program treats it as success and returns 0. That is a choice, and many tools do the same; the test records it.

Drive it from a table

wc_test.goGo
package main

import (
	"bytes"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func TestRun(t *testing.T) {
	dir := t.TempDir()
	file := filepath.Join(dir, "in.txt")
	if err := os.WriteFile(file, []byte("a b\nc\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	tests := []struct {
		name       string
		args       []string
		stdin      string
		wantCode   int
		wantOut    string
		wantErrHas string
	}{
		{"words from stdin", nil, "one two three\n", 0, "3\n", ""},
		{"lines from stdin", []string{"-l"}, "x\ny\n", 0, "2\n", ""},
		{"dash means stdin", []string{"-"}, "a b", 0, "2\n", ""},
		{"file argument", []string{file}, "", 0, "3\n", ""},
		{"missing file", []string{filepath.Join(dir, "nope")}, "", 1, "", "no such file"},
		{"unknown flag", []string{"-z"}, "", 2, "", "flag provided but not defined: -z"},
		{"help", []string{"-h"}, "", 0, "", "Usage of wc"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var out, errb bytes.Buffer
			code := run(tt.args, strings.NewReader(tt.stdin), &out, &errb)
			if code != tt.wantCode {
				t.Errorf("exit code = %d, want %d", code, tt.wantCode)
			}
			if out.String() != tt.wantOut {
				t.Errorf("stdout = %q, want %q", out.String(), tt.wantOut)
			}
			if !strings.Contains(errb.String(), tt.wantErrHas) {
				t.Errorf("stderr = %q, want it to contain %q", errb.String(), tt.wantErrHas)
			}
		})
	}
}

A table keeps the cases visible, as in table driven tests. Each row names its arguments, standard input, expected exit code, expected standard output and a substring expected in standard error. The loop builds a strings.Reader for input and two bytes.Buffer values for output, calls run, and compares three things.

The rows are chosen to pin down behaviours that change in refactors:

  • words from stdin and lines from stdin check the default mode and the -l flag.
  • dash means stdin checks the Unix convention that - stands for standard input.
  • file argument uses a file written under t.TempDir, so the open path is real.
  • missing file expects code 1 and the OS error text on stderr, nothing on stdout.
  • unknown flag expects code 2, the exit status convention for misuse, and the flag package's message.
  • help expects code 0 and the usage header.

Standard output is compared with == because it is the program's contract; standard error is compared with strings.Contains because its exact wording often includes paths or library messages that you do not want to freeze. Choose per stream. If a script parses your output, compare it exactly. If a human reads it, check the part that carries meaning.

Assert the exit code in every row, including the successes. A refactor that accidentally returns 0 for a failure is the most dangerous regression in a CLI, since shell scripts and CI jobs trust it.

Run the real binary once

A unit test of run does not prove that main passes the right streams or that the build works. The lab's script builds the binary and runs three commands:

Shell
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//'
go build -o wc . && printf 'a b c\n' | ./wc; printf 'x\ny\n' | ./wc -l; ./wc -z; echo "exit=$?"; ./wc /nope; echo "exit=$?"
Text
--- PASS: TestRun
    --- PASS: TestRun/words_from_stdin
    --- PASS: TestRun/lines_from_stdin
    --- PASS: TestRun/dash_means_stdin
    --- PASS: TestRun/file_argument
    --- PASS: TestRun/missing_file
    --- PASS: TestRun/unknown_flag
    --- PASS: TestRun/help
3
2
exit=2
exit=1
flag provided but not defined: -z
Usage of wc:
  -l	count lines only
wc: open /nope: no such file or directory

The first go test section is the table above. After it, piping three words into ./wc printed 3, piping two lines into ./wc -l printed 2, ./wc -z printed the flag package's message and usage and exited with status 2, and ./wc /nope exited with 1 and open /nope: no such file or directory. These match the table, which is what you want from an end-to-end check: the same behaviour through the real entry point.

For larger programs, an automated version of that last step is worth having. Build the binary in TestMain into t.TempDir(), run it with os/exec, and capture output in buffers. The third-party rogpeppe/go-internal/testscript package, which the Go toolchain's own tests use, runs scripts of commands and checks their output; read its documentation before adopting it, because it is a different style of test, not a drop-in for the table.

Reading input the test cannot see

Because run takes an io.Reader, it does not know whether the input is a terminal, a pipe or a string. That is the point, but it hides one behaviour worth testing on purpose: what happens with input that is large or malformed. bufio.Scanner has a default token limit of 64 KiB, so a single line longer than that makes sc.Err() return bufio.ErrTooLong, and the program above would exit 1. A row that feeds strings.Repeat("x", 70_000) with -l would pin that behaviour down, and so would a reader that returns an error after a few bytes, written as a three-line type with a Read method. Both are cheap with injected streams and nearly impossible to arrange with a real terminal.

Environment, configuration and files

Programs that read environment variables should not call os.Getenv inside run. Pass the lookup in, or read it in main into a struct, so tests do not touch the process environment. If you must, use t.Setenv. Programs that print help or version text benefit from golden files: the expected output lives in testdata/, and a flag rewrites it when the change is intentional.

What stays untested, and why that is fine

After this refactor, main is a single statement. It can only be wrong if it passes the arguments in the wrong order or forgets os.Exit, and the built-binary check catches both. Chasing a coverage number for main means subprocess tricks for no benefit. Put the effort into run and the packages it calls.

If you use Cobra or another command framework, the same idea applies. Build the command in a function, call cmd.SetArgs, cmd.SetIn, cmd.SetOut and cmd.SetErr, and run cmd.Execute(). The framework gives you the streams as methods rather than parameters, but the pattern is identical. The flag package behaviour used here is documented at pkg.go.dev/flag, and the conventions for exit codes in Go programs come from the os and syscall documentation at pkg.go.dev/os.

Also inTooling
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.