CoursesAdvanced scripting for DevSecOpsBash for secure, resilient automation

Testing Bash: bats, ShellCheck & CI gates

Unit-testing scripts with bats, mocking, and ShellCheck as a merge gate.

Advanced30 min · lesson 6 of 15

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.

test/parse.bats
#!/usr/bin/env bats
setup() { 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_CONFIG
run 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 + guarded main = sourceable and runnable
# lib/parse.sh — pure functions, no side effects at load time
extract_version() {
local s=$1
[[ $s =~ ([0-9]+\.[0-9]+\.[0-9]+) ]] || return 1
printf '%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 test
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
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.

.github/workflows/shell.yml
name: shell
on: [push, pull_request]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ShellCheck (fail on any finding)
run: shellcheck -S style scripts/*.sh lib/*.sh
- name: bats
run: |
sudo apt-get install -y bats
bats test/
The shell quality gate
1shfmt
consistent formatting
2ShellCheck
static bug + security classes
3bats unit tests
behaviour + exit codes
4merge
only if all three pass
Formatting, static analysis, and behavioural tests are three cheap gates that together catch the vast majority of shell defects.
Untested exit codes are a silent failure
Callers and CI branch on your script’s exit code, so a script that prints an error but still exits 0 will be treated as success — the pipeline goes green on a real failure. Assert exit codes explicitly in tests (both the success and every failure path), and make sure every error path ends in a non-zero return. The most dangerous script is the one that lies about whether it worked.