Files in test scripts
The txtar format is an ingenious way to supply arbitrary files and folder structures to test scripts.
In Part 1 of this series, we got
started with the testscript
package, and in Part 2 we learned how to test Go CLI tools
using a test script. Now let’s find out some more neat stuff.
The
txtar
format: constructing test data files
We’ve already seen in this series that we can create files in the script’s work directory, using this special syntax to indicate a named file entry:
-- golden.txt --
... file contents ...
The line beginning --
, called a file marker
line, tells testscript
that everything following this
line (until the next file marker) should be treated as the contents of
golden.txt
.
A file marker line must begin with two hyphens and a space, as in our example, and end with a space and two hyphens. The part in between these markers specifies the filename, which will be stripped of any surrounding whitespace.
In fact, we can create as many additional files as we want, simply by adding more file entries delimited by marker lines:
exec cat a.txt b.txt c.txt
-- a.txt --
...
-- b.txt --
...
-- c.txt --
...
Each marker line denotes the beginning of a new file, followed by zero or more lines of content, and ending at the next file marker line, if there is one. All these files will be created in the work directory before the script starts, so we can rely on them being present.
If we need to create folders, or even whole trees of files and folders, we can do that by using slash-separated paths in the file names:
-- misc/a.txt --
...
-- misc/subfolder/b.txt --
...
-- extra/c.txt --
...
When the script runs, the following tree of files will be created for it to use:
$WORK/
misc/
a.txt
subfolder/
b.txt
extra/
c.txt
This is a very neat way of constructing an arbitrary set of files and
folders for the test, rather than having to create them using Go code,
or copying them from somewhere in testdata
. Instead, we can
represent any number of text files as part of a single file,
the test script itself.
This representation, by the way, is called txtar
format,
short for “text archive”, and that’s also the file extension we use for
such files.
While this format is very useful in conjunction with
testscript
, a txtar
file doesn’t have to be a
test script. It can also be used independently, as a simple way of
combining multiple folders and text files into a single file.
To use the txtar
format in programs directly, import the
txtar
package. For example, if you want to write a tool that can read data
from such archives, or that allows users to supply input as
txtar
files, you can do that using the txtar
package.
Supplying input to
programs using stdin
Now that we know how to create arbitrary files in the script’s work directory, we have another fun trick available. We can use one of those files to supply input to a program, as though it were typed by an interactive user, or piped into the program using a shell.
Let’s see if we can use this idea to test a more sophisticated
version of our hello
program. This time, we’ll prompt the
user to enter their name, and we’ll use that name as part of the
greeting we print.
First, we’ll set up a delegate main
function that
returns an exit status value, as we did with the hello
program:
func Main() int {
.Println("Your name? ")
fmt:= bufio.NewScanner(os.Stdin)
scanner if !scanner.Scan() {
return 1
}
.Printf("Hello, %s!\n", scanner.Text())
fmtreturn 0
}
Just as with hello
, we’ll use the map passed to
testscript.RunMain
to associate our new custom program
greet
with the greet.Main
function:
func TestMain(m *testing.M) {
.Exit(testscript.RunMain(m, map[string]func() int{
os"greet": greet.Main,
}))
}
We’ll add the usual parent test that calls
testscript.Run
:
func TestGreet(t *testing.T) {
.Run(t, testscript.Params{
testscript: "testdata/script",
Dir})
}
And here’s a test script that runs the greet
program,
supplies it with fake “user” input via stdin
, and checks
its output:
stdin input.txt
exec greet
stdout 'Hello, John!'
-- input.txt --
John
First, the stdin
statement specifies that standard input
for the next program run by exec
will come from
input.txt
(defined at the end of the script file, using a
txtar
file entry).
Next, we exec
the greet
command, and verify
that its output matches Hello, John!
. Very simple, but a
powerful way to simulate any amount of user input for testing. Indeed,
we could simulate a whole “conversation” with the user.
Suppose the program asks the user for their favourite food as well as their name, then prints a customised dining invitation:
func Main() int {
.Println("Your name? ")
fmt:= bufio.NewScanner(os.Stdin)
scanner if !scanner.Scan() {
return 1
}
:= scanner.Text()
name .Println("Your favourite food? ")
fmtif !scanner.Scan() {
return 1
}
:= scanner.Text()
food .Printf("Hello, %s. Care to join me for some %s?\n", name,
fmt)
foodreturn 0
}
How can we test this with a script? Because the program scans user input a line at a time, we can construct our “fake input” file to contain the user’s name and favourite food on consecutive lines:
stdin input.txt
exec greet
stdout 'Hello, Kim. Care to join me for some barbecue?'
-- input.txt --
Kim
barbecue
We can go even further with stdin
. We’re not restricted
to supplying input from a file; we can also use the output of a
previous exec
. This could be useful when one program is
supposed to generate output which will be piped to another, for
example:
exec echo hello
stdin stdout
exec cat
stdout 'hello'
First, we execute echo hello
. Next, we say
stdin stdout
, meaning that the input for the next
exec
should be the output of the previous exec
(in this case, that input will be the string hello
).
Finally, we execute the cat
command, which copies its
input to its output, producing the final result of this “pipeline”:
hello
. You can chain programs together using
stdin stdout
as many times as necessary.
This isn’t quite like a shell pipeline, though, because there’s no
concurrency involved: stdin
reads its entire input before
continuing. This is fine for most scripts, but just be aware that the
script won’t proceed until the previous exec
has finished
and closed its output stream.
File operations
Just as in a traditional shell script, we can copy one file to
another using cp
:
cp a.txt b.txt
However, the first argument to cp
can also be
stdout
or stderr
, indicating that we want to
copy the output of a previous exec
to some file:
exec echo hello
cp stdout tmp.txt
We can also use mv
to move a file (that is,
rename it) instead of copying:
mv a.txt b.txt
We can also create a directory using mkdir
, and then
copy multiple files into it with cp
:
mkdir data
cp a.txt b.txt c.txt data
The cd
statement will change the current directory for
subsequent programs run by exec
:
cd data
To delete a file or directory, use the rm
statement:
rm data
When used with a directory, rm
acts recursively, like
the shell’s rm -rf
: it deletes all contained files and
subdirectories before deleting the directory itself.
To create a symbolic link from one file or directory to another, we
can use the symlink
statement, like this:
mkdir target
symlink source -> target
Note that the ->
is required, and it indicates the
“direction” of the symlink. In this example, the link
source
will be created, pointing to the existing directory
target
.
Differences from shell scripts
While test scripts look a lot like shell scripts, they don’t have any kind of control flow statements, such as loops or functions. Failing assertions will bail out of the script early, but that’s it. On the plus side, this makes test scripts pretty easy to read and understand.
There is a limited form of conditional statement, as we’ll see later in this series, but let’s first look at a few other ways in which test scripts differ from shell scripts.
For one thing, they don’t actually use a shell: commands run
by exec
are invoked directly, without being parsed by a
shell first. So some of the familiar facilities of shell command lines,
such as globbing (using wildcards such as *
to
represent multiple filenames) are not available to us directly.
That’s not necessarily a problem, though. If we need to expand a glob expression, we can simply ask the shell to do it for us:
# List all files whose names begin with '.'
exec sh -c 'ls .*'
Similarly, we can’t use the pipe character (|
) to send
the output of one command to another, as in a shell pipeline. Actually,
we already know how to chain programs together in a test script using
stdin stdout
. But, again, if we’d rather invoke the shell
to do this, we can:
# count the number of lines printed by 'echo hello'
exec sh -c 'echo hello | wc -l'
Comments and phases
A #
character in a test script begins a comment, as we
saw in the examples in the previous section. Everything else until the
end of the line is considered part of the comment, and ignored. That
means we can add comments after commands, too:
exec echo hello # this comment will not appear in output
Actually, comments in scripts aren’t entirely ignored. They also delimit distinct sections, or phases, in the script.
For example, we can use a comment to tell the reader what’s happening at each stage of a script that does several things:
# run an existing command: this will succeed
exec echo hello
# try to run a command that doesn't exist
exec bogus
This is informative, but that’s not all. If the script fails at any
point, testscript
will print the detailed log output, as
we’ve seen in previous examples. However, only the log of the
current phase is printed; previous phases are suppressed. Only
their comments appear, to show that they succeeded.
Here’s the result of running the two-phase script shown in the previous example:
# run an existing command: this will succeed (0.003s)
# try to run a command that doesn't exist (0.000s)
> exec bogus
[exec: "bogus": executable file not found in $PATH]
Notice that the exec
statement from the first phase
(running echo hello
) isn’t shown here. Instead, we see only
its associated comment, which testscript
treats as a
description of that phase, followed by the time it took to run:
# run an existing command: this will succeed (0.003s)
Separating the script into phases using comments like this can be very helpful for keeping the failure output concise, and of course it makes the script more readable too.
That’s it for Part 3; in Part 4 we’ll get to grips with conditions in scripts, supplying environment variables to programs, and running programs in the background. See you there!
Previous: Testing CLI tools in Go