Testing Go CLIs with testscript
Table of contents
While wrapping up eon
, I wanted to test the binary the same way a user would use it. The
test couldn’t depend on whatever eon binary happened to be installed on the machine. I
also wanted to keep it inside go test, so unit and integration tests could run through the
same tooling.
eon is my CLI for scheduling jobs with LLMs. This command stores an hourly job named
backup and tells eon to run echo hi later:
eon add --cron '@hourly' --name backup -- echo hi
The --cron flag says when the job should run. --name gives it a stable name. Everything
after -- is the command eon saves for later. Then eon ls --json lists the saved jobs as
JSON.
The unit tests already covered the code behind those commands: parsing schedules, writing
jobs, reading them back. The CLI can still break while those tests pass. --cron can parse
correctly and then get dropped before the job is saved. JSON output can change. An error can
go to stdout instead of stderr. A config lookup can touch my real home directory during a
test. Parser and store tests don’t catch those failures.
I wanted the integration tests to:
- run
eon add,eon ls --json, and a few invalid commands - keep eon’s files under a temporary home directory
- check stdout, stderr, exit codes, and saved state
- stay inside
go test
I didn’t know about testscript yet, so I started by reading how the Go project tests the
go command itself. That led me to cmd/go’s script tests
: src/cmd/go/testdata/script.
The directory is full of .txt fixtures for go test, go build, modules, workspaces,
vendoring, and other command-line behavior.
Those files are script fixtures. The Go command runs them with its own internal script runner. The driver lives in script_test.go , and these imports show the parts doing most of the work:
// cmd/go/script_test.go
import (
"internal/txtar"
"cmd/internal/script"
"cmd/internal/script/scripttest"
)
In that file, the test function is named TestScript. For every fixture, it roughly does
this:
- scans
testdata/script/*.txt - creates a temporary directory for the case
- exposes that directory to the script as
$WORK - sets
GOPATHto$WORK/gopathand moves into$WORK/gopath/src - parses the fixture as a txtar archive
- extracts the embedded files into
$WORK/gopath/src - runs the archive comment with Go’s internal script engine
A shortened version of the driver looks like this. The comments and highlights are mine:
// cmd/go/script_test.go
func TestScript(t *testing.T) {
engine := &script.Engine{
Conds: scriptConditions(t),
Cmds: scriptCommands(quitSignal(), gracePeriod),
Quiet: !testing.Verbose(),
}
// Each .txt file in testdata/script becomes one subtest.
files, err := filepath.Glob("testdata/script/*.txt")
if err != nil {
t.Fatal(err)
}
for _, file := range files {
name := strings.TrimSuffix(filepath.Base(file), ".txt")
workdir, err := os.MkdirTemp(testTmpDir, name)
if err != nil {
t.Fatal(err)
}
// This is the per-script work directory.
s, err := script.NewState(tbContext(ctx, t), workdir, env)
if err != nil {
t.Fatal(err)
}
a, err := txtar.ParseFile(file)
if err != nil {
t.Fatal(err)
}
// initScriptDirs exposes workdir as $WORK, sets GOPATH to
// $WORK/gopath, and chdirs to $WORK/gopath/src.
telemetryDir := initScriptDirs(t, s)
// The -- filename -- sections are extracted into $WORK/gopath/src.
if err := s.ExtractFiles(a); err != nil {
t.Fatal(err)
}
// The archive comment is the script body.
scripttest.Run(t, engine, s, file, bytes.NewReader(a.Comment))
checkCounters(t, telemetryDir)
}
}
I covered txtar separately in A tour of txtar , so I won’t repeat the format here. For these script tests, cmd/go uses the format this way:
- the text before the first
-- filename --marker is the script body - the sections after those markers are files
- those files get written under
$WORK/gopath/srcbefore the script runs
The README in that directory documents the same format.
A real fixture from the Go tree, trimmed from test_regexps.txt , looks like this:
# cmd/go/testdata/script/test_regexps.txt
go test -cpu=1 -run=X/Y -bench=X/Y -count=2 -v testregexp
# TestX/Y is run, twice
stdout -count=2 '^=== RUN TestX/Y$'
# TestZ is not run
! stdout '^=== RUN TestZ$'
-- go.mod --
module testregexp
go 1.16
-- x_test.go --
package x
...
-- z_test.go --
package x
...
func TestZ(t *testing.T) {
t.Logf("LOG: Z running")
}
Note
Read that fixture as:
- the command section runs
go testand checks its output stdout -count=2requires the regex to match twice! stdoutis the negative assertion, soTestZmust not appeargo.mod,x_test.go, andz_test.goare written into$WORK/gopath/src
The go command works because the driver registers it with the script engine in
scriptcmds_test.go
. The fixture contains both the commands and the throwaway module.
Go’s driver sits under internal packages, so normal projects can’t import it. Roger Peppe published the extracted public package in go-internal . The README traces testscript back to Go’s internal script package, and the package you import is testscript . That’s the package I used for eon.
Install it like any other test dependency:
go get github.com/rogpeppe/go-internal/testscript
Then point testscript at a directory of scripts:
// main_test.go
func TestScripts(t *testing.T) {
testscript.Run(t, testscript.Params{
Dir: "testdata/script",
})
}
Tip
The usual setup is:
- put scripts under
testdata/script - each
.txtor.txtarfile becomes a subtest - each subtest gets an isolated directory at
$WORK - use
execto run a command - use
stdoutandstderrto assert regexes against the last command - use
cmp,env, andexistswhen the filesystem or environment is part of the case
The testscript
docs cover the full syntax. The language isn’t /bin/sh, so run sh -c
explicitly when you need shell behavior.
Testing a tiny CLI #
Here’s a tiny CLI called hello. It prints hello, world when you invoke it as hello,
and -shout uppercases the output:
// main.go
package main
import (
"flag"
"fmt"
"io"
"os"
"strings"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("hello", flag.ContinueOnError)
fs.SetOutput(stderr)
shout := fs.Bool("shout", false, "uppercase output")
if err := fs.Parse(args); err != nil {
return 2
}
name := "world"
if fs.NArg() > 0 {
name = fs.Arg(0)
}
msg := "hello, " + name
if *shout {
msg = strings.ToUpper(msg)
}
fmt.Fprintln(stdout, msg)
return 0
}
The test file registers hello as a command that scripts can execute. The highlighted lines
are the testscript wiring:
// main_test.go
package main
import (
"os"
"testing"
"github.com/rogpeppe/go-internal/testscript"
)
func TestMain(m *testing.M) {
testscript.Main(m, map[string]func(){
"hello": func() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
},
})
}
func TestScripts(t *testing.T) {
testscript.Run(t, testscript.Params{
Dir: "testdata/script",
RequireExplicitExec: true,
})
}
Now add testdata/script/greet.txt:
# testdata/script/greet.txt
# Default greeting.
exec hello
stdout '^hello, world$'
! stderr .
# Positional argument plus a flag.
exec hello -shout redowan
stdout '^HELLO, REDOWAN$'
# Bad flags should fail and print the flag package error.
! exec hello -bogus
stderr 'flag provided but not defined: -bogus'
Run it with:
go test ./...
I also put the example in a playground version
. The playground runs the test binary in a
sandbox, so that version writes the script into t.TempDir before calling
testscript.RunT. In a normal project, keep the script under testdata/script.
To run only this script while iterating:
go test -run 'TestScripts/^greet$' -v
Important
exec hello doesn’t use a system-wide hello binary.
testscript.Mainputs its tempbindirectory first inPATH- during
go test, it copies the current test binary there ashello exec hello -shout redowanstarts that copied binary as a subprocess- the child process re-enters
testscript.Main testscript.Maindispatches by the basename ofos.Args[0]and calls the registered"hello"function
So the test gets real argv, stdout, stderr, and exit status behavior without installing the CLI.
For longer output, put the expected text in the same script and compare against it:
# testdata/script/shout.txt
exec hello -shout gopher
cmp stdout want
-- want --
HELLO, GOPHER
The want section is written into $WORK before the script starts. After exec,
cmp stdout want compares the previous command’s stdout with that file and prints a diff on
failure.
Using testscript in eon #
The eon setup lives in eon’s script_test.go
. The TestMain
block registers the real CLI
entrypoint as eon. It also registers a small timeout helper for the log-following daemon
script:
// cmd/eon/script_test.go
func TestMain(m *testing.M) {
testscript.Main(m, map[string]func(){
"eon": func() { os.Exit(runEonMain()) },
"timeout": func() { os.Exit(runTimeoutMain()) },
})
}
runEonMain builds the root command and runs it through the same Fang execution path as the production binary:
// cmd/eon/script_test.go
func runEonMain() int {
ctx, cancel := signal.NotifyContext(
context.Background(),
syscall.SIGINT,
syscall.SIGTERM,
)
defer cancel()
root := newRoot()
if err := fang.Execute(ctx, root, fangOptions()...); err != nil {
return exitCode(err)
}
return 0
}
The TestScripts setup
points eon’s data directories at the script’s $WORK directory:
// cmd/eon/script_test.go
func TestScripts(t *testing.T) {
testscript.Run(t, testscript.Params{
Dir: "testdata/script",
Setup: func(env *testscript.Env) error {
env.Setenv("HOME", env.WorkDir)
env.Setenv("XDG_DATA_HOME", env.WorkDir+"/xdg")
env.Setenv("XDG_CONFIG_HOME", env.WorkDir+"/xdg-config")
env.Setenv("CLICOLOR", "0")
env.Setenv("NO_COLOR", "1")
return nil
},
})
}
Note
eon stores jobs in SQLite under the platform data directory. During tests, I point all of
those paths at $WORK:
HOMEunder$WORKXDG_DATA_HOMEunder$WORK/xdgXDG_CONFIG_HOMEunder$WORK/xdg-config- color disabled for stable stdout and stderr assertions
The scripts can add jobs, list them, and read logs without touching my real scheduler state.
One eon script, add_basic.txt , covers the add/list/show path:
# cmd/eon/testdata/script/add_basic.txt
exec eon add --cron '@hourly' --name backup -- echo hi
stdout 'added job [0-9A-Za-z]+ \(cron, @hourly\)'
exec eon add --at '+1h' --name morning -- echo wake
stdout 'added job [0-9A-Za-z]+ \(oneshot, at .*\)'
# Name defaults to the command when --name is omitted.
exec eon add --cron '@daily' -- /bin/echo from-cmd
stdout 'added job [0-9A-Za-z]+'
# Three jobs created — verify by name (IDs are random 5-char strings).
exec eon ls --json
stdout '"name": "backup"'
stdout '"name": "morning"'
stdout '"cron": "@hourly"'
# Show resolves by name.
exec eon show backup --json
stdout '"kind": "cron"'
exec eon show morning --json
stdout '"kind": "oneshot"'
stdout '"fire_at"'
With that in place, go test ./... covers the CLI behavior I care about:
- parser tests exercise schedule parsing directly
- store tests hit SQLite APIs directly
- testscript tests cover flags, output, exit codes, and state written under an isolated home directory
The tests don’t install eon or pick up a stale command from PATH. They still run the
command as a subprocess, so argv, stdout, stderr, and exit codes go through the same code
path a user hits in a terminal.