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
FindingWhat it usually means
SC2086An unquoted expansion: add double quotes
SC2046An unquoted command substitution being word-split
SC2002cat file | cmd where cmd < file is correct
SC2181Checking $? instead of testing the command directly
SC2155Declaring and assigning in one step hides the exit status
SC1091A 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 ]
}
  • run captures stdout, stderr and the exit status into $output and $status, so one assertion cannot break the next.
  • setup and teardown run around every test; that is what keeps tests independent.
  • load test_helper shares fixtures across files, and bats -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.

A practical automation project Portability and POSIX shell

Last refreshed 2026-09-18.