Testing and linting shell scripts
ShellCheck and fixing what it reports, shfmt formatting, bats for unit tests, testing with fixtures and temporary directories, and wiring checks into CI.
ShellCheck and shfmt
shellcheck deploy.sh # every finding, with a wiki link
shellcheck -S warning bin/*.sh # fail only on warning and above
shellcheck -x -s bash lib/*.sh # follow sourced files, force a dialect
shellcheck -f gcc -e SC1091 script.sh # CI-friendly output, one rule disabled
shfmt -d -i 2 -ci script.sh # show a diff of the formatting
shfmt -w -i 2 -ci script.sh # rewrite in place
# .shellcheckrc in the repository root
# shell=bash
# disable=SC1091 # not following a source we know about
# external-sources=true| Finding | What it usually means |
|---|---|
| SC2086 | An unquoted expansion: add double quotes |
| SC2046 | An unquoted command substitution being word-split |
| SC2002 | cat file | cmd where cmd < file is correct |
| SC2181 | Checking $? instead of testing the command directly |
| SC2155 | Declaring and assigning in one step hides the exit status |
| SC1091 | A sourced file is not visible to the linter |
Treat every suppression as a comment that needs a reason. A file with a dozen unexplained disables is linted in name only; a file with two, each carrying a note, is genuinely checked.
Unit tests with bats
#!/usr/bin/env bats
setup() {
tmp=$(mktemp -d)
export PATH="$BATS_TEST_DIRNAME/../bin:$PATH"
export APP_HOME=$tmp
}
teardown() {
rm -rf "$tmp"
}
@test "prints usage and exits 2 without arguments" {
run deploy
[ "$status" -eq 2 ]
[[ "$output" == *"usage:"* ]]
}
@test "refuses an unknown environment" {
run deploy --env mars
[ "$status" -ne 0 ]
[[ "$output" == *"unknown environment"* ]]
}
@test "creates the output file once" {
run deploy --env staging --out "$APP_HOME/out.txt"
[ -f "$APP_HOME/out.txt" ]
deploy --env staging --out "$APP_HOME/out.txt" # must stay idempotent
[ "$(grep -c staging "$APP_HOME/out.txt")" -eq 1 ]
}runcaptures stdout, stderr and the exit status into$outputand$status, so one assertion cannot break the next.setupandteardownrun around every test; that is what keeps tests independent.load test_helpershares fixtures across files, andbats -f 'creates the output file'runs a single test while you work.
Fixtures and CI
# fixtures: real-looking inputs under version control
test/fixtures/invoices/2026-08.csv
test/fixtures/config/minimal.ini
# never write into the checkout; use a temp directory per run
workdir=$(mktemp -d)
trap 'rm -rf "$workdir"' EXIT
# a pre-commit hook that is fast enough to actually leave enabled
shellcheck -S warning bin/*.sh || exit 1
bash -n bin/*.sh || exit 1
bats --print-output-on-failure test/# .github/workflows/shell.yml
name: shell
on: [push, pull_request]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint
run: |
sudo apt-get update && sudo apt-get install -y shellcheck
shellcheck -S warning -x bin/*.sh lib/*.sh
- name: Test
run: |
sudo apt-get install -y bats
bats --print-output-on-failure test/💡
The cheapest reliability win in a shell codebase is
bash -n plus shellcheck in CI. Neither replaces tests, but together they catch the majority of breakages — quoting, typos and unreachable branches — without executing anything.FAQ
Is ShellCheck always right?
Almost always worth reading; occasionally it cannot see through indirection, which is what a justified disable with a comment is for. Never disable a rule globally to silence one line.
How do I test a script that deletes things?
Point it at a temporary directory with fixtures and assert on the resulting contents. Expose the base path as a variable or flag so the test controls where the destructive work happens.
Related
A practical automation project Portability and POSIX shell
Last refreshed 2026-09-18.