Bash Scripting for Engineers›07 · Testing & linting

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.

Practitioner
Key wordsshellcheckshfmtbatsunit testsCIsource guard

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

  1. Install shellcheck, shfmt and bats.
  2. Run shellcheck on the fragile script from lesson 05 (before you fixed it). Fix every finding.
  3. Take one function from your lesson 02 or 03 scripts, move it into a lib/ file with the BASH_SOURCE guard, and write three bats tests for it (a success, a failure and an edge case).
  4. Add a Makefile target make check that runs shellcheck, shfmt -d and 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, fake curl), 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 main with [[ "${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.