Lesson 07 of 8 · Modules
Testing & linting
Catch Bash bugs before they reach a server: shellcheck for static analysis, shfmt for consistent formatting, bats for tests, and a small CI pipeline that runs them on every change.
Bash deserves tests too
Scripts that manage production are production code. A missing quote can delete the wrong directory; a typo in a rarely used branch waits months to fail. Three tools cover most of the risk:
| Tool | Catches |
|---|---|
| shellcheck | Bugs and bad practices, statically, without running anything |
| shfmt | Inconsistent formatting (and some syntax errors) |
| bats | Behaviour: does the function return what it should? |
Before a school trip, a teacher checks the list (shellcheck: is anything obviously missing?), makes sure everyone is lined up neatly (shfmt), and does a practice run of the bus route (bats tests). None of it is the trip itself, but it stops most surprises.
shellcheck: the Bash linter
$ cat cleanup.sh
#!/usr/bin/env bash
dir=$1
rm -rf $dir/*
for f in $(ls $dir); do echo $f; done
$ shellcheck cleanup.sh
In cleanup.sh line 3:
rm -rf $dir/*
^--^ SC2115 (warning): Use "${var:?}" to ensure this never expands to /* .
^--^ SC2086 (info): Double quote to prevent globbing and word splitting.
In cleanup.sh line 4:
for f in $(ls $dir); do echo $f; done
^-------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
(Output abridged.) Every finding links to a wiki page explaining the problem and the fix. When a warning is intentional, disable it on that line, with a reason:
# Word splitting is intended: $EXTRA_OPTS holds several flags.
# shellcheck disable=SC2086
exec myapp $EXTRA_OPTS
shfmt: one style for everyone
$ shfmt -d -i 2 cleanup.sh # show the diff
$ shfmt -w -i 2 scripts/ # rewrite in place
Consistent formatting makes reviews about logic, not whitespace.
bats: tests for Bash
Structure the script so its functions can be loaded without running it:
#!/usr/bin/env bash
# lib/checks.sh
set -euo pipefail
is_port() { # is_port 8080 → status 0; is_port abc → status 1
[[ "$1" =~ ^[0-9]+$ ]] && (( $1 >= 1 && $1 <= 65535 ))
}
main() {
is_port "${1:-}" || { echo "invalid port: ${1:-}" >&2; exit 2; }
echo "port $1 ok"
}
# run main only when executed, not when sourced by tests
[[ "${BASH_SOURCE[0]}" == "$0" ]] && main "$@"
#!/usr/bin/env bats
# tests/checks.bats
setup() {
source "$BATS_TEST_DIRNAME/../lib/checks.sh"
}
@test "accepts a valid port" {
run is_port 8080
[ "$status" -eq 0 ]
}
@test "rejects text" {
run is_port abc
[ "$status" -ne 0 ]
}
@test "rejects 70000" {
run is_port 70000
[ "$status" -ne 0 ]
}
@test "main prints a friendly message" {
run bash "$BATS_TEST_DIRNAME/../lib/checks.sh" 443
[ "$status" -eq 0 ]
[[ "$output" == *"port 443 ok"* ]]
}
$ bats tests/
checks.bats
✓ accepts a valid port
✓ rejects text
✓ rejects 70000
✓ main prints a friendly message
4 tests, 0 failures
Run it all in CI
A minimal GitHub Actions workflow (GitHub's Ubuntu runners include shellcheck; bats is installed from the Ubuntu packages):
name: scripts
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: shellcheck -x scripts/*.sh lib/*.sh
- run: sudo apt-get update && sudo apt-get install -y bats
- run: bats tests/
Now every pull request is linted and tested before anyone reviews it (see CI/CD & Software Supply Chain).
Try it: lint, fix, test
- Install
shellcheck,shfmtandbats. - Run shellcheck on the fragile script from lesson 05 (before you fixed it). Fix every finding.
- Take one function from your lesson 02 or 03 scripts, move it into a
lib/file with theBASH_SOURCEguard, and write three bats tests for it (a success, a failure and an edge case). - Add a
Makefiletargetmake checkthat runs shellcheck,shfmt -dand bats.
Going deeper: keeping scripts healthy
- Add shellcheck to your editor (VS Code has an extension). Feedback while typing beats feedback in CI.
- Use pre-commit hooks to run shellcheck and shfmt before every commit.
- Test destructive scripts against a temporary directory (
BATS_TEST_TMPDIR) or a container, never against real paths. - If tests need lots of mocking (fake
kubectl, fakecurl), that's a signal the tool may be better in Python, where mocking is easier.
Recap
- shellcheck finds real bugs statically. Fix findings, or disable per line with a reason.
- shfmt keeps formatting consistent.
- bats tests behaviour; guard
mainwith[[ "${BASH_SOURCE[0]}" == "$0" ]]so files can be sourced in tests. - Run all three in CI on every change.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.