14 — Testing Architecture: bats, Mocking & CI Integration
bats Setup and Test Structure
bash
# ── Install bats-core ──
brew install bats-core # macOS
npm install -g bats # npm
apt install bats # Debian/Ubuntu (may be older)
# From source (latest):
git clone https://github.com/bats-core/bats-core.git
cd bats-core && ./install.sh "$HOME/.local"
# ── Optional: bats-assert and bats-support (for assertion helpers) ──
git clone https://github.com/bats-core/bats-support.git test/test_helper/bats-support
git clone https://github.com/bats-core/bats-assert.git test/test_helper/bats-assert
# ── Test file structure (.bats extension) ──
# test/math_test.bats:
#!/usr/bin/env bats
# ── Setup/teardown: run before/after EACH test ──
setup() {
# Create test fixtures
tmpdir=$(mktemp -d)
echo "3.14159" > "$tmpdir/pi"
echo "hello world" > "$tmpdir/data"
}
teardown() {
# Clean up (runs even if the test fails)
[[ -n "$tmpdir" && -d "$tmpdir" ]] && rm -rf -- "$tmpdir"
}
# ── Test: @test "description" { body } ──
@test "string length" {
str="hello"
[[ "${#str}" -eq 5 ]]
}
@test "uppercase" {
result=$(echo 'hello' | tr 'a-z' 'A-Z')
[[ "$result" == "HELLO" ]]
}
@test "file exists after setup" {
[[ -f "$tmpdir/pi" ]]
}
@test "file content is correct" {
[[ "$(cat "$tmpdir/data")" == "hello world" ]]
}
run, $status, $output: Testing Commands
bash
#!/usr/bin/env bats
# ── `run` executes a command and captures: ──
# $status — exit code (0-255)
# $output — combined stdout+stderr
# $lines — array of output lines
@test "command succeeds" {
run echo "hello"
[[ "$status" -eq 0 ]]
[[ "$output" == "hello" ]]
}
@test "command fails with specific exit code" {
run bash -c 'exit 42'
[[ "$status" -eq 42 ]]
}
@test "command outputs multiple lines" {
run printf '%s\n' "line1" "line2" "line3"
[[ "$status" -eq 0 ]]
[[ "${#lines[@]}" -eq 3 ]]
[[ "${lines[0]}" == "line1" ]]
[[ "${lines[1]}" == "line2" ]]
[[ "${lines[2]}" == "line3" ]]
}
@test "command outputs contains substring" {
run echo "error: file not found"
[[ "$status" -eq 0 ]]
[[ "$output" == *"file not found"* ]] # glob match (substring)
}
@test "command outputs matches regex" {
run echo "2024-01-15"
[[ "$output" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]
}
# ── ⚠️ `run` captures stdout AND stderr together ──
@test "stderr is captured" {
run bash -c 'echo "stdout"; echo "stderr" >&2'
[[ "$output" == *stdout* ]]
[[ "$output" == *stderr* ]] # both captured (order may vary)
}
# ── Separate stderr (bats-core 1.5+) ──
@test "separate stderr" {
run --separate-stderr bash -c 'echo "out"; echo "err" >&2'
[[ "$output" == "out" ]]
[[ "$stderr" == "err" ]]
}
Testing Script Functions (Source and Test)
bash
# ── lib/math.sh: the library under test ──
add() {
echo "$(( $1 + $2 ))"
}
is_even() {
(( $1 % 2 == 0 ))
}
factorial() {
local n=$1 result=1
for ((i = 2; i <= n; i++)); do
((result *= i))
done
echo "$result"
}
validate_email() {
[[ "$1" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]
}
# ── test/math_test.bats: tests for lib/math.sh ──
#!/usr/bin/env bats
# Source the library (functions become available in the test shell)
source "$(dirname "$BATS_TEST_FILENAME")/../lib/math.sh"
@test "add returns the sum" {
[[ "$(add 2 3)" == "5" ]]
}
@test "add with negative numbers" {
[[ "$(add -5 -3)" == "-8" ]]
}
@test "add with zero" {
[[ "$(add 0 0)" == "0" ]]
}
@test "is_even returns 0 for even numbers" {
run is_even 4
[[ "$status" -eq 0 ]]
}
@test "is_even returns non-zero for odd numbers" {
run is_even 3
[[ "$status" -ne 0 ]]
}
@test "factorial of 0 is 1" {
[[ "$(factorial 0)" == "1" ]]
}
@test "factorial of 5 is 120" {
[[ "$(factorial 5)" == "120" ]]
}
@test "validate_email accepts valid email" {
run validate_email "user@example.com"
[[ "$status" -eq 0 ]]
}
@test "validate_email rejects missing @" {
run validate_email "userexample.com"
[[ "$status" -ne 0 ]]
}
bats-assert: Readable Assertions
bash
#!/usr/bin/env bats
# Load bats-support and bats-assert (for assertion helpers)
load '../test_helper/bats-support/load.bash'
load '../test_helper/bats-assert/load.bash'
@test "assert_success" {
run echo "hello"
assert_success # $status is 0
}
@test "assert_failure" {
run false
assert_failure # $status is non-zero
}
@test "assert_failure with specific code" {
run bash -c 'exit 42'
assert_failure 42 # $status is 42
}
@test "assert_output exact match" {
run echo "hello"
assert_output "hello"
}
@test "assert_output substring match" {
run echo "hello world"
assert_output --partial "world"
}
@test "assert_output regex match" {
run echo "2024-01-15"
assert_output --regexp "[0-9]{4}-[0-9]{2}-[0-9]{2}"
}
@test "assert_equal" {
assert_equal "hello" "hello"
assert_equal "$(add 2 3)" "5"
}
@test "refute_output (negate)" {
run echo "hello"
refute_output "world" # output is NOT "world"
}
@test "assert_line (check specific line)" {
run printf '%s\n' "line1" "line2" "line3"
assert_line --index 0 "line1"
assert_line --index 1 "line2"
assert_line --partial "line"
}
Mocking: Override Commands with Functions
bash
#!/usr/bin/env bats
# ── Mock: override a command with a function ──
# The function has the same name as the command.
# `export -f` makes it available in subshells (where `run` executes).
setup() {
# Create mock functions for this test
mock_curl() {
echo '{"status": "ok"}'
return 0
}
export -f mock_curl
}
@test "script handles successful API call" {
# Create a mock for `curl`
curl() {
echo '{"status": "ok"}'
return 0
}
export -f curl
# The script calls curl — our mock returns success
run ./my_script.sh --api-check
assert_success
assert_output --partial "API is healthy"
}
@test "script handles failed API call" {
# Mock curl to fail
curl() {
echo "connection refused" >&2
return 7 # curl exit 7 = connection refused
}
export -f curl
run ./my_script.sh --api-check
assert_failure
assert_output --partial "API is down"
}
@test "mock with argument capture" {
# Capture arguments passed to the mock
mock_git() {
echo "MOCK: git $*" >> "$BATS_TEST_TMPDIR/git_calls.log"
return 0
}
export -f mock_git
export BATS_TEST_TMPDIR
# Override git
git() { mock_git "$@"; }
export -f git
run ./deploy_script.sh
assert_success
# Verify git was called with expected args
[[ -f "$BATS_TEST_TMPDIR/git_calls.log" ]]
grep -q "git push" "$BATS_TEST_TMPDIR/git_calls.log"
}
# ── Restore in teardown ──
teardown() {
unset -f curl git mock_curl mock_git 2>/dev/null
}
Parameterized Tests
bash
#!/usr/bin/env bats
source "../lib/math.sh"
# ── bats doesn't have built-in parameterized tests ──
# But you can loop inside a test or generate tests with a helper.
# ── Method 1: loop inside a test ──
@test "is_even for multiple values" {
for n in 2 4 6 8 10; do
run is_even "$n"
[[ "$status" -eq 0 ]] || fail "is_even $n should be even"
done
for n in 1 3 5 7 9; do
run is_even "$n"
[[ "$status" -ne 0 ]] || fail "is_even $n should be odd"
done
}
# ── Method 2: table-driven with data array ──
@test "add table-driven" {
# Format: "a b expected"
local data=(
"1 2 3"
"0 0 0"
"-1 -1 -2"
"100 200 300"
)
for row in "${data[@]}"; do
read -r a b expected <<< "$row"
local actual
actual=$(add "$a" "$b")
[[ "$actual" == "$expected" ]] || fail "add $a $b = $actual (expected $expected)"
done
}
# ── Method 3: generate tests with bats-loop or a generator ──
# bats-loop: https://github.com/bats-core/bats-loop
# Or generate .bats files from a template (for separate test per case)
setup_file and teardown_file (bats-core 1.5+)
bash
#!/usr/bin/env bats
# ── setup_file/teardown_file: run ONCE per file (not per test) ──
setup_file() {
# Heavy setup: start a test database, create fixtures
export TEST_DB=$(mktemp /tmp/test.db.XXXXXX)
sqlite3 "$TEST_DB" "CREATE TABLE users (id INTEGER, name TEXT);"
sqlite3 "$TEST_DB" "INSERT INTO users VALUES (1, 'Alice'), (2, 'Bob');"
}
teardown_file() {
# Clean up after ALL tests
rm -f "$TEST_DB"
}
# ── setup/teardown: run before/after EACH test ──
setup() {
# Per-test setup: reset state, create temp files
tmpdir=$(mktemp -d)
}
teardown() {
rm -rf -- "$tmpdir"
}
@test "database has 2 users" {
local count
count=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM users;")
[[ "$count" == "2" ]]
}
@test "Alice exists in database" {
local name
name=$(sqlite3 "$TEST_DB" "SELECT name FROM users WHERE id=1;")
[[ "$name" == "Alice" ]]
}
Anti-Pattern: Calling the Function Twice
bash
# ❌ NAIVE — calls add() twice (once with run, once with $())
@test "add returns the sum" {
run add 2 3 # runs add, captures into $status/$output (but doesn't check!)
[[ "$(add 2 3)" == "5" ]] # runs add AGAIN — wasteful, and side effects compound!
}
# ✅ CORRECT — use run and check $output
@test "add returns the sum" {
run add 2 3
assert_success
assert_output "5" # checks $output (from the single run call)
}
# ✅ ALSO CORRECT — capture directly (for pure functions, no run needed)
@test "add returns the sum" {
local result
result=$(add 2 3)
[[ "$result" == "5" ]]
}
# ── Why double-calling is bad ──
# 1. Wasteful: runs the function twice.
# 2. Side effects: if the function writes to a file or modifies state,
# calling it twice causes issues (double writes, double increments).
# 3. Confusing: the run's output isn't checked; the direct call's result is.
# Pick one: `run` + $status/$output (for commands), or `$(...)` + check (for pure functions).
CI Integration
yaml
# .github/workflows/test.yml
name: Bash Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install bats-core
run: |
git clone https://github.com/bats-core/bats-core.git
cd bats-core && ./install.sh /usr/local
- name: Install bats-assert and bats-support
run: |
git clone https://github.com/bats-core/bats-support.git test/test_helper/bats-support
git clone https://github.com/bats-core/bats-assert.git test/test_helper/bats-assert
- name: Install ShellCheck
run: sudo apt-get install -y shellcheck
- name: Run ShellCheck
run: shellcheck *.sh lib/*.sh
# Lint all shell scripts
- name: Run bats tests
run: bats test/
# Run all .bats files in test/
- name: Run bats tests (TAP output)
run: bats --formatter tap test/
# TAP format for CI integration
Production Pattern: Testing a Complete Script
bash
#!/usr/bin/env bats
# ── Test the full backup script ──
# bin/backup.sh:
# #!/usr/bin/env bash
# set -euo pipefail
# src="${1:-}"
# dst="${2:-}"
# [[ -d "$src" ]] || { echo "source not found: $src" >&2; exit 1; }
# [[ -n "$dst" ]] || { echo "missing dest" >&2; exit 1; }
# tar -czf "$dst/backup.tar.gz" -C "$src" .
# echo "backup complete"
setup() {
src=$(mktemp -d)
dst=$(mktemp -d)
echo "important data" > "$src/file.txt"
}
teardown() {
rm -rf -- "$src" "$dst"
}
@test "creates a backup" {
run bash "$BATS_TEST_DIRNAME/../bin/backup.sh" "$src" "$dst"
assert_success
assert_output --partial "backup complete"
[[ -f "$dst/backup.tar.gz" ]]
# Verify the backup content
tar -xzf "$dst/backup.tar.gz" -C "$dst/extracted"
[[ "$(cat "$dst/extracted/file.txt")" == "important data" ]]
}
@test "fails on missing source" {
run bash "$BATS_TEST_DIRNAME/../bin/backup.sh" "/nonexistent" "$dst"
assert_failure
assert_output --partial "source not found"
}
@test "fails on missing dest" {
run bash "$BATS_TEST_DIRNAME/../bin/backup.sh" "$src" ""
assert_failure
assert_output --partial "missing dest"
}
@test "fails on non-directory source" {
echo "not a dir" > "$src/file2.txt"
run bash "$BATS_TEST_DIRNAME/../bin/backup.sh" "$src/file2.txt" "$dst"
assert_failure
assert_output --partial "source not found"
}
💡 Tips & Tricks
bash
# ── Skip tests conditionally ──
@test "only run on Linux" {
[[ "$(uname)" == "Linux" ]] || skip "Linux only"
# ... test ...
}
@test "requires docker" {
command -v docker &>/dev/null || skip "docker not installed"
# ... test ...
}
@test "TODO: implement" {
skip "not implemented yet"
}
# ── Skip with reason ──
@test "slow integration test" {
[[ -n "${RUN_SLOW:-}" ]] || skip "set RUN_SLOW=1 to run"
# ... slow test ...
}
# ── Test helpers: create reusable functions ──
setup() {
# Create a temp project with a git repo
setup_git_repo() {
local dir=$1
mkdir -p "$dir"
cd "$dir"
git init --quiet
git config user.email "test@test.com"
git config user.name "Test"
echo "content" > file.txt
git add . && git commit -m "initial" --quiet
}
export -f setup_git_repo
}
# ── Capture output for debugging ──
@test "debug output" {
run ./my_script.sh
# If the test fails, bats shows $output and $status
# You can also print manually:
echo "DEBUG: status=$status" >&3 # >&3 prints to bats' output (not captured)
echo "DEBUG: output=$output" >&3
assert_success
}
# ── Run a subset of tests ──
# bats test/math_test.bats # run one file
# bats -f "factorial" test/ # run tests matching filter
# bats -n test/ # run only tests that failed last time (junit output)
⚠️ Edge Cases & Gotchas
bash
# ── bats tests are in .bats files ──
# bats test/ runs all *.bats files. .sh test files are ignored.
# Name test files: test/feature_test.bats
# ── `source` in a test runs in the test's shell ──
# Functions/variables from the sourced file are available.
# But `set -e` in the sourced file applies to the test shell (may cause unexpected exits).
# Don't use `set -e` in libraries — use it only in scripts.
# ── `run` captures stdout AND stderr ──
# $output has both. Use `run --separate-stderr` (bats 1.5+) for $stderr separately.
# ── Mocking with functions affects the test shell ──
# `git() { ... }` overrides git in the test. Always unset in teardown: `unset -f git`.
# ── `$BATS_TEST_FILENAME` vs `$0` ──
# $0 is the bats executable. Use $BATS_TEST_FILENAME for the test file's path.
# source "$(dirname "$BATS_TEST_FILENAME")/../lib/utils.sh"
# ── bats and `set -e` ──
# A failing `[[ ]]` in a @test causes the test to fail (good).
# But `set -e` in a sourced file can exit the test shell. Don't source files with `set -e`.
# ── Tests don't catch everything ──
# bats tests behavior (runtime). ShellCheck catches static issues (quoting, unset vars).
# Use BOTH: bats for behavior, ShellCheck for static analysis.
🧠 Quick Quiz
Why does this test pass even when the function is broken?
bash
@test "add works" {
run add 2 3
[[ "$(add 2 3)" == "5" ]]
}
Answer
The test calls add 2 3 twice:
run add 2 3— captures into$status/$output, but neither is checked.$(add 2 3)— the[[ ]]checks this call's output against "5".
If add is broken (returns "6"), the run call's output ("6") is ignored, and the [[ ]] fails — the test does fail. But the issue is:
- Wasteful:
addruns twice. - Side effects: if
addwrites to a file or increments a counter, it runs twice. - Confusing: the
runis dead code (its output isn't checked). A reader might think the test checksrun's output.
The fix: use one approach:
bash
@test "add works" {
run add 2 3
assert_success
assert_output "5" # checks the run's output (single call)
}
Or (for pure functions):
bash
@test "add works" {
result=$(add 2 3)
[[ "$result" == "5" ]]
}
The lesson: don't mix run and direct $(...) calls in the same test. Pick one — run + $output for commands, or $() + [[ ]] for pure functions.