CoursesAdvanced scripting for DevSecOpsTesting Bash: bats, ShellCheck & CI gates

Testing Bash: bats, ShellCheck & CI gates

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

Advanced30 min · lesson 6 of 15

A smoke detector costs about ten dollars and does nothing on a normal day. The one morning the toast catches fire, it earns its price a hundred times over. Tests around a shell script work the same way. Most shell bugs are small and boring: a variable nobody quoted, an exit code that reports 'fine' when the run actually failed, a missing guard that turns a command loose on empty input. They are easy to catch. They ship anyway, because the file is 'only a script,' and then they run as root (the account that can do anything on the machine) on a production box at three in the morning. A little test scaffolding stops the boring bugs before they reach a server.

Make The Script Testable First

You cannot test a script that does its whole job the instant you load it. If sourcing the file connects to a database, deletes a directory, and calls the cloud provider's API (application programming interface, the way one program asks another to do something), then a harness that only wants to read the file sets all of that in motion. Think of a recipe card: reading the steps aloud should be safe, and only actually lighting the stove should cook anything. Shell code earns that same split. Keep the thinking (parsing, validation, string work) in small functions that change nothing outside themselves, push the doing (files, network, processes) out to the edges, and put a guard on the entry point so loading the file does not run it.

Bash gives you one clean trick for that guard. Every running script knows its own name two ways, a bit like the difference between the name a person is called by and the street address where they actually live. $0 is the name the shell was invoked with. ${BASH_SOURCE[0]} is the file the current line of code physically lives in. Run ./lib/parse.sh directly and the two match. Let a test source that same file instead, and $0 becomes the name of the test runner that is actually executing while ${BASH_SOURCE[0]} stays pointed at the library, so they no longer agree. Compare the two, and one file can be both a script you can run and a library you can source.

lib/parse.sh
#!/usr/bin/env bash
# Pure functions: no filesystem, network, or process work 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"
}
# Run main only when executed directly, not when sourced by a test.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi

Unit Tests With bats

bats (short for Bash Automated Testing System) turns a shell file into a test runner. Think of it as a checklist a second person walks down: each @test block is one line on the list, and it either ticks or it does not. The helper you lean on is run. It executes a command in a subshell (a separate child shell, so nothing it does leaks back into your test) and captures two things you normally cannot inspect after the fact: the exit status, in $status, and everything the command printed, in $output. Skip run, and a command that exits non-zero can fail the whole test before you reach your own checks, which is exactly wrong when that non-zero exit is the behavior you meant to test. The first function here, extract_version, pulls a semantic version (semver, the MAJOR.MINOR.PATCH numbering like 1.4.2) out of a release tag.

test/parse.bats
#!/usr/bin/env bats
setup() {
# BATS_TEST_DIRNAME is the folder this .bats file sits in.
source "${BATS_TEST_DIRNAME}/../lib/parse.sh"
}
@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 APP_CONFIG is unset" {
unset APP_CONFIG
run config_path
[ "$status" -eq 0 ]
[ "$output" = "/etc/app/config.yaml" ]
}
~/secopslog — bash
$ bats test/parse.bats
parse.bats ✓ extract_version pulls semver from a tag ✓ extract_version rejects garbage with non-zero status ✓ config_path defaults when APP_CONFIG is unset 3 tests, 0 failures

Mocking The Parts That Touch The World

A crash-test dummy stands in for a person so engineers can slam a car into a wall without hurting anyone. A mock is that same idea for code: a stand-in for the real thing that would be slow, dangerous, or unpredictable to call for real. Your deploy script shells out to other command-line tools, git, curl, aws, kubectl. You do not want a unit test reaching the network or moving real infrastructure to check a few lines of logic. In Bash you get mocking almost for free, because the shell looks for a function named git before it looks for the git binary on your PATH (the list of directories the shell searches for commands). Define a function with that same name inside the test, and every call in the code under test hits your fake instead of the real tool.

lib/deploy.sh
#!/usr/bin/env bash
current_sha() {
git rev-parse --short HEAD
}
release_tag() {
local sha
sha=$(current_sha) || return 1
printf 'release-%s' "$sha"
}
test/deploy.bats
#!/usr/bin/env bats
setup() { source "${BATS_TEST_DIRNAME}/../lib/deploy.sh"; }
@test "release_tag wraps the short SHA git reports" {
current_sha() { printf 'deadbee'; } # stub: no real git call
run release_tag
[ "$status" -eq 0 ]
[ "$output" = "release-deadbee" ]
}
@test "release_tag fails when git cannot resolve HEAD" {
current_sha() { return 128; } # 128 is git's exit code on a bad repo
run release_tag
[ "$status" -eq 1 ]
}
output
deploy.bats
✓ release_tag wraps the short SHA git reports
✓ release_tag fails when git cannot resolve HEAD
2 tests, 0 failures

The stub (the dumbed-down stand-in, the same idea as the mock) replaces current_sha, so the test never runs git and never needs a real repository on disk. It exercises release_tag's own logic, wrap the value and pass a failure up the chain, against a known short SHA (the short commit fingerprint git prints for each commit) and a known error code. The failing test drives the path where git cannot find HEAD (git's name for the commit you currently have checked out) and returns 128. That is the whole move: fake the edge, test the middle.

When the thing you want to fake is a real binary rather than a function in your own code, say the script calls aws directly, override it by name the same way, or drop a fake executable into a directory you prepend to PATH inside setup(). Keep the stub dumb: return the one value the code needs and the exit code you are testing for, nothing more. If you also want to prove the code called the tool with the right arguments, have the stub append its arguments to a temp file, then read that file back and assert on it after the run.

ShellCheck: The Static Gate

ShellCheck is a spell-checker for shell scripts. It reads the code without running it, which is what 'static' means here, and flags constructs that look fine and behave badly. The headline one for anyone doing operations work is the unquoted variable. Write rm -rf $target/build, let $target arrive empty for any reason, and the shell expands the line to rm -rf /build. Worse, an unquoted variable holding a path with a space, or a value an attacker managed to influence, gets split into separate arguments or expanded by globbing (the shell turning characters like * into matching filenames), and suddenly runs a command you never wrote. ShellCheck catches that whole class before it ever executes, which is the kind of bug a defender wants stopped at review time, not discovered during an incident.

clean.sh
#!/usr/bin/env bash
target=$1
rm -rf $target/build
echo "cleaned $target"
~/secopslog — bash
$ shellcheck -S style clean.sh; echo "exit: $?"
In clean.sh line 4: rm -rf $target/build ^-----^ SC2086 (info): Double quote to prevent globbing and word splitting. Did you mean: rm -rf "$target"/build For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... exit: 1

Two things to read off that output. First, shellcheck exited 1: it returns non-zero the moment it finds anything, which is what lets it fail a build. Second, findings sort into four severity levels (error, warning, info, and style, loudest to quietest), and the -S (severity) flag sets the floor. -S style reports everything, which is what you want on a gate; -S error narrows it down to the code that is almost certainly broken. Every finding carries a code like SC2086 and a wiki link that spells out the reasoning, so nobody has to guess why the linter is unhappy. When a rule genuinely does not apply to one line, silence that single line on purpose with a directive and say why, rather than switching the check off for the whole file.

deploy.sh
# We want word splitting here: $CURL_OPTS holds several separate flags.
# shellcheck disable=SC2086
curl $CURL_OPTS "$url"
A script that exits 0 on failure is worse than one that crashes
Callers and continuous integration (CI, the automated checks that run on every push) branch on the exit code, not on the words a script prints. A script that logs 'ERROR: upload failed' and then returns 0 tells the pipeline everything is fine, so the pipeline goes green on a real failure and the next stage runs against a half-broken deploy. Assert the exit code in every test, on the success path and on every failure path, and make every error branch end in a non-zero return. run captures $status precisely so you can check it. The most dangerous script is the one that lies about whether it worked.

Wire Both Into CI

Run both checks on every change, and fail the build on any ShellCheck finding and any failing test. Picture a quality inspector at the end of an assembly line: nothing ships until it passes. The static gate catches the bug classes; the tests prove the behavior. This is a short job that pays for itself the first time it blocks an unquoted rm -rf $VAR from reaching main. Here it is as a GitHub Actions workflow (the config that runs your checks on GitHub's own servers), though the shape is identical on GitLab CI, Jenkins, or a local hook.

.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: Install bats
run: sudo apt-get update && sudo apt-get install -y bats
- name: Run tests
run: bats test/

Do not wait for the server to tell you. Run the same two commands before you push, or hang them off a pre-commit hook (a script git runs automatically right before it records a commit, so it can reject bad work on the spot). To prove the gate actually bites, commit a script with an unquoted variable on a branch, open a pull request (a request to merge your branch that your teammates review first), and watch the check go red and block the merge. A gate you have never seen fail is a gate you do not yet know is wired up.

The merge gate for a shell change
1Open pull request
branch carrying a script change
2ShellCheck
static bug classes; any finding fails
3bats
asserts $status and $output
4Both green?
red blocks the merge
5Merge to main
only clean, tested scripts land
Quick check
01A bats test runs deploy_release and asserts [ "$output" = "done" ], and it passes. Why is that assertion, on its own, an unsafe gate for a deploy script?
Correct — output and exit code are independent, and CI branches on the exit code, so you must assert $status as well.
Incorrect — run captures both on every invocation; that is the entire point of the helper.
Incorrect — you mock the side-effecting commands; the flaw here is the missing exit-code check, not a real deploy.
Incorrect — [ "$output" = "done" ] is a valid POSIX test and standard in bats.
02The library file ends with if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@"; fi. What does that guard accomplish?
Correct — the guard lets one file be both a runnable script and a sourceable library, so a test can load its functions without triggering the script's real work.
Incorrect — the comparison is between two path variables, not a check of the user or privileges.
Incorrect — ${BASH_SOURCE[0]} and $0 are just names and paths, not hashes, so nothing about integrity is verified.
Incorrect — the guard does not force sourcing; it simply skips main when the file happens to be sourced.
03Your deploy function calls aws s3 cp .... In a bats test you want to prove it passed the correct bucket path, without touching real AWS (Amazon Web Services). Which approach matches the lesson?
Incorrect — the lesson shows exactly how to capture arguments, so this is the misconception it corrects.
Incorrect — that still invokes the real tool; the lesson's move is to fake the edge, not to call AWS at all.
Correct — a shell function shadows the real binary, and recording its arguments lets you assert the call was made correctly with no network.
Incorrect — that guarantees the real aws runs, which is the network call you were trying to avoid.

Start with the two functions in your worst script that do pure string work, source them into a parse.bats, and assert on both $output and $status. That one file, plus shellcheck -S style in the pipeline, catches more real production incidents than any amount of re-reading the code by eye.

Try this

Work through “Wire Both Into CI” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a script that exits 0 on failure is worse than one that crashes. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related