Testing Go CLIs with Testscript

Brajeshwar1 pts0 comments

Testing Go CLIs with testscript | Redowan's ReflectionsSkip to content<br>Testing Go CLIs with testscript<br>May 18, 2026<br>Table of contentsWhile wrapping up eon<br>, I wanted to test the binary the same way a user would use it. The<br>test couldn&rsquo;t depend on whatever eon binary happened to be installed on the machine. I<br>also wanted to keep it inside go test, so unit and integration tests could run through the<br>same tooling.<br>eon is my CLI for scheduling jobs with LLMs. This command stores an hourly job named<br>backup and tells eon to run echo hi later:<br>eon add --cron '@hourly' --name backup -- echo hi

The --cron flag says when the job should run. --name gives it a stable name. Everything<br>after -- is the command eon saves for later. Then eon ls --json lists the saved jobs as<br>JSON.<br>The unit tests already covered the code behind those commands: parsing schedules, writing<br>jobs, reading them back. The CLI can still break while those tests pass. --cron can parse<br>correctly and then get dropped before the job is saved. JSON output can change. An error can<br>go to stdout instead of stderr. A config lookup can touch my real home directory during a<br>test. Parser and store tests don&rsquo;t catch those failures.<br>I wanted the integration tests to:<br>run eon add, eon ls --json, and a few invalid commands<br>keep eon&rsquo;s files under a temporary home directory<br>check stdout, stderr, exit codes, and saved state<br>stay inside go test<br>I didn&rsquo;t know about testscript yet, so I started by reading how the Go project tests the<br>go command itself. That led me to cmd/go&rsquo;s script tests<br>: src/cmd/go/testdata/script.<br>The directory is full of .txt fixtures for go test, go build, modules, workspaces,<br>vendoring, and other command-line behavior.<br>Those files are script fixtures. The Go command runs them with its own internal script<br>runner. The driver lives in script_test.go<br>, and these imports show the parts doing most of<br>the work:<br>// cmd/go/script_test.go<br>import (<br>"internal/txtar"

"cmd/internal/script"<br>"cmd/internal/script/scripttest"

In that file, the test function is named TestScript. For every fixture, it roughly does<br>this:<br>scans testdata/script/*.txt<br>creates a temporary directory for the case<br>exposes that directory to the script as $WORK<br>sets GOPATH to $WORK/gopath and moves into $WORK/gopath/src<br>parses the fixture as a txtar archive<br>extracts the embedded files into $WORK/gopath/src<br>runs the archive comment with Go&rsquo;s internal script engine<br>A shortened version of the driver looks like this. The comments and highlights are mine:<br>// cmd/go/script_test.go<br>func TestScript(t *testing.T) {<br>engine := &script.Engine{<br>Conds: scriptConditions(t),<br>Cmds: scriptCommands(quitSignal(), gracePeriod),<br>Quiet: !testing.Verbose(),

// Each .txt file in testdata/script becomes one subtest.<br>files, err := filepath.Glob("testdata/script/*.txt")<br>if err != nil {<br>t.Fatal(err)

for _, file := range files {<br>name := strings.TrimSuffix(filepath.Base(file), ".txt")<br>workdir, err := os.MkdirTemp(testTmpDir, name)<br>if err != nil {<br>t.Fatal(err)

// This is the per-script work directory.<br>s, err := script.NewState(tbContext(ctx, t), workdir, env)<br>if err != nil {<br>t.Fatal(err)

a, err := txtar.ParseFile(file)<br>if err != nil {<br>t.Fatal(err)<br>// initScriptDirs exposes workdir as $WORK, sets GOPATH to<br>// $WORK/gopath, and chdirs to $WORK/gopath/src.<br>telemetryDir := initScriptDirs(t, s)<br>// The -- filename -- sections are extracted into $WORK/gopath/src.<br>if err := s.ExtractFiles(a); err != nil {<br>t.Fatal(err)<br>// The archive comment is the script body.<br>scripttest.Run(t, engine, s, file, bytes.NewReader(a.Comment))<br>checkCounters(t, telemetryDir)

I covered txtar separately in A tour of txtar<br>, so I won&rsquo;t repeat the format here. For<br>these script tests, cmd/go uses the format this way:<br>the text before the first -- filename -- marker is the script body<br>the sections after those markers are files<br>those files get written under $WORK/gopath/src before the script runs<br>The README<br>in that directory documents the same format.<br>A real fixture from the Go tree, trimmed from test_regexps.txt<br>, looks like this:<br># cmd/go/testdata/script/test_regexps.txt<br>go test -cpu=1 -run=X/Y -bench=X/Y -count=2 -v testregexp

# TestX/Y is run, twice<br>stdout -count=2 '^=== RUN TestX/Y$'

# TestZ is not run<br>! stdout '^=== RUN TestZ$'

-- go.mod --<br>module testregexp

go 1.16<br>-- x_test.go --<br>package x<br>...<br>-- z_test.go --<br>package x<br>...<br>func TestZ(t *testing.T) {<br>t.Logf("LOG: Z running")

Note<br>Read that fixture as:<br>the command section runs go test and checks its output<br>stdout -count=2 requires the regex to match twice<br>! stdout is the negative assertion, so TestZ must not appear<br>go.mod, x_test.go, and z_test.go are written into $WORK/gopath/src<br>The go command works because the driver registers it with the script engine in<br>scriptcmds_test.go<br>. The fixture contains both the commands and the throwaway module.

Go&rsquo;s driver sits under internal packages, so normal projects can&rsquo;t import it. Roger Peppe<br>published...

script work gopath test rsquo tests

Related Articles