Bash & the POSIX Shell Module 16 – Quality: ShellCheck, tests, style and speed
Testing Bash scripts with Bats
Write Bats tests for Bash scripts. Use run to read $status and $output, setup and teardown for isolation, and a stub to test offline.
What you will learn
- Write a Bats test file with @test blocks that print a TAP plan and one ok line per test
- Use run to capture $status, $output and ${lines[@]} in your assertions, with --separate-stderr for errors
- Isolate tests with setup, teardown and $BATS_TEST_TMPDIR, and source a script without running its main
- Replace an external command with a fake first on PATH, so a networked script can be tested offline
Before you start
What you’ll learn · Before you start
What you will learn
- Write a Bats test file with @test blocks that print a TAP plan and one ok line per test
- Use run to capture $status, $output and ${lines[@]} in your assertions, with --separate-stderr for errors
- Isolate tests with setup, teardown and $BATS_TEST_TMPDIR, and source a script without running its main
- Replace an external command with a fake first on PATH, so a networked script can be tested offline
Before you start
On this page
A Bash script gets edited long after it was written, and one changed line can break it with nothing to say so until
it runs for real. Three Bats tests are enough to pin down what a small greet function must do: bats --formatter tap greet.bats prints 1..3, then one ok line for each behaviour that still holds. Bats-core 1.14.0
gives you the parts of a suite worth keeping: @test blocks that read like sentences, a run helper that captures a
command’s status and output, setup and teardown around every test, and a fresh process with its own empty folder
for each one.
Below you will write a @test block for a greet function, read $status and $output from run, isolate tests
with setup, teardown and $BATS_TEST_TMPDIR, and stub curl offline by putting a fake first on PATH. The scripts
have a main guard: The test at the end of a script, if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi, which runs main only when the file is executed as a program and skips it when a shell sources the file. More in “Script structure: main, source and libraries” so a test file can sourcing: Running a file's commands in the current shell with source file or . file, so its assignments, functions and cd stay after the file returns; return N inside the file ends it early. More in “Script structure: main, source and libraries” them without main
running.
A @test block is one test; a file prints TAP
A Bats: A testing framework for Bash scripts (bats-core, MIT licence). A .bats file is Bash plus @test blocks, which Bats turns into functions and runs one by one; its TAP report has a plan line and one ok or not ok line per test. file is Bash with one extra form, @test "name" { ... }, which marks a test; Bats turns
each block into a function before it runs it. Bats-core 1.14.0 reads the file, counts the @test blocks, prints a
Test Anything Protocol: A plain-text format for test results that most CI systems read: a plan line of the form 1..N, then one ok or not ok line per test, with optional # diagnostic lines. Bats prints it whenever its output goes to a file, a pipe or a CI log, and with --formatter tap. plan line, runs each block in order and prints ok N name or not ok N name for
every one. The suite below tests a tiny greet.sh:
#!/usr/bin/env bash
# greet.sh: prints "Namaste, NAME!" with "friend" as the default.
# A test file can source this script without main running, because the
# main guard at the bottom only calls main when the file is run directly.
greet() {
local name=${1:-friend}
printf 'Namaste, %s!\n' "$name"
}
main() {
greet "$@"
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fi #!/usr/bin/env bats
# Tests for greet.sh. Each @test block is one test; run executes a command and
# records its exit status, standard output and the lines of that output.
setup() {
source "${BATS_TEST_DIRNAME}/greet.sh"
}
@test "greet with no name uses friend" {
run greet
[ "$status" -eq 0 ]
[ "$output" = "Namaste, friend!" ]
}
@test "greet with a name uses that name" {
run greet Priya
[ "$status" -eq 0 ]
[ "$output" = "Namaste, Priya!" ]
}
@test "running the script prints the greeting" {
run bash "${BATS_TEST_DIRNAME}/greet.sh" Meera
[ "$status" -eq 0 ]
[ "${lines[0]}" = "Namaste, Meera!" ]
} Run the file with bats --formatter tap greet.bats. TAP is what Bats prints whenever its output goes to a file, a
pipe or a CI log; the flag makes that choice explicit, and bats -p asks for the pretty view instead, a tick per test
and a “3 tests, 0 failures” line. The recorded run:
from session import files, run
# Runs the Bats suite for greet.sh, the two files shown above. --formatter tap asks for
# TAP whatever the output is connected to, so this record reads the same on every machine
# and in every CI log; bats -p would ask for the pretty view instead.
files("greet.sh", "greet.bats")
run("""
bats --formatter tap greet.bats
""") Output
$ bats --formatter tap greet.bats 1..3 ok 1 greet with no name uses friend ok 2 greet with a name uses that name ok 3 running the script prints the greeting
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 greet_session.py
The plan line 1..3 says how many tests will run, and each ok line is one passing test. A failing test starts
with not ok, and the # lines under it give the test file and the line where it stopped, such as (in test file greet.bats, line 18), then the command that failed. When the failure happens inside a
shell function: A named group of commands defined with name() { …; } or function name { …; }, called like a command. It runs in the current shell, shares its variables unless local is used, and returns a status with return. More in “Functions: arguments, local and return status”, they name that function and its line first. TAP goes back to
t/TEST, the test script that came with Perl 1. Bats writes the older form, without a version line:
--formatter tap13 adds TAP version 13 for a reader that wants one, and --formatter junit prints JUnit XML for a
CI system that reads that format instead.
One idea per test, named in a sentence
“greet with no name uses friend” is already the sentence an interview answer would start with. If a test needs “and” in its name, split it: one @test per behaviour makes a failure point at exactly one thing.
Checkpoint
Check: @test blocks and TAP
run captures status and output, so assertions are plain Bash
Bats runs every @test with set -e, so a command that ends with a non-zero
exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it” ends the test with not ok. That is a problem for the commands you are
testing, because a command under test may be meant to fail. run cmd ... solves it: Bats runs the command in a
subshell, saves its exit status in $status, its combined output in $output and that output split by newline in
the array ${lines[@]}, leaving out empty lines unless you add --keep-empty-lines, and then returns 0 itself, so the
next test assertion: A check in a test that states what must be true for the test to pass. In Bats it is an ordinary command such as [ "$status" -eq 0 ], and its non-zero exit status ends the test with not ok. runs. The assertions are ordinary [ ... ] tests on those variables.
Because the command ran in a subshell, a variable it sets is gone once run returns; call a function directly when
that variable is what you want to test.
The greet.bats tests show the three shapes you need often: [ "$status" -eq 0 ] for the exit code,
[ "$output" = "Namaste, friend!" ] for the whole output, and [ "${lines[0]}" = "Namaste, Meera!" ] for one line of
many. For a command whose standard error: Descriptor 2, where a command writes its warnings and error messages, kept apart from its results so that they still show when standard output goes to a file or a pipe. Also called stderr. More in “Redirection: stdin, stdout, stderr, /dev/null” is the point, add --separate-stderr:
Bats-core 1.5.0 added it, and test 2 of the stub-curl suite below uses it. Step through that test to see what run
keeps when the command fails on purpose:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The test calls run. Test 2 seeds a 500 with MOCK_CURL_STATUS=500 and calls run --separate-stderr download http://example.test/. Called without run, download would return 1 and end the test on that line.
- run starts a subshell. run turns off set -e for the call and runs download in a subshell, so a variable that download sets is gone when run returns.
- download fails on purpose. The fake curl answers 500, so download writes "download: http://example.test/ returned 500" to standard error, nothing to standard output, and returns 1.
- run keeps all three. run saves 1 in $status, the empty standard output in $output and the error line in $stderr. Without --separate-stderr, the error line would be in $output too.
- run returns 0. run itself returns 0, so set -e does not stop the test and the next line, the first check, runs.
- The checks read the data. [ "$status" -eq 1 ], [ -z "$output" ] and the $stderr comparison all hold, so Bats prints ok 2: the test proved that the failure is the right one.
After run, these are the variables a test reads, with the assertion each one usually feeds:
After run cmd ... |
Where it comes from | What a test asserts |
|---|---|---|
$status |
the command’s exit status | [ "$status" -eq 0 ] or -eq 1 |
$output |
the command’s combined stdout and stderr | [ "$output" = "Namaste, friend!" ] |
${lines[@]} |
$output split by newline, empty lines left out |
[ "${lines[0]}" = "..." ], [ "${#lines[@]}" -eq 2 ] |
$stderr |
the error stream, only with --separate-stderr |
[ "$stderr" = "download: ... returned 500" ] |
$BATS_RUN_COMMAND |
the command and arguments given to run |
[ "$BATS_RUN_COMMAND" = "greet Priya" ] |
Forgetting run and testing $? instead
Wrong: greet "$name"; [ "$?" -eq 0 ]. If greet fails, the test ends at that call, so the [ "$?" -eq 0 ] line
never runs, and a test that expects a failure, [ "$?" -eq 1 ], can never pass. Right: run greet "$name", then
[ "$status" -eq 0 ]. The point of run is to turn a failure into data the next line can check.
Checkpoint
Check: run captures status and output
setup, teardown and a fresh folder isolate every test
Bats runs each @test block in a fresh Bash process of its own, so variables, the current folder and traps from one
test cannot leak into the next: a cd / in test 1 still leaves test 2 in the folder bats was started from. setup
runs before every test and teardown runs after, in that same process; setup_file and teardown_file run once
around the whole file, in a process of their own, so only what setup_file exports reaches the tests. The
environment variable: A variable that has been exported, so every program the shell starts receives a copy of it in its environment, the list of name=value pairs a process begins with. More in “Variables, export and the environment” $BATS_TEST_TMPDIR names a new, empty folder made for
the current test: what other tests wrote in their own folders is not in it, and Bats removes all those folders when
the run ends. test isolation: The property that one test's actions cannot change another test's result. Bats runs each @test in a fresh process of its own with a new, empty $BATS_TEST_TMPDIR, so variables, the current folder and traps do not carry over from one test to the next. in Bats covers the process and that folder; a file
written anywhere else is still there for the next test.
Walk through the lifecycle for the first @test of greet.bats:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The file starts. Bats parses greet.bats, counts the @test blocks and prints its plan line, 1..3, so a reader knows how many tests will run.
- setup_file runs once. If the file defines setup_file, Bats runs it before any @test, and only what it exports reaches the tests. teardown_file runs after the last @test the same way. greet.bats defines neither.
- A fresh process per @test. Bats starts a new Bash process for every @test, so variables, the current folder and traps from one test cannot leak into the next. $BATS_TEST_TMPDIR points at a new, empty folder of its own.
- setup runs. In greet.bats, setup sources greet.sh so the test can call its functions. The main guard inside greet.sh sees that BASH_SOURCE[0] is greet.sh but $0 is the Bats program bats-exec-test, so main does not run.
- The @test body runs. "greet with no name uses friend" runs. run greet calls greet with no arguments, captures its exit status in $status and its printed text in $output, and leaves $? at 0 so the next command is not interrupted.
- The assertions decide. [ "$status" -eq 0 ] and [ "$output" = "Namaste, friend!" ] decide whether the test passes. Each is an ordinary command: a non-zero status would end the @test with "not ok".
- teardown runs, the process ends. teardown runs if it is defined, then the process exits. The next test gets a different $BATS_TEST_TMPDIR; Bats keeps every test folder until the run is over and then removes them all.
- Bats prints the TAP line. "ok 1 greet with no name uses friend" goes to standard output. The next @test starts another fresh process; the three ok lines of greet.bats are three repeats of steps 3 to 7.
The source line in step 4 is what the main guard is for. source "${BATS_TEST_DIRNAME}/greet.sh"
reads the file into the test’s shell so you can call greet directly, but greet.sh’s own
[[ ${BASH_SOURCE[0]} == "$0" ]] is false there ($0 is Bats’s own bats-exec-test program), so main does not
run. See
sourcing and script structure for the template every script in this
module uses.
Key idea
A @test block runs in a fresh process with setup and teardown around it, $BATS_TEST_TMPDIR is a new, empty
folder for that test alone, and only what setup_file exports reaches the tests.
Checkpoint
Check: Setup, teardown and a fresh folder
Stub an external command with a fake first on PATH
A command stub: A fake program with a real command's name, placed first on PATH so the shell finds it instead of the real one. A stub lets a test decide what the script sees from curl, date or any external tool, so the suite needs no network and gives the same result every run. is a fake program with the real one’s name, placed first on PATH so
the shell finds it instead: for a name that is not a function or a builtin, Bash searches the folders of PATH in
order and runs the first executable file with that name. For a script that calls curl, the stub writes a seeded
body to the file it was told to and prints a seeded status code, so the test proves the script’s own logic without
touching the network. The stub lives in $BATS_TEST_TMPDIR/bin so each test’s PATH is clean when the next one
starts; the real curl is still on PATH, behind the stub.
#!/usr/bin/env bash
# download.sh URL: fetches URL with curl. Prints the body on a 2xx response.
# On any other HTTP status it writes a message to standard error and exits 1.
# A transport error from curl exits 2.
download() {
local url=$1 tmp http
tmp=$(mktemp)
http=$(curl --silent --output "$tmp" --write-out '%{http_code}' "$url") || {
rm -f "$tmp"
return 2
}
if [[ $http != 2* ]]; then
rm -f "$tmp"
printf 'download: %s returned %s\n' "$url" "$http" >&2
return 1
fi
cat "$tmp"
rm -f "$tmp"
}
main() {
download "$@"
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fi #!/usr/bin/env bats
# Tests download.sh offline. A fake curl, first on PATH, writes a seeded body
# and status, so the suite runs without touching the network.
bats_require_minimum_version 1.5.0
setup() {
bindir="${BATS_TEST_TMPDIR}/bin"
mkdir -p "$bindir"
cat > "$bindir/curl" <<'STUB'
#!/usr/bin/env bash
# Fake curl: --output writes MOCK_CURL_BODY to that file; --write-out prints
# MOCK_CURL_STATUS. Only the arguments download.sh uses are read.
set -euo pipefail
out=
while [ $# -gt 0 ]; do
case $1 in
--silent) shift ;;
--output) out=$2; shift 2 ;;
--write-out) shift 2 ;;
*) shift ;;
esac
done
printf '%s' "${MOCK_CURL_BODY-OK}" > "$out"
printf '%s' "${MOCK_CURL_STATUS-200}"
STUB
chmod +x "$bindir/curl"
PATH=$bindir:$PATH
source "${BATS_TEST_DIRNAME}/download.sh"
}
@test "a 2xx response prints the body" {
MOCK_CURL_BODY=hello MOCK_CURL_STATUS=200 run download http://example.test/
[ "$status" -eq 0 ]
[ "$output" = "hello" ]
}
@test "a 500 goes to standard error and the exit status is 1" {
MOCK_CURL_STATUS=500 run --separate-stderr download http://example.test/
[ "$status" -eq 1 ]
[ -z "$output" ]
[ "$stderr" = "download: http://example.test/ returned 500" ]
}
@test "the curl the script finds is this test's stub" {
[ "$(command -v curl)" = "$BATS_TEST_TMPDIR/bin/curl" ]
} from session import files, run
# Runs stub-curl.bats, shown above, against download.sh. The fake curl it writes is the
# only curl the tests reach, so the run needs no network.
files("download.sh", "stub-curl.bats")
run("""
bats --formatter tap stub-curl.bats
""") Output
$ bats --formatter tap stub-curl.bats 1..3 ok 1 a 2xx response prints the body ok 2 a 500 goes to standard error and the exit status is 1 ok 3 the curl the script finds is this test's stub
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stub-curl_session.py
The three tests show what offline testing is for: the body test proves download returns a 2xx body as it is; the 500
test proves the error message goes to standard error and the exit status is 1; the third test is a
test fixture: A known starting state built before a test and torn down after it: a seeded file, a stub command on PATH or an environment variable. In Bats, setup builds a per-test fixture and setup_file a per-file one. check, in which command -v curl must name the stub in this test’s own
$BATS_TEST_TMPDIR/bin, so a broken PATH line fails a test whose name says what went wrong, while the other two
would only show the symptom. The bats_require_minimum_version 1.5.0 line at the top says the file needs Bats 1.5.0
or later, the release that added run --separate-stderr. The function itself arrived in Bats 1.7.0, which also warns
about a run flag used without it; an older Bats does not know the function, so the call fails instead of letting the
suite pass by mistake.
Prove the suite catches a regression with mutation testing: An audit of a test suite's strength: change one small thing in the code under test and see which tests fail. A mutant that no test catches is a hole in the suite; a stronger assertion or a new test is needed.: change one character of
download.sh, run bats stub-curl.bats, and watch a test fail. Flip the comparison from 2* to 3*: the 2xx test
goes from ok to not ok on its [ "$status" -eq 0 ] line, because the function now treats success as an error,
while the other two stay ok. A mutant that leaves every test ok shows a hole in the suite: add the test, or the
assertion, that fails on it.
Run Bats in CI next to the static checks already on the file, so a broken test and a shell bug are both found before
the next pull request: shellcheck download.sh && shfmt -i 2 -ci -d download.sh && bats stub-curl.bats. The
-i 2 -ci flags match the two-space layout of these files; a bare shfmt -d would ask for tabs and fail the job. The
lessons on ShellCheck and
style with shfmt set those up; the
performance lesson times code with the shell’s time, and
time bats stub-curl.bats times the whole suite the same way.
Checkpoint
Check: Stubbing a command on PATH
Practise: predict output, pick the fix, read a TAP line
Each question shows a Bats fragment, a TAP line or a scenario you have met in a shell test. A wrong choice names the rule it broke:
Practice that adapts to you
Practise this lesson
8 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): What do $status and $output hold after run, and why not just call the command directly in a @test?
run CMD... tells Bats to execute the command, save its exit status in $status, its combined standard output and
standard error in $output, and that output split by newline in the array ${lines[@]}, with empty lines left out,
and then return 0 itself. The return 0 is the point: Bats runs a @test with set -e, so a bare greet Priya that
returns 1 would end the test on that line with “not ok” before any assertion ran, and the test could never prove
that a failure is the right answer for bad input. With run, the status is data: [ "$status" -eq 1 ] or
[ "$status" -eq 0 ] is an ordinary [ ] test like any other. For a command whose standard error is the thing you
want to check, run --separate-stderr (Bats 1.5 and up) gives $output and $stderr separately.
Two harder questions follow, one about tests that pass alone but fail in the suite, one about testing a script that calls the network. Explain your reasoning before you open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A Bats test "writes the report" passes when it is the only @test in the file, but fails whenever another test runs before it. The failing message is "report.csv: No such file or directory". Explain what shared state is hiding here, and how you would fix it.
-
Mid to senior
A release script gets a token with curl, then posts the release; after a 503 it runs sleep 5 and posts again, three tries in all. Design an offline Bats suite that proves the retry limit and that the token is sent, without ever sleeping, and show that it would catch a broken retry loop.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is a Bats test file, and what does it print?
Bash with @test "name" { ... } blocks. bats --formatter tap FILE prints a plan line and one ok or not ok line per test, in the Test Anything Protocol that CI systems read.
-
What do $status, $output and ${lines[@]} hold after run CMD?
The command's exit status, its combined standard output and standard error, and that output split into lines with empty ones left out. run --separate-stderr adds $stderr.
-
Why call a command through run inside a @test instead of directly?
Bats runs each test with set -e, so a command that fails directly ends the test at once. run captures the status and returns 0, so the test can assert on the status it expected.
-
How does Bats keep tests from affecting each other?
Every @test runs in a fresh process with a new, empty $BATS_TEST_TMPDIR; setup and teardown run around each test, and setup_file and teardown_file once per file.
-
What must a script have before a Bats file can source it to test its functions?
A main guard, so main does not run when the file is sourced under a different $0; to test the whole command, run the script itself.
-
How do you test a script that calls curl without touching the network?
Put a fake curl first on PATH, in $BATS_TEST_TMPDIR/bin, so the script calls the stub. Then prove the suite with a mutant that must turn at least one test to not ok.
Key takeaways
- A Bats file is Bash with
@test "name" { ... }blocks;bats --formatter tap FILEprints a plan line and oneokornot okline per test, in the Test Anything Protocol that CI systems read. run CMD...runs the command in a subshell and keeps its exit status in$status, its combined output in$outputand its non-empty lines in${lines[@]}, so an assertion is an ordinary[ ... ]test;run --separate-stderradds$stderrfor error messages.- Every
@testruns in a fresh process with a new, empty$BATS_TEST_TMPDIR;setupandteardownrun around each test, andsetup_fileandteardown_filerun once per file. - Test a function by sourcing its script, which needs a main guard that stops
mainfrom running under a different$0; test a command byrunning the script itself. - Stub a command by putting a fake first on
PATHin$BATS_TEST_TMPDIR/bin, so a script that callscurlcan be tested offline, and prove the suite with a mutant that must turn at least one testnot ok.
Terms in this lesson
- Bats
- A testing framework for Bash scripts (bats-core, MIT licence). A .bats file is Bash plus @test blocks, which Bats turns into functions and runs one by one; its TAP report has a plan line and one ok or not ok line per test.
- command stub
- A fake program with a real command's name, placed first on PATH so the shell finds it instead of the real one. A stub lets a test decide what the script sees from curl, date or any external tool, so the suite needs no network and gives the same result every run.
- mutation testing
- An audit of a test suite's strength: change one small thing in the code under test and see which tests fail. A mutant that no test catches is a hole in the suite; a stronger assertion or a new test is needed.
- Test Anything Protocol
- A plain-text format for test results that most CI systems read: a plan line of the form 1..N, then one ok or not ok line per test, with optional # diagnostic lines. Bats prints it whenever its output goes to a file, a pipe or a CI log, and with --formatter tap.
- test assertion
- A check in a test that states what must be true for the test to pass. In Bats it is an ordinary command such as [ "$status" -eq 0 ], and its non-zero exit status ends the test with not ok.
- test fixture
- A known starting state built before a test and torn down after it: a seeded file, a stub command on PATH or an environment variable. In Bats, setup builds a per-test fixture and setup_file a per-file one.
- test isolation
- The property that one test's actions cannot change another test's result. Bats runs each @test in a fresh process of its own with a new, empty $BATS_TEST_TMPDIR, so variables, the current folder and traps do not carry over from one test to the next.
References
- Bats-core documentation (stable) (bats-core maintainers)
- Writing tests: @test, run, setup and teardown (bats-core maintainers)
- Usage: formatters, filters and options (bats-core maintainers)
- bats-core changelog (the release that added each feature) (bats-core on GitHub)
- bats-core v1.14.0 release (bats-core on GitHub)
- Test Anything Protocol, version 14 (Test Anything Protocol)
- TAP history: the versions since Perl 1 (Test Anything Protocol)
- Bash Reference Manual: Command Search and Execution (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Shell Functions (Chet Ramey (GNU Bash maintainer))
Report a problem with this lesson
Kept only in this browser. Your Learn progress