TLDR
- clone this repo and run
prove -v t/t3st-hello.t :: --help
(...helo.t
is a lightweight demo that also prints further instructions) - read the self-tests in
t3st.t
(which uses thet3st-ttt0.sh
framework) - run
/src/t3st/git-t3st-setup
inside an existing git project and look int/
t3st
is a shell library that produces TAP testing output. It can test any shell function, or commands or scripts. It requires only a POSIX shell (but works under bash / zsh as well as several sh
variants — busybox / mksh / BSD sh / others). A TAP framework (prove
comes with any system perl) is also assumed. The defaults make tests easy to write without sacrificing correctness or flexibility. The test API completely avoids namespace pollution, which guarantees complete interoperability with any source. Features:
- easy setup: adding to existing projects (
git-t3st-setup
), and running multiple tests under multiple shells (git t3st-prove
/make -C t/
) are one-liners. In addition to the basic library, there is an "opinionated" framework reducing boilerplate / friction to a minimum. - output + exit code testing, with precise final newline handling
- full shell integration: tests are real shell scripts with no special formatting or syntax magic; you can request tests conditionally / from loops / inside subshells and pipes / from wrapper functions
- usable for POSIX
sh
code, as well as bash / zsh errexit
(set -e
) andset -u
tests- repeated tests (for stress-testing / benchmarking)
- TAP directives (
TODO
)
Notes
- development takes place at GitLab
t3st
but you can also report issues at the GitHubt3st
mirror. - watch the changelog if using the
git
version — there are no API guarantees at this stage
- Installing
- Usage
- Test function
- Extras
- File-based (
_me
) tests - Using
prove
- Supported shells
- Copyright & license
Add t3st
to a project directly:
cd myproject
URL=https://gitlab.com/kstr0k/t3st/-/raw/master/git-t3st-setup
curl -s "$URL" | sh # or ... | sh -s -- --tdir=./mytests
Use prove
, or the git aliases and Makefile targets, to run / add tests :
prove -v t/t3st-hello.t :: --help
git t3st-new # or make -C t/ new
git t3st-prove [-v] # from anywhere in repo, multiple shells
git t3st-setup # update / repair
git config t3st.prove-shells 'sh,bash#,etc' # saved in repo's .git/config
The script adds to repo/.git/config
file (displayed at startup), but won't overwrite unrelated (or subsequently modified) settings. Specifically, it
- sets up a no-tags, no-push
kstr0k-t3st
git remote - creates a test directory (
--tdir=t/
by default) and copies the library to it directly fromgit
. It also adds a simplet3st-hello.t
example with instructions, and a more complete framework (t/t3st-lib/t3st-ttt0.sh
) for heavier development. - adds
git
aliases (which conveniently work from anywhere in the repo) to- run the tests in multiple shells:
git t3st-prove
(controlled bygit config t3st.prove-shells
) - create a new test:
git t3st-new
- re-run itself:
git t3st-setup
- run the tests in multiple shells:
- creates a
t3st.mk
makefile (symlinked toMakefile
) witht3st-prove
(default) andnew
targets. Use withmake -C t/
- accepts special setup parameters:
--reset
(removes allt3st
-relatedgit
settings as a first step);--no-setup
: don't re-addt3st
settings (combine with--reset
)
For manual installation, clone this repo and run git-t3st-setup [--help]
from another project. Or just copy k9s0ke_t3st_lib.sh
in a testsuite.
Once you have a t/
folder with "*.t
" tests, run them using prove
(or any TAP test harness):
prove -v # or change the shell:
prove -e 'busybox sh'
prove -vr tests # rename t/, recursive
ttt0
is a framework that builds on t3st
, organizes boilerplate / tricky code into overridable methods, and provides sensible defaults. It can be helpful, but is not required (see Hand-rolled).
To get started, run git t3st-new
(or make -C t/ new
) after importing t3st
into your project (git-t3st-setup
). Rename and edit the generated new.t
and new-e.t
(or delete the latter). new.t
sources t/t3st-lib/t3st-ttt0.sh
and defines a set of tests in TTT__tfile_tests
; if needed, extend (see below) or replace methods inside TTT__tfile_entry
. See the test function, now aliased to TTT
, TTT_de
/ TTT_ee
and TTT_xe
(the _?e
ones for specific errexit
disable / enable modes; _xe
calls TTT_de
, then TTT_ee
— it's not a single test).
The *.t
methods (TTT__tfile_*
, — a file-wide namespace prefix) receive whatever arguments the test harness supplies (e.g. prove .. :: ARG..
). You can override them; once ...METHOD
is overridden, you can still access the initial default with the corresponding ..._METHOD_0
(i.e. internally, ...METHOD
simply calls ...METHOD_0
). After _early
, they can all use ..._my{path|name|dirn}
path-related globals. The methods are (users are normally concerned with the first three, and possibly ..._parse_args
):
..._tests
(innew.t
): add tests here..._setup
(int3st-ttt0
): by default, sourcesk9s0ke_t3st_lib.sh
and definesTTT*
. Extend this to source other libraries...._thelp
(int3st-ttt0
): by default, prints some debug info...._entry
(innew.t
): called by*.t
itself if run as a script, or by any script using*.t
as a library. Sets up..._mypath
based on the absolute path to*.t
and sources thettt0
framework (getting this right is rather tricky — some naive attempts to use$0
fail under different scenarios / shells)..._early
(int3st-ttt0
): called right after sourcingt3st-ttt0
with an additional$1
argument bound to the original script's$0
. Sets up some globals ($...myname
= basename of*.t
,$..._mydirn
). If you want to override it, do so afterttt0
is sourced, for obvious reasons...._runme
(int3st-ttt0
): sequences the other functions. Checks for--help
, calls..._setup
,k9s0ke_t3st_enter
's, then calls..._tests
, and finallyk9s0ke_t3st_leave
's. Internally, it uses …..._parse_args
: processes command-line switches (optional parameters) one at a time and calls itself recursively. When switches are exhausted (or a--
is encountered), calls..._parse_args_end
with the remaining positional parameters. Override / extend..._parse_args
to define new switches.
A sample.t
file (using defaults aggressively, and peculiar formatting to highlight tested code) might look like
#!/bin/sh
# real shell script -- no syntax / formatting magic
. "$(dirname -- "$0")/k9s0ke_t3st_lib.sh # or wherever
TTT() { k9s0ke_t3st_one "$@"; } # or whatever
k9s0ke_t3st_enter
TTT spec='as bare as it gets' \
-- echo
TTT nl=false rc='-ne 0' spec='standard command "false" -> non-0 exit status' \
-- false
TTT out=// hook_test_pre='cd /' spec='use eval for multiple commands' \
-- eval 'printf $PWD; pwd'
if (type __str_subst >/dev/null 2>&1); then
TTT out=abcbcbc nl=false \
-- __str_subst abbb b bc
else k9s0ke_t3st_skip 1 \
'no str_subst (http://gitlab.com/kstr0k/mru-files.kak/-/tree/master/k9s0ke-shlib)'
fi
k9s0ke_t3st_leave
That is: source k9s0ke_t3st_lib.sh
(along with any tested code you need to reference) and call
k9s0ke_t3st_enter [test_plan]
to start TAP output. Omit the plan to have the library count tests automatically and print the plan at the end.k9s0ke_t3st_one [param=value]... [-- cmd args...]
(see test function for defaults) for each test; it executes everything after "--
" (a single command or function call — useeval '...'
otherwise) in a subshell and checks the output and exit status. Usually aliased toTTT
.k9s0ke_t3st_me
— alternative file-based tests (see_me
tests)k9s0ke_t3st_leave [test_plan]
: ends TAP output. If no plan was given to..._enter
, it prints the supplied test plan, or generates one that matches the total number of test calls ("1..k9s0ke_t3st_one
-call-count"). The simplest setup is to not pass a test plan to either_enter
orleave
.
Don't "set -e" globally (i.e. outside a _one
or _me
call): the library code will refuse to run (it can't properly record exit statuses with set -e
). Instead, either
- define a shortcut function (
TTT_ee() { k9s0ke_t3st_one errexit=true $@; }
), OR - use
errexit=true
in individual..._one
/..._me
calls, OR - set a global errexit default (
k9s0ke_t3st_g_errexit=true
) in the.t
file or in the environment (k9s0ke_t3st_g_errexit=true prove...
), OR set -e
inside the tested code
To run tests with both set -e
and set +e
, create a ...-e.t
file which adds a global errexit
default, then sources the base .t
file. The -e.t
file can also define additional tests. t/t3st-e.t implements this; so do the new.t
/ new-e.t
generated files (see t3st-ttt0);
set -u
does not affect operation — set it either way globally and/or use set_pre=[-/+]u
parameters (or the $k9s0ke_t3st_g_set_pre
global default).
Call k9s0ke_t3st_one
once per test in *.t
(you may want to alias it, e.g. to TTT
, possibly with some pre-set parameters). Minimal, though contrived, succeeding tests are the first tests in t/t3st.t
TTT() { k9s0ke_t3st_one "$@"; }
TTT -- echo # expect out=('' + default \n)
TTT in= # stdin = '' + \n; implied command = cat; expect '' + \n
TTT nl=false # stdin = /dev/null; expect out=''
These illustrate the defaults: call cat
if no command is supplied, expect exit status rc=0
, expect output out=''
, add a final newline to the expected output (nl=true
), stdin from /dev/null
. The available arguments (before "--
"; all optional, in any order; some defaults can be overridden by setting a corresponding k9s0ke_t3st_g_...
global) are:
nl={ true | false }
: adds a newline to the expectedout=
, as well as anyin=
parameters described below (but not toinfile= / outfile=
). This is the default (most commands work with full lines); override withnl=false
.out='expected...'
(default: empty) compare the command's output (including any final newline) to the specified string (plus a newline ifnl=true
). For more complex conditions, usepp=
(extras).outfile='...'
: loadout=
from a file (won't clobber host files, despite the name);nl=
does not apply.infile={ 'path' | [-] }
: redirect the command's input, or leave stdin alone (use caller's environment); without anyinfile=
(orin=
), all tests run with input from/dev/null
.nl=
has no influence.in='...'
: input to pass onstdin
to the command; an additional newline is added with (the default)nl=true
(even for an emptyin
). For a completely empty input, don't specify eitherin=
orinfile=
; or usein= nl=false
. For a single newline, usein=
orin="$k9s0ke_t3st_nl" nl=false
. As noted above,nl=
also affects expected output.rc={ $rc | '-$cmp $rc'}
: compare the command's exit status ($?
) to the supplied value / uses the value as a shelltest
condition (e.g.rc='-lt 2'
checks that$?
is 0 or 1). If omitted, the expected exit is 0; if set to''
, the exit status is ignored.spec='...'
: print this after theok / not ok
result (in particular, "# TODO
" directives mark sub-tests as possibly failing, without causing the entire test file to fail).prove
displays test names using this bit of output. Defaults to the first word of the command. You can also usetodo='comment..'
(syntactic sugar) andspecfmt=
below.pp='shell code...'
: post-process the output ($1
) and exit status ($2
). This code runs within a temporary function; whatever it outputs replaces the original output, and its exit status (from its last statement, or an explicitreturn
) replaces the original$?
. Therc=
andout=
parameters then match against these post-processed values. Enables arbitrarily complex tests.errexit={ true | false }
: run the test underset -e
conditions. Defaults to false or the global*_g_*
override. Do not 'set -e' globally in*.t
.set_pre={ -? | +? }*
(e.g.set_pre=-f
turns off globbing). Defaults to nothing or the global*_g_*
override.repeat=N
: repeat this testN
times, or until it first fails. Defaults to 1, or$k9s0ke_t3st_g_repeat
.cnt={ true | false }
: the test counter$k9s0ke_t3st_cnt
normally auto-increments; this can be disabled for pipes and subshells — see below
Note that shell variables (including in=
, out=
, and the internal variable that stores actual output) cannot hold NUL
(\0
) characters. The infile=
, however, as well as pipes / redirects, can. Preprocess any NUL
s' before they reach the library (e.g. eval '... | tr \\0 \\n'
; pp=
won't help).
infile=
can be used with any local files (permanent or created on the fly, e.g. in $k9s0ke_t3st_tmp_dir
). Here-doc (<<'EOF'
/ <<EOF
with expansions) redirects work with infile=-
, but can only create newline-terminated inputs.
You can specify redirects (or anything that changes the environment) in the pre-test hook, which runs in the same subshell as the tested command:
TTT hook_test_pre='cd /tmp || exit; exec 2>&1' ...
The standard error log of each test is normally pasted as TAP '#
' comments below the test (prove -v
displays them); exec 2>/dev/null
in the hook gets rid of it.
k9s0ke_t3st_one
can run in a pipeline, but it might execute in a different (forked) process than the main script. Pass infile=-
(avoids the default </dev/null
), cnt=false
(in case the test part of the pipeline runs in the script process after all), and increment the counter manually after each such test:
echo 'XX YY' | k9s0ke_t3st_one out=XX infile=- cnt=false \
-- eval 'read -r x rest; echo "$x"'
k9s0ke_t3st_cnt=$(( k9s0ke_t3st_cnt + 1 ))
This is also necessary if you call k9s0ke_t3st_one
from a subshell. If the forked process might run an undetermined number of tests, use
k9s0ke_t3st_cnt_save
at the end of a subshell / pipek9s0ke_t3st_cnt_load
back in the top-level shell
k9s0ke_t3st_bailout [message]
: stop testing, output a TAP bailout marker, exit. Undefined behavior if you call this from a subshell / pipe.k9s0ke_t3st_skip skip_count reason
: mark a few tests as skipped. This keeps the total number of tests constant with conditional tests. By default the plan (including the final test counter) is printed at the end, so this is not required (but helps with debugging).k9s0ke_t3st_g_on_fail={bailout | skip-rest | ignore-rest }
(experimental): bailout or skip / ignore all tests after first (non-TODO) failurek9s0ke_t3st_g_specfmt
(- $1
by default): a format string applied to both the implicit and explicitspec
; set it to- $*
(and possibly include other variables) to make implicit test names show all..._one
arguments instead of just$1
. Double quotes must be escaped withinspecfmt
. Also available as aspecfmt=
parameter in..._one
...._one hook_test_pre=...
: code to beeval
'd before the test command (defaults tok9s0ke_t3st_g_hook_test_pre
, or empty). The framework adds additional code to this hook (errexit
/set_pre
setup, redirects)...._one diff_on={ ok, | notok, }*
: print actual vs expected results (as TAP "# ..." comments) for some tests. The default isnotok
, or$k9s0ke_t3st_g_diff_on
if defined. Use '=ok,notok
' to print all diffs or '=,
' to print none...._one
supports key+=value arguments (which append to previous values, or the default). For example, you can have aTTT_myfun
wrapper which calls..._one
including aspec=
argument, then callTTT_myfun spec+='...'
- you can inject arbitrary variables into
..._one
viav:VARNAME=value
(no"+="
support yet)
out_rc=$( k9s0ke_t3st_slurp_exec 'prelude' [cmd args]... )
: load a command's output plus exit code into a shell variable. Before executing the command,eval()
the prelude (e.g.set -e
). Use this, along withk9s0ke_t3st_slurp_split
, to avoid truncating final newlines, as the$()
construct does in all shells. If no command is supplied, it runscat
; to slurp a file, use...slurp_exec <...
k9s0ke_t3st_slurp_split "$out_rc" outvar [rcvar]
: split anout_rc
string (as obtained above); setsoutvar
to the output andrcvar
(if supplied and not empty) to the exit status. Both*var
parameters are variable names (don't prepend a "$
")$k9s0ke_t3st_tmp_dir
is a temporary workdir (removed at the end, unlessk9s0ke_t3st_g_keep_tmp = true
). You can use it, but paths starting with.t3st*
are reserved for the library.k9s0ke_t3st_mktemp outvar
creates a temporary file and setsoutvar
to its path. It is automatically removed when testing ends (..._leave
or..._bailout
).k9s0ke_t3st_dump_str str
outputs a compact one-line representation of a string (usingperl
if available and not explicitly disabled by settingk9s0ke_t3st__perl
to''
).
For convenience, the library defines a few character constants, most notably k9s0ke_t3st_nl
(\n
), but also a tab, ['"<>|&;]
etc (named k9s0ke_t3st_ch_
+ the HTML entity name mostly — see the source)
TLDR: t/t3st.t
contains two _me
tests, complete with .out
, .rc
and .exec
.
Call k9s0ke_t3st_me FILE [PARAM=..]..
(like _one
but without -- cmd..
); instead of rc=
, in=
and out=
, create FILE.{rc,in,out}
files. nl=false
is assumed. The actual command can be supplied as an initial exec=...
argument (after FILE). If no exec=
is supplied, _me()
(unlike _one()
which defaults to cat
) looks for an .exec
file and uses that.
_me()
calls _one()
internally, so the in
, rc
, and out
defaults still apply, and other parameters can be supplied.
The expected output .out
is read in a shell variable, so it still can't contain NUL
(\0
) characters. The .in
file (if any), however, is used as an infile=
parameter (a real redirect) and thus can contain anything.
..._me()
can be called multiple times with different arguments, and doesn't preclude invoking regular _one()
in the same .t
file. You may wish to stick to one style per test file for clarity, though.
While the library itself only uses POSIX shell code, it can test scripts that require bash
(or others) — the library code works in several shells. Use an appropriate shebang in *.t
, or pass prove
a -e SHELL
argument. The following is being used to test t3st
itself (with no errors):
for shell in dash bash bash44 bash32 'busybox sh' mksh yash zsh 'zsh --emulate sh' posh
do printf '\n%s\n' "$shell"; prove -e "$shell"
done
# or `git t3st-prove`
posh
doesn't honorset -e
inside eval; expliciterrexit=true
tests are auto-skipped.- FreeBSD sh: has exhibited nested parameter expansion bugs (
t3st
does not currently use this)
t3st
itself does not depend on the following behaviors, but zsh has not been fully tested, so there may be other problems. The major differences from POSIX seem to be that by default:
sh_option_letters
= off; someset -?
options have a different meaning (in particular, 'set -F
', rather than '-f
', isnoglob
). Avoid this by usingset {+o|-o} OPTNAME
instead of option letters (+o
= disabled); this makes code portable to both POSIX shells andzsh
(for POSIX options, that is).shwordsplit
= offnomatch
= on (causes failures withset -e
when globs fail to match)posixcd
= off (directories starting with+/-
are reinterpreted as dirstack entries)posixargzero
= off ($0
switches to the function name inside a function)glob_subst
= off: zsh does not honor globs (*, ?
) in${var##$pat}
,${var%$pat}
etc parameter expansions (unless in ksh / sh emulation). This only applies to patterns supplied via a variable. For example,pat='/*'; var=/etc; echo ${var##$pat}
yields''
insh
, but not inzsh
.
Use set {-|+}o OPTNAME
individually to turn these on/off (don't combine, for maximum portability, and definitely don't omit {+|-}o
before each option). Or invoke zsh with --emulate [k]sh
to turn on POSIX mode / ksh
mode (the latter is close to bash). emulate
also works as a command, or as a wrapper (emulate sh -c '...'
). Check "[ "${ZSH_VERSION:-}" ]
" to see if running under zsh (possibly in emulation).
prove
(distributed with perl
) acts as both a test harness and a TAP producer. t3st
relies on prove
for shell scripts (not perl modules), so some command-line options are more relevant than others (for the full details, review the prove perldoc
):
prove [options] [FILE-or-DIR].. [ :: SCRIPT_ARG..]
:prove
accepts multiple directories and/or files; with no positional parameters, it looks for at/
directory. Inside directories (but not their subdirs), it looks for.t
files.- everything after
::
— passed to every*.t
(e.g.--help
,--db=...
). -r
: recurse into subdirs too-v
: verbose mode; without it, you won't see individual test names, actual-vs-expected lines, SKIP / TODO's etc — only a summary. It doesn't combine nicely with other flags (-j
), however.-e SHELL
: controls what shellprove
uses for all*.t
's. Otherwise, executable.t
's use their own shebang (#!
) line, i.e. probably/bin/sh
. Non-executable.t
's get run with Perl, which won't do much good for shell scripts.SHELL
can be a command, such as'busybox sh'
or'zsh --emulate sh'
.-j9
: enables parallel execution (don't combine with-v
)-Q
: really quiet — no progress--timer
-a tap.tgz
: produces an archive of TAP results, which you can pass to other utilities. The*.t
in the archive are, somewhat confusingly, TAP logs named identically to the tests that produced them.--formatter=TAP::Formatter::HTML
prove -j<INT>
runs test files (but not individual tests) in parallel. Put slow tests in separate .t
's (and use e.g. prove -j9
) if testing takes too long.
- Projects using
t3st
:t3st
includes self-tests based onttt0
(t/t3st.t
)- The
mru-files.kak
test branch has a self-containedt3st
+git
setup, with separate worktrees / branches. That project includes a POSIX shell library, the test file for which can also serve as inspiration. bashaaparse
'smin-template.sh
(a sh / bash / zsh argument parser) has tests that use temporary files,pp=
post-processing andhook_test_pre
to enforce complex conditions (grep in stderr, check globals assigned by code)
- TAP consumers: if you want to go beyond the widely available
prove
command. The language they're written in doesn't matter as long as they can parse TAP output. For example, ESR'stapview
. You'll still need to generate the TAP output in the first place; see "Using prove" (-a tap.tgz
). - other frameworks:
shellspec
,sharness
,bats-core
,shspec
,assert.sh
Alin Mr. <almr.oss@outlook.com>
/ MIT license