JavaRush /Courses /Go SELF /Table-driven CLI tests: args → exit code → output

Table-driven CLI tests: args → exit code → output

Go SELF
Level 53, Lesson 1
Available

1. Why a CLI is convenient to test with table-driven tests

When you start testing a CLI, it quickly becomes clear that there are many scenarios. No arguments—one branch. An unknown command—another. help—a third. A flag error—a fourth. And that’s only the “skeleton”; as soon as you add real functionality, the number of combinations grows like a testing hydra: cut off one head, and three new ones grow back.

The table-driven approach (table tests) is a way to keep all of this under control: you describe a set of cases as a data structure, and then in one loop you run them through your Run and validate the contract. For a CLI this is close to ideal, because a CLI is essentially a function “arguments → result,” except the result consists of three parts at once: exit code, stdout, stderr.

To make the idea concrete, here’s a “model” of a CLI test as a mini diagram:

flowchart LR
    A[args + stdin] --> B["Run(...)"]
    B --> C[exit code]
    B --> D[stdout buffer]
    B --> E[stderr buffer]

2. The CLI test contract: exit code, stdout, stderr

With a CLI it’s easy to make a mistake and “assert only one thing.” For example, a test checks only stdout—and it looks like everything is fine. But scripts and users often don’t rely on text alone: they also rely on the return code. In the Unix world, “correct text + wrong code” is almost like “the right words, but sent to the wrong chat.”

So we define the contract like this: a single command run gives us three observable results.

A convenient way to keep this in mind is:

What we check Where it “lives” Why it matters
Exit code int returned by Run Machine contract: CI, shell scripts, automation
Stdout out (io.Writer) The command’s “normal result” (what you can pipe further)
Stderr errOut (io.Writer) Errors, usage, diagnostics (what you should not mix with the result)

And yes: Go tests in general can capture output and compare it with expectations (for example, this is used in example tests), so the idea “compare what was printed” is completely normal practice.

3. Minimal test: a single scenario

Before making a table with a dozen cases, it’s useful to write a “dumb” test for one scenario once. It’s like learning to slice bread first, and only then building a sandwich assembly line.

Suppose we have a package cli with a function:


func Run(args []string, in io.Reader, out, errOut io.Writer) int

Then a test for the version command may look like this:

package cli

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

func TestRun_Version(t *testing.T) {
	var out bytes.Buffer
	var errOut bytes.Buffer

	code := Run([]string{"version"}, strings.NewReader(""), &out, &errOut)

	if code != exitOK {
		t.Fatalf("code=%d, want %d", code, exitOK)
	}
	if errOut.String() != "" {
		t.Fatalf("stderr=%q, want empty", errOut.String())
	}
	if out.String() != "todo-cli dev\n" {
		t.Fatalf("stdout=%q, want %q", out.String(), "todo-cli dev\n")
	}
}

Two things matter here. First, bytes.Buffer is both memory for accumulating text and a ready-made io.Writer implementation. Second, strings.NewReader("") is a convenient way to provide an io.Reader, even if we don’t need stdin right now.

4. Table-driven tests: cases, stdin, and assertions

Case description and the test loop

Once you have 5–10 scenarios, writing 10 separate TestRun_Xxx tests becomes boring. And boredom in tests is dangerous: you’ll start copy-pasting, and one day you’ll forget to change an expected value (and the test will assert something other than what you think).

A table-driven test starts with something simple: we describe the case structure. Usually it has a name (so t.Run shows a clear report), args, expectations for the code, and for the output streams.

type runCase struct {
	name     string
	args     []string
	wantCode int
	wantOut  string
	wantErr  string
}

Then we define an array of cases and run them in a loop. Inside each t.Run we create new buffers so output doesn’t get “mixed” across cases.

package cli

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

func TestRun_TableDriven(t *testing.T) {
	tests := []runCase{
		{
			name:     "no command",
			args:     nil,
			wantCode: exitUsage,
			wantOut:  "",
			wantErr:  "missing command\n",
		},
		{
			name:     "unknown command",
			args:     []string{"wat"},
			wantCode: exitUsage,
			wantOut:  "",
			wantErr:  "unknown command: wat\n",
		},
		{
			name:     "version",
			args:     []string{"version"},
			wantCode: exitOK,
			wantOut:  "todo-cli dev\n",
			wantErr:  "",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var out bytes.Buffer
			var errOut bytes.Buffer

			code := Run(tt.args, strings.NewReader(""), &out, &errOut)

			if code != tt.wantCode {
				t.Fatalf("code=%d, want %d", code, tt.wantCode)
			}
			if out.String() != tt.wantOut {
				t.Fatalf("stdout=%q, want %q", out.String(), tt.wantOut)
			}
			if errOut.String() != tt.wantErr {
				t.Fatalf("stderr=%q, want %q", errOut.String(), tt.wantErr)
			}
		})
	}
}

Notice a nice effect: as soon as you add a new command or a new error, you add one more element to tests, rather than copying an entire test.

Buffer isolation: why they’re created inside t.Run

At the beginning it’s very tempting to do this: “I’ll create out and errOut once and reuse them.” It feels like an optimization, but in practice it’s a factory for weird bugs.

If you use one buffer for all cases, the second case will start writing “on top of” the first case’s result. Then you’ll see test failures with output like "missing command\nunknown command: wat\n" and you’ll spend 10 minutes on the philosophical question: “why is my program complaining about two errors at once—it’s not that smart.”

So the rule is simple: one t.Run → its own buffers. This is scenario isolation at the memory level. In tests, isolation is half of reliability.

Stdin in table-driven tests: when args isn’t enough

Many commands read something from stdin: for example, “add a task; if text wasn’t passed as an argument, read a line.” Even if we haven’t implemented such a command yet, the table-driven approach extends well: add a stdin field to the test case.

type runCase struct {
	name     string
	args     []string
	stdin    string
	wantCode int
	wantOut  string
	wantErr  string
}

And in the run, plug in strings.NewReader(tt.stdin):

package cli

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

func TestRun_TableDriven_WithStdin(t *testing.T) {
	tests := []runCase{
		{
			name:     "empty stdin treated as usage",
			args:     []string{"add"},
			stdin:    "",
			wantCode: exitUsage,
			wantOut:  "",
			wantErr:  "task text is required\n",
		},
		{
			name:     "stdin provides task text",
			args:     []string{"add"},
			stdin:    "buy milk\n",
			wantCode: exitOK,
			wantOut:  "added: buy milk\n",
			wantErr:  "",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var out bytes.Buffer
			var errOut bytes.Buffer

			code := Run(tt.args, strings.NewReader(tt.stdin), &out, &errOut)

			if code != tt.wantCode {
				t.Fatalf("code=%d, want %d", code, tt.wantCode)
			}
			if out.String() != tt.wantOut {
				t.Fatalf("stdout=%q, want %q", out.String(), tt.wantOut)
			}
			if errOut.String() != tt.wantErr {
				t.Fatalf("stderr=%q, want %q", errOut.String(), tt.wantErr)
			}
		})
	}
}

Even if right now your add doesn’t read stdin yet, the test shape itself shows how it will be validated. The key point: stdin is also part of the contract, just an input part.

Strict comparison vs. checking fragments

Sometimes error output depends on the standard library. A classic example is the flag parser (flag.FlagSet). It may print “usage,” the flag name, a string format, and all of that may change from version to version or depending on how you configured FlagSet.

In such cases, comparing stderr as a whole can be too brittle: you changed the order of usage lines, and tests failed, even though the user-visible behavior stayed acceptable.

Then a compromise is used: for “our” text we assert strictly, and for text from an external component we check that stderr contains an important fragment.

package cli

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

func TestRun_UnknownFlag_MessageFragment(t *testing.T) {
	var out bytes.Buffer
	var errOut bytes.Buffer

	code := Run([]string{"list", "-nope"}, strings.NewReader(""), &out, &errOut)

	if code != exitUsage {
		t.Fatalf("code=%d, want %d", code, exitUsage)
	}
	if out.String() != "" {
		t.Fatalf("stdout=%q, want empty", out.String())
	}
	if !strings.Contains(errOut.String(), "flag provided but not defined") {
		t.Fatalf("stderr=%q, want message about unknown flag", errOut.String())
	}
}

The point here is not to “cut corners,” but to make sure the test checks the essence: that the error is recognized as usage and that the user sees a clear hint like “flag is not defined.”

A small helper so tests don’t grow too much

When the same “ritual” with buffers and calling Run repeats in every t.Run, you want to extract it into a helper. That’s fine: we aren’t reducing assertions, we’re reducing noise.

Let’s make a helper runForTest that returns three values: code, stdout, stderr. Notice t.Helper()— it makes the test failure report clearer (the error will point to the line in the test, not inside the helper).

package cli

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

func runForTest(t *testing.T, args []string, stdin string) (int, string, string) {
	t.Helper()

	var out bytes.Buffer
	var errOut bytes.Buffer

	code := Run(args, strings.NewReader(stdin), &out, &errOut)

	return code, out.String(), errOut.String()
}

And now the table-driven test becomes more compact:

package cli

import "testing"

func TestRun_TableDriven_WithHelper(t *testing.T) {
	tests := []runCase{
		{name: "version", args: []string{"version"}, stdin: "", wantCode: exitOK, wantOut: "todo-cli dev\n", wantErr: ""},
		{name: "unknown", args: []string{"wat"}, stdin: "", wantCode: exitUsage, wantOut: "", wantErr: "unknown command: wat\n"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			code, gotOut, gotErr := runForTest(t, tt.args, tt.stdin)

			if code != tt.wantCode {
				t.Fatalf("code=%d, want %d", code, tt.wantCode)
			}
			if gotOut != tt.wantOut {
				t.Fatalf("stdout=%q, want %q", gotOut, tt.wantOut)
			}
			if gotErr != tt.wantErr {
				t.Fatalf("stderr=%q, want %q", gotErr, tt.wantErr)
			}
		})
	}
}

Yes, putting a case on a single line looks a bit dense, but that’s a style choice. Often cases are formatted across multiple lines, while the loop is kept short.

5. Common mistakes

Mistake #1: testing only stdout and ignoring stderr.
This is one of the most common problems: the command prints an error to stderr, but the test looks only at stdout and is happy with an empty string. In the real world, this can cause you to accidentally treat an error as a “successful but empty result.” The habit is simple: in each case, either assert that stderr is empty, or assert a specific error message.

Mistake #2: not checking the exit code because “the message makes it obvious.”
For a human, it’s obvious. For a bash script, it’s not. If a command returned 0, automation will assume everything is fine even if stderr contains “something went wrong.” That’s why the exit code is a mandatory part of expectations—just like stdout.

Mistake #3: reusing a single bytes.Buffer for all cases.
Tests start “accumulating output” across scenarios, and you get mysterious strings that were never printed by a single run. The fix is simple: create buffers inside t.Run or inside a helper that is called per case.

Mistake #4: comparing the full text generated by flag (usage) and getting brittle tests.
Sometimes the error/usage format can change with small refactorings. If you’re not testing the “help design,” but rather the fact that an error occurred, it’s better to check a fragment (strings.Contains) and separately check the exit code. A full comparison is useful when you truly fixed the format as a contract.

Mistake #5: mixing stdout and stderr into a single stream “for simplicity.”
This makes tests simpler for about 5 minutes, and then painful for years: it becomes impossible to tell what is the command’s result and what is an error message. Keep two writers separate in both code and tests—this discipline pays off very quickly.

1
Task
Go SELF, level 53, lesson 1
Locked
Version command
Version command
1
Task
Go SELF, level 53, lesson 1
Locked
Add a task
Add a task
1
Task
Go SELF, level 53, lesson 1
Locked
Sum of flags
Sum of flags
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION