Testing Bash: bats, ShellCheck & CI gates
Unit-testing scripts with bats, mocking, and ShellCheck as a merge gate.
Scripts run in production, so they deserve tests like any other production code. The tragedy is that most shell bugs are trivially testable — a quoting bug, a wrong exit code, a missing guard — yet they ship because "it is just a script." A small amount of testing infrastructure (bats for unit tests, ShellCheck as a gate, a habit of asserting exit codes) catches the classics before they reach a server.
Unit-testing with bats
bats-core turns Bash into a test runner: each @test block runs a snippet, and the run helper captures a command’s status and output so you can assert on them. Factor your script so the logic lives in functions you can source without executing main, then test those functions directly — pure functions (string munging, parsing, validation) are the easy wins, and they are exactly where the subtle bugs hide.
#!/usr/bin/env batssetup() { load '../lib/parse.sh'; } # source the functions under test@test "extract_version pulls semver from a tag" {run extract_version "release-v1.4.2"[ "$status" -eq 0 ][ "$output" = "1.4.2" ]}@test "extract_version rejects garbage with non-zero status" {run extract_version "not-a-version"[ "$status" -ne 0 ]}@test "config path defaults when unset" {unset APP_CONFIGrun config_path[ "$output" = "/etc/app/config.yaml" ]}
Making a script testable in the first place
The pattern that makes shell testable is the same one that makes any code testable: separate pure logic from side effects, and guard the entry point so sourcing the file does not run it. The BASH_SOURCE vs 0 check lets a file be both an executable script and a sourceable library. Push filesystem and network calls to the edges, behind functions you can stub in tests.
# lib/parse.sh — pure functions, no side effects at load timeextract_version() {local s=$1[[ $s =~ ([0-9]+\.[0-9]+\.[0-9]+) ]] || return 1printf '%s' "${BASH_REMATCH[1]}"}config_path() { printf '%s' "${APP_CONFIG:-/etc/app/config.yaml}"; }main() { extract_version "$1"; }# only run main when executed directly, not when sourced by a testif [[ "${BASH_SOURCE[0]}" == "${0}" ]]; thenmain "$@"fi
ShellCheck and bats as CI gates
The two checks belong in every pipeline: ShellCheck to catch the static bug classes and bats to prove behaviour. Fail the build on any ShellCheck finding and any failing test. This is a five-line CI job that pays for itself the first time it blocks an unquoted rm -rf $VAR from merging.
name: shellon: [push, pull_request]jobs:lint-and-test:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- name: ShellCheck (fail on any finding)run: shellcheck -S style scripts/*.sh lib/*.sh- name: batsrun: |sudo apt-get install -y batsbats test/