Verdict
Yes
Yes, give each shell script that CI, cron or a teammate runs one bats-core test that runs the whole script in a temporary folder with fake versions of the commands that reach other systems, and run ShellCheck on every script in CI.
Why
- Blast radiusinternal
- Change frequencyrarely
- Detectabilityeventually
- Reversibilitywith-effort
- Test costmoderate
Yes, test shell scripts minimally: one bats-core test per script, plus ShellCheck in CI. The typical case is a bash script that CI, cron or a teammate runs to build, deploy or delete old files, so Blast radius is internal, because a failure stalls the team and no customer sees it, and Change frequency is rarely. Detectability is eventually, because bash carries on after a failed command unless the script sets set -e, and ignores a failure early in a pipeline without pipefail, so a half-done run often exits 0. Reversibility is with-effort, because half an artefact or a deleted folder needs a rerun and a repair. Test cost is moderate, because the script must take its paths as variables and the test needs fake commands on PATH, so rule R12 gives Test minimally.
| When | Decision | Why |
|---|---|---|
| The script deletes files on customers' machines, such as an installer or an updater | Test: run the script with each path variable empty and assert that it deletes nothing | Blast radius rises to users and Reversibility to impossible, because deleted customer files have no backup; Detectability falls to same-day, because customers report the loss |
| The script is a CI step under set -euo pipefail that only builds code, and the next step fails without its output | Do not test the script; keep ShellCheck and set -euo pipefail | Detectability falls to immediately and Reversibility to trivial, because a failure turns the build red and the script writes only to the CI workspace |
| A one-off script moves customer uploads on the production server to a new folder layout | Test it differently: run it on a copy of the uploads, compare file counts and checksums, and keep a backup | Change frequency falls to once and Blast radius rises to users, because the script runs one time against files customers open |
| The script has grown into subcommands for the team's environments that change in most weeks | Test: one bats-core test per subcommand, plus a regression test for every bug | Change frequency rises to constantly, because each edit can break a subcommand nobody runs that week |
| A cron script calls a cloud CLI with the server's credentials, and the failure is an expired permission CI cannot reproduce | Test it differently: the script pings a heartbeat monitor after each successful run, which alerts on a missing ping | Test cost rises to heavy, because CI has no copy of the server's credentials, while Detectability stays eventually and Reversibility with-effort |
What breaks if you don't test
A variable left empty because a folder moved turns rm -rf "$DIR/"* into a delete from the root folder. A backup step whose tar fails inside tar | gzip still uploads a truncated archive, because the pipeline returns the status of gzip, and the job exits 0. Nobody notices until someone needs a restore.
What you lose if you over-test
A test that fakes every command and asserts its exact arguments breaks when you reorder two cp lines and passes when files land in the wrong place. Tests that call the real aws or ssh need live credentials in CI. A script that needs dozens of tests is cheaper to test after a rewrite in Python, where functions take arguments and return values.
How to test
- Run ShellCheck on every
.shfile in CI. It flags an unguardedrm -rf "$VAR/"*as SC2115. - Start each bash script with
set -euo pipefailand write every deleted path as${VAR:?}, which stops the script when the variable is empty. - Write one bats-core test per script: point its paths at
$BATS_TEST_TMPDIR, put fakeawsorsshscripts first onPATH, and assert the exit status and the files left. - Add a regression test for each bug that reaches a server.
When the answer changes
- The script deletes files on machines other people own.
- The script runs one time against production files.
- The failures come from credentials or tools that CI does not have.
Real incident + Code example
The Steam update that deleted home folders
In January 2015 a Steam for Linux user reported that after he moved Steam's folder, starting Steam deleted every file his account owned, including a 3 TB backup drive under /media. The Register traced it to steam.sh, which ran rm -rf "$STEAMROOT/"* under the comment # Scary!. When STEAMROOT came out empty, the delete started at the root folder. ShellCheck now uses that line as its SC2115 example. Steam's case is the first row of the table, and the second test below fails on the Steam line:
#!/usr/bin/env bats
# clean.sh runs: rm -rf "${APP_ROOT:?}/"*
@test "empties the app folder" {
mkdir "$BATS_TEST_TMPDIR/app" && touch "$BATS_TEST_TMPDIR/app/cache.bin"
run env APP_ROOT="$BATS_TEST_TMPDIR/app" "$BATS_TEST_DIRNAME/clean.sh"
[ "$status" -eq 0 ]
[ ! -e "$BATS_TEST_TMPDIR/app/cache.bin" ]
}
@test "deletes nothing when APP_ROOT is empty" {
# A fake rm first on PATH logs its arguments, so a missing guard deletes nothing here.
mkdir "$BATS_TEST_TMPDIR/bin"
printf '#!/bin/sh\necho "$*" >> "$RM_LOG"\n' > "$BATS_TEST_TMPDIR/bin/rm"
chmod +x "$BATS_TEST_TMPDIR/bin/rm"
run env RM_LOG="$BATS_TEST_TMPDIR/rm.log" PATH="$BATS_TEST_TMPDIR/bin:$PATH" \
APP_ROOT="" "$BATS_TEST_DIRNAME/clean.sh"
[ "$status" -ne 0 ]
[ ! -e "$BATS_TEST_TMPDIR/rm.log" ]
}
Related questions
FAQ
- Should you unit test bash scripts?
Yes, give each bash script that CI or other people run one bats-core test that runs the whole script in a temporary folder. For most scripts the whole run is the unit.
- How do I test a bash script?
Test a bash script with bats-core: point its paths at the test's temporary folder, put fake versions of
awsorsshfirst onPATH, and assert the exit status and the files it leaves.- Is ShellCheck enough to test shell scripts?
No, ShellCheck finds unsafe code such as an unguarded
rm -rf "$VAR/"*, but it cannot tell whether a script copies the right files. Keep ShellCheck and add one bats-core test of a full run.- Should I rewrite a shell script in Python so I can test it?
Rewrite a shell script in Python when it parses data or branches on many options; keep bash when it mostly calls other programs in order. A Python function returns values, so its test needs no fake commands on
PATH.