Skip to content
shellhacks.dev
Testing

How to Test Go CLI Tools With Table Driven Tests Properly

Test Go command line tools with table driven tests: a testable run function, in-memory streams, subtests, temp directories and a real binary test.

7 min read
Summarize in AI
Illustration for "How to Test Go CLI Tools With Table Driven Tests Properly": a terminal session, test results, a loop arrow.

A Go program that calls os.Exit deep inside and writes straight to os.Stdout cannot be tested without running it as a process. A small restructuring makes it testable in memory: pass the input and output streams in, and return the exit code. After that, table-driven tests, the Go idiom for many cases of the same shape, cover the behavior with little code. This post shows the structure, as the Go sibling of pytest for CLI tools. Targets Go 1.24.

The testable shape

main.goGo
package main

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

func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("greet", flag.ContinueOnError)
	fs.SetOutput(stderr)
	shout := fs.Bool("shout", false, "upper-case the greeting")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	if fs.NArg() != 1 {
		fmt.Fprintln(stderr, "usage: greet [-shout] NAME")
		return 2
	}
	msg := "hello " + fs.Arg(0)
	if *shout {
		msg = strings.ToUpper(msg)
	}
	fmt.Fprintln(stdout, msg)
	return 0
}

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

The flag set is created inside run with ContinueOnError, so a bad flag returns an error instead of exiting the process, and its output goes to the injected stderr. The global flag.CommandLine would carry state between tests. Everything the function needs comes in through parameters.

The table

main_test.goGo
package main

import (
	"bytes"
	"strings"
	"testing"
)

func TestRun(t *testing.T) {
	tests := []struct {
		name     string
		args     []string
		wantCode int
		wantOut  string
		wantErr  string
	}{
		{"plain", []string{"ada"}, 0, "hello ada\n", ""},
		{"shout", []string{"-shout", "ada"}, 0, "HELLO ADA\n", ""},
		{"missing name", nil, 2, "", "usage:"},
		{"unknown flag", []string{"-nope", "ada"}, 2, "", "flag provided but not defined"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			var out, errb bytes.Buffer
			code := run(tc.args, strings.NewReader(""), &out, &errb)
			if code != tc.wantCode {
				t.Errorf("exit code = %d, want %d", code, tc.wantCode)
			}
			if out.String() != tc.wantOut {
				t.Errorf("stdout = %q, want %q", out.String(), tc.wantOut)
			}
			if !strings.Contains(errb.String(), tc.wantErr) {
				t.Errorf("stderr = %q, want it to contain %q", errb.String(), tc.wantErr)
			}
		})
	}
}
Shell
go test ./... -run TestRun -v

Each case is a struct, and each runs as a named subtest, so a failure says which case broke and -run TestRun/shout runs one. Adding a case is one line. Match error text with Contains on a stable fragment, since full messages change. Since Go 1.22, the loop variable is per iteration, so subtests are safe to run in parallel with t.Parallel().

Files and environment

Go
func TestReadsFile(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "names.txt")
	if err := os.WriteFile(path, []byte("ada\ngrace\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	t.Setenv("GREET_PREFIX", "hi")
	var out bytes.Buffer
	if code := run([]string{"-file", path}, strings.NewReader(""), &out, io.Discard); code != 0 {
		t.Fatalf("code %d", code)
	}
	want, _ := os.ReadFile("testdata/names.golden")
	if out.String() != string(want) {
		t.Errorf("got %q", out.String())
	}
}

t.TempDir() creates a directory removed at the end of the test, and t.Setenv sets a variable and restores it. The testdata directory is ignored by the Go tool and holds expected-output files; see golden file tests for the update workflow.

One test on the real binary

Go
func TestBinary(t *testing.T) {
	bin := filepath.Join(t.TempDir(), "greet")
	if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
		t.Fatalf("build: %v\n%s", err, out)
	}
	out, err := exec.Command(bin, "ada").Output()
	if err != nil || string(out) != "hello ada\n" {
		t.Fatalf("got %q, %v", out, err)
	}
}

This one checks what run cannot: that main wires the real streams and that the build works. Keep it to one or two. For richer scripted tests of the compiled command, the testscript package from github.com/rogpeppe/go-internal runs files of shell-like commands and compares output.

Where this does not apply

Code that must call os.Exit in the middle, such as a helper called from many places, should return errors up to run instead. Interactive tools that need a terminal need a pseudo-terminal in tests. For the structure of the tool itself, see building a command line tool in Go and testing CLI tools.

The shape that makes a Go command testable

The standard main function is hard to test because it calls os.Exit and reads the global arguments, output streams and environment. Move the work into a function with explicit inputs: run(args []string, stdin io.Reader, stdout, stderr io.Writer) int, which returns the exit code, and make main a single line that calls it with the real values and passes the result to os.Exit. Parse flags with a fresh flag.NewFlagSet inside run, with the error handling set to return instead of exiting, so each test starts with clean flag state, since the global flag set can only be defined once per process. The tests pass buffers for the streams and a slice for the arguments, and assert on what comes out. This is the same idea as dependency injection with the standard library's interfaces, and it needs no framework. It also makes the program easier to reuse, since the same function can serve another front end.

Keep logic out of run itself. It should parse, call a function that does the work and format the result, and the function can be checked with ordinary values and no streams at all.

Writing the table well

A table-driven test is a slice of structs, one per case, with a name, the arguments, the input, the expected output and the expected exit code, and a loop that runs each with t.Run(tc.name, ...). Subtests give each case its own name in the output, so a failure points at the case, and you can run one with -run. Write the table so each row reads as a sentence: the name should say what is being checked, such as "missing file reports an error and exits 2". Put the interesting cases in the table, not just the happy path: no arguments, an unknown flag, an empty input, a very long line, invalid UTF-8, a file that does not exist, and a flag given twice. When a bug is found, add a row that reproduces it before fixing it. Compare output with a helper that prints the difference, such as cmp.Diff from the go-cmp package, since a plain inequality of two long strings is hard to read.

Files, environment and the real binary

t.TempDir() returns a directory that the test framework removes afterwards, which is where tests should create the files that the command reads and writes. t.Setenv sets an environment variable for the duration of one test and restores it, and it cannot be combined with t.Parallel, since the environment is shared by the whole process, so choose between parallelism and environment changes per test. Never let a test read the developer's real home directory or configuration. For output that is large, golden files in a testdata directory are the Go convention, with an -update flag that rewrites them. Keep one or two tests that run the compiled binary, built with go build in TestMain or invoked through exec.Command, to check what in-process tests cannot: the real exit status, signal handling and the behavior of the standard streams. For scripts that feed a program a sequence of commands and compare results, the testscript package from the go-internal module gives a compact text format built for this.

Mistakes to avoid and a sensible mix

Do not call os.Exit or log.Fatal inside the functions you test, since either ends the test binary abruptly with a confusing result. Return errors, and let run turn them into messages and exit codes. Do not share mutable state between table rows, such as a buffer declared outside the loop, since a failure in one row then corrupts the next. Capture the loop variable correctly in parallel subtests on Go versions before 1.22, where it was shared between iterations. A sensible mix is many fast table-driven tests on run with buffers, a handful of golden-file tests for large output, and one or two tests of the real binary for the process-level behavior. Run the race detector in CI, and run the suite with -count=1 when you suspect the test cache is hiding a dependency on an environment variable or a file that changed.

See the TableDrivenTests wiki page and the testing package.

Also inGo
Taggedgotesting
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.