Make sure BASH scripts are safe before production use: static analysis with ShellCheck to catch common bugs like SC2086 and SC2164, then automated testing with Bats-core. Includes practice writing tests for utility functions and the pitfalls of shell testing.

In episode 24 we covered logging, colorizing output, and terminal UX — how to make scripts tidy, recorded, and comfortable to use — and in this episode we discuss what's often considered "no time for it" but is actually the most decisive: guaranteeing script quality before it's really used.
Here's the irony of the BASH scripting world: no other language is used as much as the shell, yet so few scripts are tested. You wouldn't ship a Python app without unit tests, but many people deploy BASH scripts that have never been verified beyond "yeah, it worked yesterday". Yet BASH scripts are prone to invisible errors: mistyped variables, missing quotes, directories that don't exist, unquoted paths. This kind of error usually only explodes at midnight, on a production server, when nobody's watching.
Two tools will be your safeguards. ShellCheck is a static analyzer: it reads your script without running it and points out hundreds of common bug patterns — like a copy editor flagging ambiguous sentences before they're printed. Bats-core is a testing framework: you write expectations ("this function must return 0 when run as root"), and Bats runs them for you every time the script changes — like a safety net that catches regressions.
The analogy: ShellCheck is the spell checker that works while you write, while Bats is the exam that makes sure behavior stays correct after you change something. Combining the two changes your BASH script from "fragile code" into "trustworthy code" — and that trust is the primary requirement before a script goes to production.
In this episode we'll dissect ShellCheck and its rules, then Bats-core and its test structure, practice them on a real utility script, and close with the pitfalls most often encountered when testing shell code.
ShellCheck is a linter for shell scripts that catches hundreds of bug classes before the script executes. It doesn't run the script — it analyzes writing patterns and compares them with known bash behavior. Installation on Debian/Ubuntu:
apt install shellcheck # Debian/Ubuntu
shellcheck deploy.sh # jalankan pada skrip kalianEach finding has a unique code (e.g. SC2086) and four severity levels: error (very likely wrong), warning (likely wrong), info (not sure, check), and style (cosmetic improvement). The higher the severity, the more likely it's a real bug.
Some codes that appear most often and most often break production:
| Code | Meaning | Example |
|---|---|---|
SC2086 | Unquoted variable (word splitting) | rm $file → rm "$file" |
SC2164 | cd without failure check | cd "$dir" → `cd "$dir" |
SC2034 | Variable declared but unused | variable name typo |
SC2001 | `echo ... | sed` that could be parameter expansion |
SC2162 | read without -r (backslash interpreted) | read line → read -r line |
See how SC2086 works on a real example — the most classic bug in the shell world:
rm -rf $BACKUP_DIR/*.tmp
rm -rf "$BACKUP_DIR"/*.tmpWithout quotes, if $BACKUP_DIR contains a space (for example /var/backup harian), the command splits into two arguments — and rm -rf on a wrong path is the accident admins fear most. ShellCheck flags it in an instant, without waiting for the script to execute with real data.
To see what ShellCheck findings look like in practice, run it on a deliberately badly-written script — notice how one command produces several findings at once:
cat > demo.sh <<'EOF'
#!/usr/bin/env bash
cd /tmp
echo $1
for i in $(ls); do
echo "file: $i"
done
EOF
shellcheck demo.shIn demo.sh line 2:
cd /tmp
^-- SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
In demo.sh line 3:
echo $1
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
In demo.sh line 4:
for i in $(ls);
^-- SC2045 (error): Iterating over ls output is fragile.Notice three different findings at three different levels: SC2045 as an error (iterating ls output is dangerous), SC2164 as a warning (a failed cd can make the script continue in the wrong directory), and SC2086 as info (missing quotes). With one command, ShellCheck found three bugs, each of which could blow up in production.
Sometimes you know better — for example a variable intentionally left unused, or behavior deliberately intended for a certain environment. For those cases, ShellCheck provides exceptions via a disable comment:
# shellcheck disable=SC2034
readonly DEFAULT_REGION="ap-southeast-1"Note
Exceptions are technical debt. Every # shellcheck disable=SC... should come with a short comment why — for example # SC2034: dipakai oleh library yang di-source saat runtime. A pile of reason-less disables is as dangerous as turning off every fire alarm: it seems easier, but it removes the protection.
ShellCheck's real power shows when it's integrated into your workflow. In the editor, the VS Code extension or a vim plugin shows findings live as you type. In CI/CD, just add one pipeline step so a pull request containing bugs never gets merged:
name: Lint shell scripts
on: [push, pull_request]
jobs:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: apt-get update && apt-get install -y shellcheck
- run: shellcheck scripts/*.shWith this configuration, every time someone pushes a script with a bug, the pipeline fails before the script ever gets a chance to run a dangerous command.
ShellCheck ensures syntax and patterns are safe, but it can't ensure behavior is correct. For that we need automated testing — and for the shell, the de facto standard is Bats-core (Bash Automated Testing System).
Why a framework? BASH has no built-in test mechanism, and writing manual tests with chained ifs is a recipe for chaos. Bats gives you three things: the @test structure, helper functions run/$status/$output, and tidy result reporting. Installation:
apt install bats # Debian/Ubuntu (bats-core)
git clone https://github.com/bats-core/bats-core /opt/bats
/opt/bats/bin/bats --versionA .bats file structure is very declarative. Each @test declares a name, then contains three core elements: run <command> to run something and capture its result, $status for the exit code, and $output for the produced text:
#!/usr/bin/env bats
@test "perintah echo mengembalikan status 0" {
run echo "halo dunia"
[ "$status" -eq 0 ]
[ "$output" = "halo dunia" ]
}
@test "grep menemukan pola yang ada" {
run grep "error" log-sample.txt
[ "$status" -eq 0 ]
[ "$output" != "" ]
}Notice the repeated pattern: run always comes before the assertions. Bats captures stdout and stderr into $output, and the exit code into $status. To match part of the text, use globbing:
@test "pesan error mengandung kata kunci" {
run myscript.sh --invalid-flag
[ "$status" -ne 0 ]
[[ "$output" == *"unknown option"* ]]
}The [[ "$output" == *"..."* ]] style tests whether the output contains a substring — far more flexible than full equality.
To share helper functions between test files, use load. Create a helpers.bash file containing functions that set up the environment, then load it in every test file:
load helpers
@test "skrip tanpa argumen menampilkan usage" {
run myscript.sh
[[ "$output" == *"Usage:"* ]]
}Bats also supports setup() and teardown() — functions run before and after each test, useful for creating temporary files or cleaning up artifacts.
Let's apply it to a real scenario. Suppose we have lib/utils.sh with two functions: is_root (checks whether run as root) and is_number (checks whether the argument is a number). Both are perfect candidates for testing:
is_root() {
[ "$(id -u)" -eq 0 ]
}
is_number() {
case "$1" in
''|*[!0-9]*) return 1 ;;
*) return 0 ;;
esac
}Now write the tests in utils.bats. We need to source those functions — since they aren't executables, we load them with source inside each test:
#!/usr/bin/env bats
load helpers
setup() {
source "$BATS_TEST_DIRNAME/../lib/utils.sh"
}
@test "is_number menerima angka" {
run is_number "2026"
[ "$status" -eq 0 ]
}
@test "is_number menolak teks campuran" {
run is_number "12abc"
[ "$status" -ne 0 ]
}
@test "is_number menolak string kosong" {
run is_number ""
[ "$status" -ne 0 ]
}
@test "is_root mengembalikan status sesuai id -u" {
run is_root
if [ "$(id -u)" -eq 0 ]; then
[ "$status" -eq 0 ]
else
[ "$status" -ne 0 ]
fi
}Run it with the bats command:
bats test/utils.bats ✓ is_number menerima angka
✓ is_number menolak teks campuran
✓ is_number menolak string kosong
✓ is_root mengembalikan status sesuai id -u
4 tests, 0 failuresNotice how quickly this testing builds confidence: every time you change is_number (for example to support negative numbers), one bats command immediately tells you if old behavior broke. That's the safety net preventing regressions from reaching production.
Tip
One test, one behavior. Split every behavior into its own @test, rather than combining many assertions into a single test. When a suite fails, a clear test name immediately points to the broken behavior — saving precious debugging time compared to reading a hundred lines of output.
Like ShellCheck, Bats is also most valuable when installed in a pipeline. In GitHub Actions, one step runs the whole suite every time code changes:
- name: Run Bats tests
run: |
apt-get install -y bats
bats test/Now every pull request that changes the is_number function must prove that all its old behaviors are still intact — regressions are caught in minutes, not weeks later in production. That's what automated testing means: a machine reminding you about the things you forgot.
1. ShellCheck reports "false positives". Sometimes ShellCheck genuinely misunderstands context — for example a variable filled by a sourced library, or an intentional expansion pattern. The solution isn't silencing the whole file, but a per-line disable with a reason comment (see earlier). Check first whether the finding is correct, then disable it.
2. run doesn't capture output correctly. The most common mistake: testing $output without calling run first. $output is only populated after run. Remember the standard order: run command → check $status → check $output. Testing an unpopulated variable produces assertions that always fail (or worse, always pass) — misleading results.
3. Testing interactive scripts. Scripts that call read or select hang inside Bats, because there's no terminal responding. The solution isn't forcing the test, but refactoring: split the logic into pure functions that accept input as parameters, and keep the interactive part in a thin layer. That's the same pattern as dependency injection in programming languages — and it makes your scripts easier to test and easier to read.
4. Forgetting chmod +x or not source-ing. A test calling a function with run directly from a non-executable .sh file will fail mysteriously. Make sure functions are sourced in setup(), and that script files being run have execution permission.
5. Tests that "always pass". [ "$status" -eq 0 ] without ever calling run is a time bomb: $status is empty, the -eq comparison on an empty string errors, and Bats reports the test failed — or worse, a wrong [[ ]] passes anyway. Always verify that your test can fail: run it once with deliberately wrong input and make sure the suite truly falls.
In this episode 25, you've added a professional quality layer to your BASH scripts. With ShellCheck, we catch static bugs from the start: installing the linter, reading findings with codes like SC2086 (unquoted variable), SC2164 (cd without a check), and SC2034 (unused variable), disabling false findings with a reason-annotated # shellcheck disable=..., and integrating it into the editor and CI pipeline. With Bats-core, we built automated testing: the .bats file structure with @test, the run → $status → $output pattern, substring assertions with [[ == *...* ]], load for helpers, up to setup()/teardown(). We practiced by testing the is_root and is_number functions, and closed with the pitfalls that most often make shell testing misleading.
The key takeaways:
run → check $status → check $output; don't test unpopulated variables.You now have the complete toolkit: solid syntax, control flow, API integration, logging, and quality assurance. Only one episode remains. In episode 26 — the closing episode of this series — we'll assemble everything into practical production-grade scenarios: a complete automatic backup & retention script and a health check & auto-healing script, plus a recap of your entire journey from episode 0 to 26. See you there!