Testing Shell Scripts With Bats and ShellCheck in Practice
Test shell scripts with Bats and ShellCheck: write tests, mock commands, test functions, run in CI and containers, and catch quoting bugs before they ship.

A shell script with no tests is a script that works until the day someone changes it. Shell makes the problem worse than most languages, because mistakes are silent: an unquoted variable, a pipeline that hides a failure, or an assumption about the current directory produces no error, only a wrong result. The tools that fix this are ordinary and cheap. ShellCheck finds a large class of bugs without running anything, and Bats lets you run the script against controlled input and assert on what it does. This guide builds both into a workflow, using a real backup script and a real test suite run on Debian 12 with Bats 1.8.2, ShellCheck 0.9.0 and Bash 5.2.15. Writing the scripts in the first place is covered in the Bash scripting tutorial.
The guide covers what to test and at which level, ShellCheck in depth, a first Bats test, the run helper, temporary files and fixtures, testing exit codes and output, mocking external commands, testing functions, hostile inputs, setup and teardown, CI, containers and a matrix of shells, plus the habits that keep a suite useful instead of brittle.
Three layers of checking
Different tools catch different bugs, and the cheapest layer should run first. The first layer is static: the shell's own bash -n for syntax, shellcheck for quoting, portability and logic mistakes, and shfmt for formatting. These run in milliseconds, need no setup and find roughly the bugs that look obvious in review. The second layer is behavioral: tests that run the script and assert on its exit status, output and effects. These find the bugs that depend on what the script does with real files and arguments. The third layer is environmental: running the same tests across shells, distributions and the scheduler the script will run under, which finds the differences between your machine and the target.
Each layer has a blind spot. ShellCheck cannot know what the script is supposed to do, so it finds no bug in a script that does the wrong thing correctly. Tests cover only the cases someone wrote. Environmental runs cover only the environments chosen. Together they leave little room, and a team that does all three catches most regressions before a human reads the diff. Start with the first layer today, since it needs no investment, and add the others as the script becomes something that matters.
Install the tools
Both are in the standard repositories. On Debian 12, apt install bats shellcheck provides Bats 1.8.2 and ShellCheck 0.9.0. Newer releases are available from the projects' own pages, and Bats can be vendored into a repository as a Git submodule so that every developer and the CI runner use the same version. Pinning matters: a ShellCheck upgrade adds new checks and can turn a green build red, which is good, as long as it happens on your schedule.
sudo apt install bats shellcheck
bats --version
shellcheck --version | sed -n 2p
git submodule add https://github.com/bats-core/bats-core.git test/bats
test/bats/bin/bats --versionBats 1.8.2
version: 0.9.0
The project documentation is in the bats-core manual and the ShellCheck wiki. Both are short and worth reading once through. Keep a test/ directory beside the script and a make test or equivalent target, so that running the suite is one command that everyone knows.
The script under test
All the examples use one script, a backup tool that archives a directory and keeps the newest few archives. It takes an optional -k KEEP, a source and a destination, validates its input and honors an environment variable BACKUP_STAMP that fixes the timestamp, which makes the output predictable in tests. That last feature shows the point of designing for testability, here by letting the clock be injected, is part of writing the script.
#!/usr/bin/env bash
set -euo pipefail
usage() { echo "usage: backup.sh [-k KEEP] SOURCE DEST" >&2; exit 2; }
keep=3
while getopts ":k:" o; do
case $o in k) keep=$OPTARG ;; *) usage ;; esac
done
shift $((OPTIND - 1))
(( $# == 2 )) || usage
[[ $keep =~ ^[0-9]+$ ]] || { echo "KEEP must be a number" >&2; exit 2; }
src=$1 dest=$2
[[ -d $src ]] || { echo "not a directory: $src" >&2; exit 1; }
mkdir -p -- "$dest"
name="$(basename -- "$src")-${BACKUP_STAMP:-$(date +%Y%m%d%H%M%S)}.tar.gz"
tar -czf "$dest/$name" -C "$(dirname -- "$src")" -- "$(basename -- "$src")"
echo "wrote $dest/$name"
mapfile -t old < <(ls -1t -- "$dest"/*.tar.gz | tail -n +"$((keep + 1))")
for f in "${old[@]}"; do [[ -n $f ]] && rm -f -- "$f"; doneIt saves as bin/backup.sh and is executable. The tests below exercise its exit codes, its validation, its handling of file names with spaces and its rotation. Notice it has several behaviors that are easy to break silently: the exit code 2 for usage errors versus 1 for runtime errors, the digit check on KEEP, and the number of archives left after a run.
Run ShellCheck first
Before writing a single test, run ShellCheck on the script. It is the quickest source of real findings, and fixing them first stops tests from encoding bugs as expected behavior.
shellcheck bin/backup.shIn bin/backup.sh line 17:
mapfile -t old < <(ls -1t -- "$dest"/*.tar.gz | tail -n +"$((keep + 1))")
^------------------------^ SC2012 (info): Use find instead of ls to better handle non-alphanumeric filenames.
For more information:
https://www.shellcheck.net/wiki/SC2012 -- Use find instead of ls to better ...
It reports one finding at info level. The script uses ls -t deliberately, to sort by modification time, on file names the script itself created, so the warning is a judgment call: either accept it with a comment or switch to a form that does not parse ls. The exit status of ShellCheck is non-zero when it reports anything, including info notes, which matters for CI. Use -S warning to fail only on warnings and errors, and fix or explicitly disable the rest.
Decide what ShellCheck should fail on
ShellCheck has four severities: error, warning, info and style. On a clean script you can fail on everything; on an old code base, failing on style notes buries the real findings. A sensible start is -S warning in CI, then fix info notes in a cleanup pass and tighten to -S info. Control it with a .shellcheckrc file at the repository root so that developers and CI agree.
# .shellcheckrc
shell=bash
external-sources=true
enable=require-variable-braces,quote-safe-variables
disable=SC1091
shellcheck -S warning -f gcc bin/*.sh
echo "rc=$?"
shellcheck -x -f json bin/backup.sh | head -c 100The gcc format prints one line per finding in the form that editors and CI annotation tools parse. -x (or external-sources=true) follows source statements to check the files they load. The enable= line turns on optional checks listed by shellcheck --list-optional. Keep the file small: every disable in it silences a check for the whole project, so prefer local suppression where the exception is real.
Suppress a warning the right way
Sometimes a warning is wrong for a particular line, such as deliberate word splitting. Silence it with a directive comment on the line above, naming the code and giving the reason. A reason turns a suppression from a mystery into a decision that a later reader can reverse.
# shellcheck disable=SC2086 # opts is a trusted, space-separated list built above
tar $opts -f "$archive" -- "$dir"
# shellcheck source=lib/log.sh
source "$script_dir/lib/log.sh"The source= directive tells ShellCheck where a dynamically named file is, so it can follow it without a warning about not being able to. Keep directives as narrow as possible: placed directly above a command, they apply to that command only; placed at the top of a file, they apply to the whole file. Review them like code, and when a suppression has no reason, ask for one. A count of directives in the repository is a rough measure of how much unreviewed risk the code carries.
Your first Bats test
A Bats file is a Bash script with a special syntax: each @test "name" { ... } block becomes a function, and the test passes if every command inside returns success. Bats runs each test in its own subprocess, so tests cannot affect each other through variables. Save this as test/backup.bats.
#!/usr/bin/env bats
setup() {
SCRIPT="$BATS_TEST_DIRNAME/../bin/backup.sh"
SRC="$BATS_TEST_TMPDIR/data"
DEST="$BATS_TEST_TMPDIR/out"
mkdir -p "$SRC"
echo "hello" > "$SRC/file one.txt"
}
@test "prints usage and exits 2 with no arguments" {
run "$SCRIPT"
[ "$status" -eq 2 ]
[[ $output == *"usage:"* ]]
}bats test/backup.bats1..1
ok 1 prints usage and exits 2 with no arguments
The output is in the Test Anything Protocol format: a plan line, then one line per test. The setup function runs before every test, so each starts from the same state. BATS_TEST_DIRNAME is the directory of the test file, which lets tests find the script no matter where they are launched from. BATS_TEST_TMPDIR is a fresh temporary directory per test, deleted afterwards. The usage test uses the run helper, explained next.
The run helper
run executes a command and captures its exit status in $status, its combined standard output and standard error in $output, and the output split into lines in the array $lines. It then returns success regardless, so a failing command does not abort the test and you can assert on the failure. Without run, a command that exits non-zero fails the test immediately, which is what you want for setup steps and not for the thing under test.
@test "fails when the source is not a directory" {
run "$SCRIPT" /nonexistent "$DEST"
[ "$status" -eq 1 ]
[[ $output == *"not a directory"* ]]
}
@test "rejects a non numeric KEEP" {
run "$SCRIPT" -k many "$SRC" "$DEST"
[ "$status" -eq 2 ]
}ok 2 fails when the source is not a directory
ok 3 rejects a non numeric KEEP
Because $output merges the two streams, a test cannot tell whether a message went to standard error. Bats 1.5 and later add run --separate-stderr, which fills $stderr separately, and a bats_require_minimum_version call documents the dependency. The Debian 12 package is 1.8.2, so that option is available. Assertions use plain Bash: [ ] for numbers and exact strings, [[ ]] for patterns. The pair [ "$status" -eq 0 ] is the usual first line after run; asserting the exit status first makes a failure show the useful part.
What a failing test looks like
A test suite is only useful if its failures are readable. Bats prints the test name, the file and line, and the command that failed.
@test "deliberate failure shows output" {
run bash -c 'echo got; exit 3'
[ "$status" -eq 0 ]
}1..1
not ok 1 deliberate failure shows output
# (in test file test/fail.bats, line 3)
# `[ "$status" -eq 0 ]' failed
The report names the assertion but not the value of $status, which is a weakness of bare [ ]. Print the context yourself when it helps, with echo "status=$status output=$output" >&3, since Bats shows anything written to file descriptor 3 in the report and hides the rest. Or use the bats-assert helper library, whose assert_equal and assert_output print expected and actual values side by side. The exit status of bats is non-zero when any test fails, which is what CI reads.
Assert with bats-assert and bats-support
The bats-core project maintains helper libraries that make assertions expressive and failures informative. bats-support provides the plumbing, bats-assert provides assert_success, assert_failure, assert_output, assert_line and refute_output, and bats-file provides file assertions such as assert_file_exists. Add them as submodules and load them in setup.
setup() {
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
load 'test_helper/bats-file/load'
}
@test "creates an archive" {
BACKUP_STAMP=20261105000001 run "$SCRIPT" "$SRC" "$DEST"
assert_success
assert_output --partial "wrote"
assert_file_exists "$DEST/data-20261105000001.tar.gz"
}When assert_output fails it prints the expected and the actual text, which saves the extra echo lines. Use --regexp for pattern matches and --partial for substrings. The libraries are optional: a suite of plain [ ] and [[ ]] works, has no dependencies, and is often enough for a script. Choose the libraries when failure messages are costing time.
Test a file with a space in its name
The point of a test suite is to pin down the behaviors that matter and are easy to break. For this script, the commonest regression would be losing the quoting, so a file name with a space gets its own test.
@test "creates an archive containing a file with a space in its name" {
BACKUP_STAMP=20261105000001 run "$SCRIPT" "$SRC" "$DEST"
[ "$status" -eq 0 ]
[ -f "$DEST/data-20261105000001.tar.gz" ]
run tar -tzf "$DEST/data-20261105000001.tar.gz"
[[ $output == *"data/file one.txt"* ]]
}ok 4 creates an archive containing a file with a space in its name
The environment variable BACKUP_STAMP makes the archive name predictable. Setting a variable on the same line as run applies it to that command only. The second run reads the archive's table of contents, so the test checks the effect and not only that a file appeared. A test named after the property it protects, "containing a file with a space in its name", doubles as documentation, and when it fails the name tells the reader what broke.
Test rotation and time-dependent behavior
The rotation logic depends on file modification times, which tests must control. The simplest honest approach is to create the files with distinct times, either by waiting or by setting the time directly with touch -d. Waiting makes tests slow, so prefer to set the time.
@test "keeps only the newest KEEP archives" {
for n in 1 2 3 4; do
BACKUP_STAMP=2026110500000$n "$SCRIPT" -k 2 "$SRC" "$DEST" >/dev/null
sleep 1.1
done
run bash -c "ls '$DEST' | wc -l"
[ "$output" -eq 2 ]
}ok 5 keeps only the newest KEEP archives
This version sleeps a little over a second between runs, because the archives are sorted by modification time and the filesystem granularity can be coarse; it passed here but takes four seconds and could be flaky on a slow disk. A faster test creates the old archives by hand, with touch -d '2 days ago' and touch -d '1 day ago', then runs the script once and checks that only the right ones remain. Where a script reads the clock, inject it, as with BACKUP_STAMP, or put a fake date first on the PATH, as in the next section. Tests that use real sleeps are a sign that the script needs a seam.
Mock external commands
A script that calls curl, ssh, docker or systemctl cannot be tested safely against the real thing. Replace the command with a fake. Because the shell finds commands through PATH, a directory of fake executables placed first on PATH intercepts the call with no change to the script.
@test "mock via PATH" {
mkdir -p "$BATS_TEST_TMPDIR/bin"
printf '#!/bin/sh\necho "fake date"\n' > "$BATS_TEST_TMPDIR/bin/date"
chmod +x "$BATS_TEST_TMPDIR/bin/date"
PATH="$BATS_TEST_TMPDIR/bin:$PATH" run date
[ "$output" = "fake date" ]
}ok 3 mock via PATH
A more useful fake records how it was called, so the test can assert on the arguments, and can be told what to print or what status to return.
make_mock() { # make_mock NAME [EXIT_CODE] [OUTPUT]
local dir="$BATS_TEST_TMPDIR/bin"; mkdir -p "$dir"
cat > "$dir/$1" <<EOF
#!/bin/sh
printf '%s\n' "\$*" >> "$BATS_TEST_TMPDIR/$1.calls"
printf '%s' "${3:-}"
exit ${2:-0}
EOF
chmod +x "$dir/$1"
PATH="$dir:$PATH"
}
@test "notifies when the backup fails" {
make_mock curl 0
run "$SCRIPT" /nonexistent "$DEST"
[ "$status" -eq 1 ]
grep -q 'hooks.example.org' "$BATS_TEST_TMPDIR/curl.calls"
}Each call appends its arguments to a file, which the test inspects afterwards, and the fake exits with the code you choose to simulate failures: a mock that exits 1 tests the script's error handling without needing a network that fails on demand. The PATH assignment in the helper runs in the test's shell, so it applies to every later command in that test. Keep the mocks tiny and put them in a shared helper file loaded by each test file. The bats-mock library packages the same idea if you prefer a ready-made one.
Mock functions and builtins
Builtins and shell functions are not found through PATH, so the PATH trick cannot replace them. Inside a test that sources the script's functions, define a function of the same name; a function takes precedence over a command, so a test can override curl or date without creating files.
@test "retry gives up after N attempts" {
source "$BATS_TEST_DIRNAME/../lib/retry.sh"
calls=0
flaky() { calls=$((calls + 1)); return 1; }
sleep() { :; }
run retry 3 flaky
[ "$status" -eq 1 ]
}Note a trap here: run executes its command in a subshell, so the counter calls is not updated in the test's own shell and cannot be asserted afterwards. Either write the count to a file inside the fake, or call the function without run and capture its status with || status=$?. Redefining sleep as a no-op also keeps tests of retry logic fast. The cleaner design is to give the function its delay as a parameter, so the test passes zero.
Make functions testable
A script that runs its work at the top level cannot be loaded without executing. The standard fix is to put the logic in functions and guard the entry point, so sourcing the file defines the functions and running it calls main.
add() { echo $(( $1 + $2 )); }
main() { add "$@"; }
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fibash lib.sh 2 3
bash -c 'source ./lib.sh; add 4 5'5
9
When the file is executed, BASH_SOURCE[0] equals $0 and main runs. When it is sourced, they differ and nothing runs. A test can then source the file and call add directly, which is far faster than starting the whole script and makes small units easy to cover. This structure is also better design: logic in functions, one main, a single place where the script touches the outside world. Keep set -euo pipefail inside main or the guard, or the sourcing test inherits strict mode and fails in confusing places.
Test hostile input
The inputs that break shell scripts are predictable, so test them on purpose. A short list of awkward values catches most quoting and validation bugs: a name with a space, a name with a leading dash, an empty string, a name with a newline, a path that does not exist, and a path that is a symlink. For a security-sensitive script, add a value containing a semicolon, a dollar sign and a backtick.
@test "handles awkward directory names" {
for name in "with space" "-leading-dash" "semi;colon" 'dollar$HOME' "new
line"; do
dir="$BATS_TEST_TMPDIR/src/$name"
mkdir -p "$dir"
echo x > "$dir/f"
run "$SCRIPT" "$dir" "$BATS_TEST_TMPDIR/out"
[ "$status" -eq 0 ] || { echo "failed for: $name" >&3; false; }
done
}
@test "empty arguments are a usage error, not a crash" {
run "$SCRIPT" "" ""
[ "$status" -eq 1 ]
}Looping inside one test keeps the file short, and the message to file descriptor 3 names the value that failed. For a larger table of cases, generate tests in a loop with a function that registers one test per row. The hostile inputs mirror the attacks described in the shell script security guide, and each one you write is a regression test for a class of bug and not a single instance.
Setup, teardown and fixtures
setup and teardown run before and after every test. setup_file and teardown_file run once per file, and setup_suite once per run, for expensive shared state such as building a container or starting a local server. Variables exported in setup_file are visible to the tests. BATS_FILE_TMPDIR is a directory shared by all tests in the file, and BATS_TEST_TMPDIR is private to one.
setup_file() {
export SHARED="$BATS_FILE_TMPDIR/shared"
echo init > "$SHARED"
}
teardown() {
echo "td" >> "$BATS_FILE_TMPDIR/log"
}
@test "uses shared" { [ "$(cat "$SHARED")" = init ]; }
@test "skipped one" { skip "needs docker"; false; }1..3
ok 1 uses shared
ok 2 skipped one # skip needs docker
ok 3 mock via PATH
The temporary directories are removed automatically, so tests do not leave files behind, which also means a failing test's files are gone by the time you look; run with --no-tempdir-cleanup to keep them for debugging. Use skip "reason" for tests that cannot run in the current environment, such as one needing root or Docker, so the report states why instead of failing. Never write to the working directory or the repository from a test; always use the temporary directories, or tests will interfere with each other and with your checkout.
Test failure paths on purpose
A script's error handling is code, and untested error handling is usually wrong. Force each failure and assert on what the script does: it exits with the documented code, prints a useful message, leaves no temporary files and does not touch the destination. Simulating failures needs the seams from earlier. A mock that exits non-zero stands in for a dead network. A read-only directory stands in for a full disk. A missing executable, created by an empty PATH, tests the dependency check.
@test "reports a missing dependency" {
PATH="$BATS_TEST_TMPDIR/empty" run /bin/bash "$SCRIPT" "$SRC" "$DEST"
[ "$status" -ne 0 ]
}
@test "does not leave a partial archive when tar fails" {
make_mock tar 2
run "$SCRIPT" "$SRC" "$DEST"
[ "$status" -ne 0 ]
run bash -c "ls '$DEST'/*.tar.gz 2>/dev/null | wc -l"
[ "$output" -eq 0 ]
}The first test confirms that the script fails and does not carry on when a tool it needs is missing, since an empty PATH hides tar and date. The second replaces tar with a mock that exits 2 and checks that no archive is left. It passes against the sample script because set -e stops the run at once, but it has a limit worth knowing: a mock that writes nothing cannot show what a real tar that dies halfway leaves behind. To test that, make a mock that writes a partial file and then fails, and assert that the script removes it or never places it in the destination. Writing that test is what pushes a script toward building in a temporary location and moving the finished file into place. Tests of failure paths are where a suite earns its keep, since failures are the cases nobody runs by hand.
Run the suite
Run the whole directory with bats test/, one file with its path, or filter by test name with --filter. Output formats include pretty (the default on a terminal), TAP, JUnit XML for CI dashboards, and the timing report. With GNU parallel installed, --jobs N runs files in parallel, so tests must not share state.
bats test/
bats --filter 'space' test/backup.bats
bats --tap test/ | tee results.tap
bats --report-formatter junit --output reports test/
bats --timing test/backup.bats1..5
ok 1 prints usage and exits 2 with no arguments in 7ms
ok 2 fails when the source is not a directory in 7ms
Watch the timing report for slow tests; a suite that takes a minute stops being run before every commit. Aim for seconds. Tag slow or environment-dependent tests and run them in a separate CI job, using Bats tags (# bats test_tags=slow) with --filter-tags. The goal is a fast default run that developers use constantly and a fuller run that CI always does.
Run in CI
CI turns a suite from a habit into a gate. The job installs the tools, runs ShellCheck on every shell file and runs Bats, and fails the build on any non-zero exit. Because the tools are in the standard repositories, the job is a few lines on any runner. Use find or git ls-files to discover scripts, so a new file is checked without editing the config.
name: shell
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install tools
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: ShellCheck
run: git ls-files -z '*.sh' | xargs -0 shellcheck -S warning -f gcc
- name: Bats
run: test/bats/bin/bats --tap test/Pin the runner image and the action versions so the pipeline does not change under you. Using the vendored Bats from the submodule gives every contributor and the runner the same version. Add shfmt -d to fail on formatting drift if the team has agreed on a style. A pre-commit hook that runs ShellCheck on staged files gives faster feedback than waiting for CI, though CI remains the gate, since hooks can be skipped.
Test across shells and distributions
A script that claims to be portable must be checked under the shells it claims. Bats runs under Bash, but a test can launch the script under any interpreter, so a loop over shells gives a matrix with no extra tooling. For distribution differences, run the whole suite inside containers.
@test "runs under dash and busybox" {
for sh in dash "busybox sh" bash; do
run $sh "$BATS_TEST_DIRNAME/../bin/portable.sh" --version
[ "$status" -eq 0 ] || { echo "failed under: $sh" >&3; false; }
done
}docker run --rm -v "$PWD":/w -w /w debian:12 bash -c 'apt-get update -qq && apt-get install -y -qq bats shellcheck >/dev/null && bats test/'
docker run --rm -v "$PWD":/w -w /w alpine:3.20 sh -c 'apk add --no-cache bash bats shellcheck >/dev/null && bats test/'The unquoted $sh in the first example is intentional, so that busybox sh splits into two words; ShellCheck would flag it, so the line carries a suppression with that reason in real use. What the portable shell guide recommends, in POSIX sh versus Bash, is the same idea: the scripts and the tests agree on which shells matter, and CI runs them. Running the suite on the oldest Bash you support, in a container, finds the use of features that arrived later.
Measure coverage
Coverage for shell is possible with kcov, which instruments Bash by tracing line execution and produces an HTML report, and it can wrap Bats. Coverage numbers say which lines no test reached, not whether the tests are good, so read the uncovered lines and ask why. Error branches and rarely taken options usually show up red, and those are the ones worth a test.
kcov --include-path=bin coverage/ bats test/
xdg-open coverage/index.htmlTreat a threshold with care. A rule of 80 percent line coverage invites tests written to touch lines rather than to check behavior. Use coverage to find gaps in a suite you already trust, and rely on code review and the hostile-input list to judge the rest. A short script with every branch exercised and every documented exit code asserted is better tested than a long one with high coverage and loose assertions.
Common mistakes in test suites
- Writing to the repository or the real home directory. Use the temporary directories Bats provides.
- Tests that depend on each other's order. Each test must set up its own state.
- Asserting on the whole output. A test that compares a full log breaks on any wording change; assert on what matters.
- Forgetting that
runuses a subshell. Variables set inside are lost; write to a file or avoidrun. - Real sleeps and real network calls. They make tests slow and flaky; mock the clock and the network.
- Suppressing ShellCheck without a reason. Every directive should say why.
- Testing the implementation, not the behavior. Assert on exit codes, output and effects, not on which commands ran.
- Skipping failure paths. Error handling is where scripts hurt people.
- Running tests only on your machine. CI and a container catch what your environment hides.
The unifying advice is to treat tests as small, independent, readable programs. If a test needs a paragraph to explain, split it.
A workflow for changing a script safely
Putting the pieces together gives a routine for the situation where most scripts are touched: modifying one that already exists and matters. First run ShellCheck and fix or consciously accept every finding. Second, write characterization tests: run the script on representative input and record its current behavior as assertions, bugs included, so that you know when something changes. Third, write a failing test for the change you want. Fourth, make the change until the test passes and the old tests still do. Fifth, run the suite across the shells and containers you support. Sixth, review the diff for new suppressions. When something breaks in production, add the reproduction as a test before fixing it, as described in the debugging guide.
This is the same loop as in any language, and the discipline is the point. The reason shell scripts decay is not the language but the habit of editing them untested, since a missing test is invisible until the day it matters.
Golden files for larger output
When a script produces a report or a generated file, comparing it with a stored expected copy, a golden file, is simpler than writing many assertions. Keep the expected output under test/golden/, generate the actual output in the test, and compare with diff -u, whose output shows precisely what changed. To update the golden file after an intended change, regenerate it and review the diff in version control like any code.
@test "report matches the golden file" {
run "$SCRIPT" --report "$BATS_TEST_DIRNAME/fixtures/input.csv"
[ "$status" -eq 0 ]
diff -u "$BATS_TEST_DIRNAME/golden/report.txt" <(printf '%s\n' "$output")
}Keep the input small and deterministic: fix the clock, sort anything that has no defined order, and strip volatile fields such as process IDs and timestamps with sed before the comparison. Golden files suit output that people read; for anything with structure, assert on fields. A golden test that fails on every harmless change teaches the team to regenerate the file without looking, which defeats it, so keep each golden test narrow.
Check that a script is idempotent
Scripts that configure systems or run on a schedule should be safe to run twice. That property is cheap to test: run the script, capture the state, run it again, and assert the state is unchanged and the second run succeeds. The state can be a directory listing with checksums, the output of a status command, or a file's content.
@test "running twice changes nothing the second time" {
BACKUP_STAMP=20261105000001 run "$SCRIPT" "$SRC" "$DEST"
[ "$status" -eq 0 ]
before=$(cd "$DEST" && find . -type f | sort | xargs sha256sum)
BACKUP_STAMP=20261105000001 run "$SCRIPT" "$SRC" "$DEST"
[ "$status" -eq 0 ]
after=$(cd "$DEST" && find . -type f | sort | xargs sha256sum)
[ "$before" = "$after" ]
}Archives embed modification times, so the second archive has a different checksum even with the same name; if this test fails for that reason, the assertion should compare names and counts instead of content, which is a fair result, since it shows precisely what a rerun does. Deciding what idempotent means for a script is a design question, and a failing test is how it gets asked. The same pattern checks installers and provisioning scripts, where it matters more.
Test in a container when the script needs root
Scripts that manage users, mount filesystems, edit /etc or call systemctl cannot run safely on a developer machine. Run them in a disposable container, where root is harmless and the result vanishes. The suite either runs inside the container, as in the CI example, or drives the container from outside with docker run in each test.
@test "creates the service user in a clean container" {
run docker run --rm -v "$BATS_TEST_DIRNAME/../bin:/opt/bin:ro" debian:12 \
bash -c '/opt/bin/setup-user.sh app && id app && /opt/bin/setup-user.sh app'
[ "$status" -eq 0 ]
[[ $output == *"uid="* ]]
}The command runs the script twice in the same container and checks the second call succeeds, combining a root-only test with the idempotence check. Mount the scripts read-only so a bug cannot rewrite the host copy. Mark such tests with a tag and a skip when Docker is not available, so the fast suite stays fast. For scripts that need systemd, a container needs special setup; a virtual machine, or a CI runner that is itself disposable, is the safer choice.
Pre-commit hooks and formatting
Fast checks belong before the commit. A Git hook that runs ShellCheck and the quick part of the suite catches mistakes while the change is still in your head. The pre-commit framework wraps this in a config file shared by the team, and shfmt enforces a single formatting style, which removes a category of review comments.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.9.0
hooks:
- id: shellcheck
args: [--severity=warning]
- repo: https://github.com/scop/pre-commit-shfmt
rev: v3.7.0-1
hooks:
- id: shfmt
args: [-i, "2", -ci]pip install pre-commit
pre-commit install
pre-commit run --all-filesAn .editorconfig file with indent_style = space and indent_size = 2 for *.sh keeps editors in line with shfmt. Hooks run on the developer's machine and can be bypassed, so the CI job repeats the same checks; the hook is a convenience and CI is the guarantee. Keep the hook fast, under a few seconds, or people will disable it.
Debug a failing Bats test
When a test fails and the reason is not obvious, Bats has options for seeing more. --verbose-run prints the output of every run, --trace prints each command executed in the test, and --print-output-on-failure prints $output only for failing tests. Add --no-tempdir-cleanup to keep the temporary files for inspection. Inside a test, echo "..." >&3 prints a message that is always shown, and set -x works as in any script.
bats --print-output-on-failure test/backup.bats
bats --trace --verbose-run --filter 'space' test/backup.bats
bats --no-tempdir-cleanup test/backup.batsThe trace shows the exact command lines, which is the fastest way to see that an expansion produced something different from what you assumed; the techniques are the same as in the debugging guide. A test that passes alone and fails in the suite shares state with another, usually through a fixed path under /tmp or an exported variable; run each file separately and in a random order to find it.
Treat the tests as documentation
Read the test names of a good suite from top to bottom and you have the script's specification: what it accepts, what it refuses, what it leaves behind and how it fails. That makes the suite a place to look first when you pick up an unfamiliar script, and it is the reason to write test names as plain statements of behavior. When you add a feature, write the test that describes it before the code, and when a user reports a surprise, write the test that describes what they expected. Over a year the file becomes a record of the decisions made about the script, which a README rarely keeps current. Tests also make refactoring a script safe, which matters because shell is easy to write quickly and hard to tidy later: with a suite in place you can rename variables, split a long function and replace a fragile pipeline with confidence, and the tests will say whether behavior changed. The cost is small compared with the first time a change that looked trivial would have broken a nightly job unnoticed.
Review tests in pull requests with the same care as the script: a change that adds a feature and no test deserves the question, and a change that edits an assertion to make a failure go away deserves a reason in the description.
Organize a growing suite
A suite stays useful when it is easy to find things in and quick to run. Keep one .bats file per script or per library, named after it, and put shared helpers in test/test_helper/ so each file loads only what it needs. Group related tests in a file and name them as sentences about behavior: "rejects a non numeric KEEP", not "test3". Keep fixtures small and in test/fixtures/, generated at test time when they are simple, committed when they are not. A Makefile target ties the layers together so that nobody has to remember the commands.
.
├── bin/backup.sh
├── lib/retry.sh
└── test/
├── backup.bats
├── retry.bats
├── test_helper/
│ ├── common.bash
│ └── mocks.bash
└── fixtures/input.csv
lint:
git ls-files -z '*.sh' | xargs -0 shellcheck -S warning
test: lint
test/bats/bin/bats test/
Making test depend on lint means the cheap check runs first and a style problem stops the run in a second. Review the suite occasionally as you would code: delete tests that no longer protect anything, merge near-duplicates and fix any that are slow or flaky. A test that nobody trusts is worse than none, because it trains people to rerun until green.
Test a Makefile or installer end to end
Besides unit-level tests of scripts, a few end-to-end checks prove that the pieces work together as a user would run them: install into a temporary prefix, run the installed program, then uninstall and check that nothing is left. These run slower, so keep them few and run them in CI and in a container.
@test "install puts the script on the path and uninstall removes it" {
prefix="$BATS_TEST_TMPDIR/prefix"
run make -C "$BATS_TEST_DIRNAME/.." install PREFIX="$prefix"
[ "$status" -eq 0 ]
[ -x "$prefix/bin/backup.sh" ]
run "$prefix/bin/backup.sh" -h
[ "$status" -eq 2 ]
run make -C "$BATS_TEST_DIRNAME/.." uninstall PREFIX="$prefix"
[ ! -e "$prefix/bin/backup.sh" ]
}Passing PREFIX as a variable makes the installer relocatable, which is good packaging practice anyway and makes the test possible without root. If your installer cannot be pointed somewhere harmless, that is a finding: fix the installer, not the test.
A complete worked suite for a small tool
The earlier sections showed pieces. This one puts them together for a second tool, a log filter that prints lines at or above a severity, to show how the layers fit. The script takes a level and a file, supports reading from standard input, and fails with distinct exit codes. Writing the tests alongside it, in the order a person would, shows how the design is shaped by them.
#!/usr/bin/env bash
# loglevel.sh LEVEL [FILE] - print lines at LEVEL or more severe
set -euo pipefail
die() { printf 'loglevel: %s\n' "$*" >&2; exit "${2:-1}"; }
level_rank() {
case ${1^^} in
DEBUG) echo 0 ;; INFO) echo 1 ;; WARN) echo 2 ;; ERROR) echo 3 ;;
*) return 1 ;;
esac
}
main() {
(( $# >= 1 && $# <= 2 )) || die "usage: loglevel.sh LEVEL [FILE]" 2
local min; min=$(level_rank "$1") || die "unknown level: $1" 2
local in=${2:--}
[[ $in == - || -r $in ]] || die "cannot read: $in" 1
awk -v min="$min" '
BEGIN { r["DEBUG"]=0; r["INFO"]=1; r["WARN"]=2; r["ERROR"]=3 }
{ lvl = $2; if (lvl in r && r[lvl] >= min) print }
' "$in"
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi#!/usr/bin/env bats
setup() {
SCRIPT="$BATS_TEST_DIRNAME/../loglevel.sh"
LOG="$BATS_TEST_TMPDIR/app.log"
printf '%s\n' \
'10:00:01 DEBUG starting' \
'10:00:02 INFO ready' \
'10:00:03 WARN slow disk' \
'10:00:04 ERROR cannot connect' > "$LOG"
}
@test "WARN shows warnings and errors only" {
run "$SCRIPT" WARN "$LOG"
[ "$status" -eq 0 ]
[ "${#lines[@]}" -eq 2 ]
[[ ${lines[0]} == *"slow disk"* ]]
}
@test "level names are case-insensitive" {
run "$SCRIPT" error "$LOG"
[ "$status" -eq 0 ]
[ "${#lines[@]}" -eq 1 ]
}
@test "reads standard input when no file is given" {
run bash -c "cat '$LOG' | '$SCRIPT' INFO"
[ "$status" -eq 0 ]
[ "${#lines[@]}" -eq 3 ]
}
@test "an unknown level is a usage error" {
run "$SCRIPT" LOUD "$LOG"
[ "$status" -eq 2 ]
}
@test "a missing file is a runtime error" {
run "$SCRIPT" INFO /no/such/file
[ "$status" -eq 1 ]
}
@test "level_rank can be tested directly" {
source "$SCRIPT"
run level_rank warn
[ "$output" = 2 ]
}Notice what the tests demanded of the script. The source guard lets the last test load level_rank alone. The distinct exit codes, 2 for a usage problem and 1 for a runtime one, are assertable. Support for standard input exists because a test wanted to pipe into it. And the script reads the severity table once, in BEGIN, so the awk program is easy to reason about. None of that is exotic, and each came from writing the test first. The suite runs in a fraction of a second, which is what keeps people running it.
Testing scripts that read standard input and prompt
Interactive scripts are awkward to test because they wait for a person. Test the logic behind the prompts by feeding answers on standard input, and design the script so the prompting code is a thin layer. Within Bats, run accepts a here string or a pipe through bash -c, and the -t 0 test in the script makes it take a non-interactive path when no terminal is present.
@test "confirmation accepts y" {
run bash -c "printf 'y\n' | '$SCRIPT' --delete '$BATS_TEST_TMPDIR/victim'"
[ "$status" -eq 0 ]
[ ! -e "$BATS_TEST_TMPDIR/victim" ]
}
@test "confirmation defaults to no" {
touch "$BATS_TEST_TMPDIR/victim"
run bash -c "printf '\n' | '$SCRIPT' --delete '$BATS_TEST_TMPDIR/victim'"
[ "$status" -ne 0 ]
[ -e "$BATS_TEST_TMPDIR/victim" ]
}
@test "--yes skips the prompt" {
touch "$BATS_TEST_TMPDIR/victim"
run "$SCRIPT" --yes --delete "$BATS_TEST_TMPDIR/victim"
[ "$status" -eq 0 ]
}The default-to-no test deserves attention, since a destructive prompt that proceeds on an empty answer is the bug most worth catching. Provide a --yes flag so automation never has to simulate keystrokes. Full-screen interactive programs need expect or a terminal multiplexer driven by tmux send-keys, and those tests are slow and fragile, so use them sparingly and put the real logic where simpler tests can reach it.
Snapshot your dependencies in tests
A suite that passes today and fails after a package upgrade teaches people to distrust it. Control what the tests depend on. Pin the versions of Bats and ShellCheck, run the suite in a container image that is itself pinned, and make tests independent of the machine: no reliance on the time zone, the locale, the home directory, the network or a particular user. Set the environment explicitly in setup so a developer's habits do not leak in.
setup_file() {
export LC_ALL=C TZ=UTC
export HOME="$BATS_FILE_TMPDIR/home"
mkdir -p "$HOME"
unset BASH_ENV ENV CDPATH
}FROM debian:12-slim@sha256:0f8b...
RUN apt-get update && apt-get install -y --no-install-recommends \
bats=1.8.2-1 shellcheck=0.9.0-1 jq git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /work
CMD ["bats", "test/"]
Pinning a package with an equals sign makes the image build fail if that exact version disappears from the archive, which is the signal to update the pin on purpose. A reproducible image also serves as documentation of what the tests need. Developers run the same image locally as CI does, so a failure that only shows on one laptop stops being a category.
Property-style and table-driven tests
Hand-picked examples miss cases. Two techniques widen coverage cheaply. Table-driven tests run the same assertion over a list of inputs and expected outputs, so adding a case is one line. Property-style tests generate many inputs and assert something that must always be true, such as "the output is never longer than the input" or "running twice equals running once".
@test "level_rank maps names to ranks" {
source "$SCRIPT"
local row name want got
while read -r name want; do
got=$(level_rank "$name")
[ "$got" = "$want" ] || { echo "level_rank $name: want $want got $got" >&3; return 1; }
done <<'EOF'
debug 0
INFO 1
Warn 2
ERROR 3
EOF
}
@test "filtering twice gives the same result as filtering once" {
for n in $(seq 1 25); do
shuf -n 20 -r -e DEBUG INFO WARN ERROR | awk '{print "t " $1 " msg"}' > "$BATS_TEST_TMPDIR/in"
once=$("$SCRIPT" WARN "$BATS_TEST_TMPDIR/in")
twice=$(printf '%s\n' "$once" | "$SCRIPT" WARN)
[ "$once" = "$twice" ] || { cat "$BATS_TEST_TMPDIR/in" >&3; return 1; }
done
}The second test generates random logs and checks that the filter is idempotent. When it fails, the failing input is printed to the report so you can turn it into a fixed regression test. Seed the generator or print the seed, so a failure can be reproduced. A handful of properties, chosen well, find edge cases that nobody thought to write down, such as an empty input or a line without a level field.
Flaky tests and how to remove them
A test that fails one run in fifty is worse than no test, because it trains the team to rerun. The causes in shell suites are few. Timing: a test that sleeps and then checks, which fails on a busy CI runner. Shared state: two tests writing the same path, or a test that depends on another's leftovers. Ordering: output from parallel jobs interleaved. The environment: a locale, a time zone or a username that differs. Network calls to a real service. Fix each at the source, not with retries.
wait_for() { # wait_for SECONDS COMMAND...
local deadline=$(( SECONDS + $1 )); shift
until "$@"; do
(( SECONDS < deadline )) || return 1
sleep 0.1
done
}
@test "service writes its pid file" {
./service.sh &
wait_for 5 test -s "$BATS_TEST_TMPDIR/service.pid"
}Waiting for a condition with a deadline replaces a fixed sleep: it returns as soon as the condition holds and fails clearly if it never does. Run a suspect test many times to measure its flakiness, for i in $(seq 100); do bats --filter name test/x.bats || break; done, and fix it before adding more tests to a suite you cannot trust. Quarantine, with a tag and an issue, is better than deleting a flaky test, because the failure it shows might be a real race in the script.
Adopting tests in a team
The technical part is easy next to the social part. A team with fifty scripts and no tests will not write fifty suites. Start where the cost of a bug is highest: the deploy script, the backup, the installer, anything that runs unattended with privilege. Add ShellCheck to CI for all of them in report mode, then make it blocking for new and changed files only, so the existing backlog does not stall work. Write a characterization test for each script the next time it is touched, and require a test with every bug fix. Provide a template Bats file and a Makefile target, so the first test costs minutes.
Show the benefit early. The first time a test catches a regression in review, say so. Keep the suite fast, so that it runs on every push, and keep the failure output readable, so that a new contributor can fix it without asking. Over a few months the shape changes from "scripts nobody dares edit" to scripts with a safety net, and the habits described in the Bash scripting tutorial and the security guide become checkable instead of aspirational. The goal is not a number for coverage but the confidence to change a script on a Friday afternoon.
Where this does not apply
One-off commands. A script you run once, on your own data, and delete, does not need a suite. ShellCheck is still worth running, since it takes seconds.
Scripts that are mostly glue to a real system. Where the work is calling a cloud API, mocks test your assumptions about the API and not the API. Add a small number of integration tests against a real or staging system, run less often, and keep the unit tests for the logic around the calls.
Large logic. If you are writing a mock framework for the script, the script has probably outgrown shell. Tests in the language the logic should have been written in are easier to write and maintain.
Interactive and terminal behavior. Prompts, colors and full-screen programs need a pseudo-terminal harness such as expect or script, and tests of them are brittle. Separate the logic from the interaction and test the logic.
Destructive scripts on shared systems. Never point a test at real data. Use a container or a temporary directory, and make the script take its target as an argument so the test chooses the sandbox.
What to read next
Tests tell you that a script is wrong; finding out why is the work in the guide to debugging shell scripts. The inputs worth testing come from the shell script security guide, and the scripts themselves are described in the Bash scripting tutorial. To schedule a tested script and watch it run, see the cron and systemd timers guide.
Primary sources used here: the bats-core documentation, the bats-core guide to writing tests, the bats-core repository, the bats-assert repository, the ShellCheck wiki, the ShellCheck repository, the TAP specification, the Bash Reference Manual, the shfmt repository and the GitHub Actions workflow documentation.


