Testing Bash: bats, ShellCheck & CI gates
Unit-testing scripts with bats, mocking, and ShellCheck as a merge gate.
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.
#!/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 1printf '%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}" ]]; thenmain "$@"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.
#!/usr/bin/env batssetup() {# 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_CONFIGrun config_path[ "$status" -eq 0 ][ "$output" = "/etc/app/config.yaml" ]}
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.
#!/usr/bin/env bashcurrent_sha() {git rev-parse --short HEAD}release_tag() {local shasha=$(current_sha) || return 1printf 'release-%s' "$sha"}
#!/usr/bin/env batssetup() { source "${BATS_TEST_DIRNAME}/../lib/deploy.sh"; }@test "release_tag wraps the short SHA git reports" {current_sha() { printf 'deadbee'; } # stub: no real git callrun 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 reporun release_tag[ "$status" -eq 1 ]}
deploy.bats✓ release_tag wraps the short SHA git reports✓ release_tag fails when git cannot resolve HEAD2 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.
#!/usr/bin/env bashtarget=$1rm -rf $target/buildecho "cleaned $target"
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.
# We want word splitting here: $CURL_OPTS holds several separate flags.# shellcheck disable=SC2086curl $CURL_OPTS "$url"
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.
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: Install batsrun: sudo apt-get update && sudo apt-get install -y bats- name: Run testsrun: 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.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@"; fi. What does that guard accomplish?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?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.