{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/go/testscript-cli/",
"description": "How cmd/go's script tests led me to testscript, and how to use it for CLI tests that exercise argv, stdout, stderr, exit codes, and scratch files.",
"path": "/go/testscript-cli/",
"publishedAt": "2026-05-18T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Testing",
"CLI",
"Tooling"
],
"textContent": "While wrapping up [eon], I wanted to test the binary the same way a user would use it. The\ntest couldn't depend on whatever eon binary happened to be installed on the machine. I\nalso wanted to keep it inside go test, so unit and integration tests could run through the\nsame tooling.\n\neon is my CLI for scheduling jobs with LLMs. This command stores an hourly job named\nbackup and tells eon to run echo hi later:\n\nThe --cron flag says when the job should run. --name gives it a stable name. Everything\nafter -- is the command eon saves for later. Then eon ls --json lists the saved jobs as\nJSON.\n\nThe unit tests already covered the code behind those commands: parsing schedules, writing\njobs, reading them back. The CLI can still break while those tests pass. --cron can parse\ncorrectly and then get dropped before the job is saved. JSON output can change. An error can\ngo to stdout instead of stderr. A config lookup can touch my real home directory during a\ntest. Parser and store tests don't catch those failures.\n\nI wanted the integration tests to:\n\n- run eon add, eon ls --json, and a few invalid commands\n- keep eon's files under a temporary home directory\n- check stdout, stderr, exit codes, and saved state\n- stay inside go test\n\nI didn't know about testscript yet, so I started by reading how the Go project tests the\ngo command itself. That led me to [cmd/go's script tests]: src/cmd/go/testdata/script.\nThe directory is full of .txt fixtures for go test, go build, modules, workspaces,\nvendoring, and other command-line behavior.\n\nThose files are script fixtures. The Go command runs them with its own internal script\nrunner. The driver lives in [script_test.go], and these imports show the parts doing most of\nthe work:\n\nIn that file, the test function is named TestScript. For every fixture, it roughly does\nthis:\n\n- scans testdata/script/.txt\n- creates a temporary directory for the case\n- exposes that directory to the script as $WORK\n- sets GOPATH to $WORK/gopath and moves into $WORK/gopath/src\n- parses the fixture as a txtar archive\n- extracts the embedded files into $WORK/gopath/src\n- runs the archive comment with Go's internal script engine\n\nA shortened version of the driver looks like this. The comments and highlights are mine:\n\nI covered txtar separately in [A tour of txtar], so I won't repeat the format here. For\nthese script tests, cmd/go uses the format this way:\n\n- the text before the first -- filename -- marker is the script body\n- the sections after those markers are files\n- those files get written under $WORK/gopath/src before the script runs\n\nThe [README] in that directory documents the same format.\n\nA real fixture from the Go tree, trimmed from [test_regexps.txt], looks like this:\n\n> [!NOTE]\n>\n> Read that fixture as:\n>\n> - the command section runs go test and checks its output\n> - stdout -count=2 requires the regex to match twice\n> - ! stdout is the negative assertion, so TestZ must not appear\n> - go.mod, x_test.go, and z_test.go are written into $WORK/gopath/src\n>\n> The go command works because the driver registers it with the script engine in\n> [scriptcmds_test.go]. The fixture contains both the commands and the throwaway module.\n\nGo's driver sits under internal packages, so normal projects can't import it. Roger Peppe\npublished the extracted public package in [go-internal]. The README traces testscript back\nto Go's internal script package, and the package you import is [testscript]. That's the\npackage I used for eon.\n\nInstall it like any other test dependency:\n\nThen point testscript at a directory of scripts:\n\n> [!TIP]\n>\n> The usual setup is:\n>\n> - put scripts under testdata/script\n> - each .txt or .txtar file becomes a subtest\n> - each subtest gets an isolated directory at $WORK\n> - use exec to run a command\n> - use stdout and stderr to assert regexes against the last command\n> - use cmp, env, and exists when the filesystem or environment is part of the case\n>\n> The [testscript] docs cover the full syntax. The language isn't /bin/sh, so run sh -c\n> explicitly when you need shell behavior.\n\nTesting a tiny CLI\n\nHere's a tiny CLI called hello. It prints hello, world when you invoke it as hello,\nand -shout uppercases the output:\n\nThe test file registers hello as a command that scripts can execute. The highlighted lines\nare the testscript wiring:\n\nNow add testdata/script/greet.txt:\n\nRun it with:\n\nI also put the example in a [playground version]. The playground runs the test binary in a\nsandbox, so that version writes the script into t.TempDir before calling\ntestscript.RunT. In a normal project, keep the script under testdata/script.\n\nTo run only this script while iterating:\n\n> [!IMPORTANT]\n>\n> exec hello doesn't use a system-wide hello binary.\n>\n> - testscript.Main puts its temp bin directory first in PATH\n> - during go test, it copies the current test binary there as hello\n> - exec hello -shout redowan starts that copied binary as a subprocess\n> - the child process re-enters testscript.Main\n> - testscript.Main dispatches by the basename of os.Args[0] and calls the registered\n> \"hello\" function\n>\n> So the test gets real argv, stdout, stderr, and exit status behavior without installing\n> the CLI.\n\nFor longer output, put the expected text in the same script and compare against it:\n\nThe want section is written into $WORK before the script starts. After exec,\ncmp stdout want compares the previous command's stdout with that file and prints a diff on\nfailure.\n\nUsing testscript in eon\n\nThe eon setup lives in [eon's script_test.go]. The [TestMain] block registers the real CLI\nentrypoint as eon. It also registers a small timeout helper for the log-following daemon\nscript:\n\n[runEonMain] builds the root command and runs it through the same Fang execution path as the\nproduction binary:\n\nThe [TestScripts setup] points eon's data directories at the script's $WORK directory:\n\n> [!NOTE]\n>\n> eon stores jobs in SQLite under the platform data directory. During tests, I point all of\n> those paths at $WORK:\n>\n> - HOME under $WORK\n> - XDG_DATA_HOME under $WORK/xdg\n> - XDG_CONFIG_HOME under $WORK/xdg-config\n> - color disabled for stable stdout and stderr assertions\n>\n> The scripts can add jobs, list them, and read logs without touching my real scheduler\n> state.\n\nOne eon script, [add_basic.txt], covers the add/list/show path:\n\nWith that in place, go test ./... covers the CLI behavior I care about:\n\n- parser tests exercise schedule parsing directly\n- store tests hit SQLite APIs directly\n- testscript tests cover flags, output, exit codes, and state written under an isolated home\n directory\n\nThe tests don't install eon or pick up a stale command from PATH. They still run the\ncommand as a subprocess, so argv, stdout, stderr, and exit codes go through the same code\npath a user hits in a terminal.\n\n\n\n\n[eon]:\n https://github.com/rednafi/eon\n\n[A tour of txtar]:\n /go/txtar/\n\n[eon's script_test.go]:\n https://github.com/rednafi/eon/blob/857935f9fe411dce7a5b306d5b898397fdac87e5/cmd/eon/script_test.go#L18-L92\n\n[TestMain]:\n https://github.com/rednafi/eon/blob/857935f9fe411dce7a5b306d5b898397fdac87e5/cmd/eon/script_test.go#L18-L25\n\n[runEonMain]:\n https://github.com/rednafi/eon/blob/857935f9fe411dce7a5b306d5b898397fdac87e5/cmd/eon/script_test.go#L27-L38\n\n[TestScripts setup]:\n https://github.com/rednafi/eon/blob/857935f9fe411dce7a5b306d5b898397fdac87e5/cmd/eon/script_test.go#L76-L92\n\n[add_basic.txt]:\n https://github.com/rednafi/eon/blob/857935f9fe411dce7a5b306d5b898397fdac87e5/cmd/eon/testdata/script/add_basic.txt#L3-L25\n\n[cmd/go's script tests]:\n https://go.dev/src/cmd/go/testdata/script/\n\n[script_test.go]:\n https://go.dev/src/cmd/go/script_test.go\n\n[scriptcmds_test.go]:\n https://go.dev/src/cmd/go/scriptcmds_test.go#L20-L43\n\n[README]:\n https://go.dev/src/cmd/go/testdata/script/README\n\n[test_regexps.txt]:\n https://go.dev/src/cmd/go/testdata/script/test_regexps.txt\n\n[go-internal]:\n https://github.com/rogpeppe/go-internal\n\n[testscript]:\n https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript\n\n[playground version]:\n https://go.dev/play/p/eW2KHO8Ir-_1",
"title": "Testing Go CLIs with testscript"
}