Learn BASH Scripting - Static Analysis & Automated Testing (ShellCheck & Bats)
Episode 25 of 27

Learn BASH Scripting - Static Analysis & Automated Testing (ShellCheck & Bats)

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.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

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.

Main Discussion

ShellCheck: A Tireless Static Checker

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:

Instal dan jalankan ShellCheck
apt install shellcheck        # Debian/Ubuntu
shellcheck deploy.sh          # jalankan pada skrip kalian

Each 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:

CodeMeaningExample
SC2086Unquoted variable (word splitting)rm $filerm "$file"
SC2164cd without failure checkcd "$dir" → `cd "$dir"
SC2034Variable declared but unusedvariable name typo
SC2001`echo ...sed` that could be parameter expansion
SC2162read without -r (backslash interpreted)read lineread -r line

See how SC2086 works on a real example — the most classic bug in the shell world:

SC2086: variabel tanpa tanda kutip
rm -rf $BACKUP_DIR/*.tmp
rm -rf "$BACKUP_DIR"/*.tmp

Without 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:

Membuat skrip contoh dengan beberapa bug
cat > demo.sh <<'EOF'
#!/usr/bin/env bash
cd /tmp
echo $1
for i in $(ls); do
    echo "file: $i"
done
EOF
shellcheck demo.sh
Contoh output ShellCheck dengan tingkatan keparahan
In 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.
Setiap temuan dilabeli level error/warning/info/style

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:

Nonaktifkan peringatan spesifik dengan alasan
# 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:

LinuxContoh langkah CI dengan ShellCheck
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/*.sh

With 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.

Bats-core: A Testing Framework for Shell

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:

Instal Bats-core
apt install bats            # Debian/Ubuntu (bats-core)
git clone https://github.com/bats-core/bats-core /opt/bats
/opt/bats/bin/bats --version

A .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:

Struktur dasar file .bats
#!/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:

Asersi substring dan kombinasi
@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:

Memuat helper ke dalam test
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.

Practice: Writing Tests for Utility Functions

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:

/opt/scripts/lib/utils.sh
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:

/opt/scripts/test/utils.bats
#!/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:

Menjalankan seluruh suite test
bats test/utils.bats
Output Bats yang menunjuk test gagal
 ✓ is_number menerima angka
 ✓ is_number menolak teks campuran
 ✓ is_number menolak string kosong
 ✓ is_root mengembalikan status sesuai id -u
 
 4 tests, 0 failures
Simbol ✓ untuk sukses, ✗ untuk gagal beserta detail

Notice 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:

LinuxJalankan Bats di CI setelah ShellCheck
      - 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.

Common Pitfalls of Static Analysis & Testing

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.

Conclusion

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:

  • ShellCheck catches wrong patterns; Bats catches wrong behavior — the two complement each other.
  • Never deploy a script that doesn't pass ShellCheck without a documented reason.
  • The standard Bats pattern: run → check $status → check $output; don't test unpopulated variables.
  • Refactor interactive scripts into pure functions so they're easy to test.
  • Make sure every test can fail — a test that always passes gives no information.

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!

Learn BASH Scripting - Static Analysis & Automated Testing (ShellCheck & Bats) | Learn BASH Scripting