Bash has a reputation problem.
It's the language people write when they can't figure out how to do something in a "real" language. It's duct tape. It's the thing that holds your CI/CD together with set -e and crossed fingers. Nobody tests bash scripts because, well, how would you even do that?
This is bullshit.
Bash is core to every Unix-like operating system. It's the glue between tools. It's the orchestration layer for distributed systems. If you're building CLI tools meant to be composed, piped, and chained together—bash isn't a workaround, it's the runtime.
I built a distributed job queue CLI. The components were solid Go with good unit tests. But unit tests couldn't answer the real question: does this thing actually work when you're using it the way it's meant to be used? In bash. From the command line. With real files and processes and timing issues.
BATS—the Bash Automated Testing System—turned out to be the answer. Not Cucumber. Not end-to-end frameworks that spawn browsers. BATS. Because if your tool lives in bash, your integration tests should too.
Here's how to use it.
What BATS Actually Is 🔗
BATS is a TAP-compliant testing framework for bash scripts. It runs tests, reports results, and provides assertion helpers that don't make you want to throw your keyboard.
Installation 🔗
Skip the package managers. Clone the repos directly into your project so everyone gets the same version:
# install-bats-libs.sh
#!/bin/bash -e
if [ -d "./.test/bats" ]; then
echo "Deleting folder $FOLDER"
rm -rf "./.test/bats/"
mkdir -p ./.test/bats
else
mkdir -p ./.test/bats
fi
git clone --depth 1 https://github.com/bats-core/bats-core ./.test/bats/bats
rm -rf ./.test/bats/bats/.git
git clone --depth 1 https://github.com/ztombol/bats-support ./.test/bats/bats-support
rm -rf ./.test/bats/bats-support/.git
git clone --depth 1 https://github.com/ztombol/bats-assert ./.test/bats/bats-assert
rm -rf ./.test/bats/bats-assert/.git
git clone --depth 1 https://github.com/jasonkarns/bats-mock.git ./.test/bats/bats-mock
rm -rf ./.test/bats/bats-mock/.git
Bash Note:
[ -d "./.test/bats" ]uses the single-bracket test command (test) to check if a directory exists. The-dflag returns true if the path exists and is a directory. Single brackets are POSIX-compliant and work in any shell. The spaces inside the brackets are required—[-d ...]won't work.
Run it once, commit the .test/bats directory. Now your tests work the same everywhere.
Need to start fresh? Here's the cleanup script:
# clean-bats.sh
#!/bin/bash -e
if [ -d "./.test/bats" ]; then
echo "Deleting folder $FOLDER"
rm -rf "./.test/bats/"
fi
This gives you:
- bats-core - The test runner itself
- bats-support - Required dependency for other helpers
- bats-assert -
assert_success,assert_output,assert_line - bats-mock - Stubbing external commands
Run tests with the local binary:
./.test/bats/bats/bin/bats .test/*.bats
Or add it to your PATH in your test helper (we'll get to that).
Level 1: Basic Command Testing 🔗
Start simple. Can your CLI run without exploding?
# .test/basic.bats
#!/usr/bin/env bats
# Load helper libraries
load bats/bats-support/load
load bats/bats-assert/load
@test "command exists and shows help" {
run mycli --help
assert_success
assert_output --partial "Usage:"
}
@test "version flag returns version" {
run mycli --version
assert_success
assert_output --regexp '[0-9]+\.[0-9]+\.[0-9]+'
}
@test "invalid command shows error" {
run mycli not-a-real-command
assert_failure
assert_output --partial "Unknown command"
}
Run it:
bats test/basic.bats
You get TAP output:
✓ command exists and shows help
✓ version flag returns version
✓ invalid command shows error
3 tests, 0 failures
Setup and Teardown 🔗
Tests need clean state. Use setup() and teardown():
# .test/workspace.bats
#!/usr/bin/env bats
load bats/bats-support/load
load bats/bats-assert/load
setup() {
# Create temporary directory for this test
TEST_TEMP=$(mktemp -d)
cd "$TEST_TEMP"
# Initialize your tool's workspace
mkdir -p .myapp
}
teardown() {
# Clean up after test
cd /
rm -rf "$TEST_TEMP"
}
@test "creates job file" {
run mycli jobs create "Do the thing"
assert_success
# Verify file was created in workspace
[ -f .myapp/queue.jsonl ]
}
Bash Note:
[ -f .myapp/queue.jsonl ]uses-fto test if a regular file exists (test). In BATS, the test passes if the command returns exit code 0 (true). If the file doesn't exist, the test command returns 1 and BATS marks the test as failed.
Every test gets a fresh $TEST_TEMP. No pollution between tests. No "but it worked on my machine" because you forgot to clean up.
Assertions That Actually Help 🔗
The basic assertions you'll use constantly:
# assertion-cheatsheet.bash (not a runnable file, just reference)
run some-command
# Exit code
assert_success # Exit 0
assert_failure # Exit non-zero
assert_equal $status 2 # Specific exit code
# Output
assert_output "exact match"
assert_output --partial "substring"
assert_output --regexp '^[0-9]+$'
# Line-specific (0-indexed)
assert_line --index 0 "First line"
assert_line --partial "appears somewhere"
# Negation
refute_output "should not appear"
refute_line --partial "nope"
This is already more rigorous than most bash scripts get. You're testing real behavior, not mocking function calls.
Level 2: Test Helpers and Mocking 🔗
Real CLI tools interact with other tools. They read files. They parse JSON. They have dependencies.
You need test helpers.
Shared Setup in a Test Helper 🔗
# .test/test_helper.bash
# Shared test workspace setup
export TEST_WORKSPACE="${BATS_TEST_TMPDIR}/workspace"
export MOCK_BD="${BATS_TEST_TMPDIR}/bin/bd"
setup_workspace() {
rm -rf "${TEST_WORKSPACE}"
mkdir -p "${TEST_WORKSPACE}/.myapp"
mkdir -p "$(dirname "${MOCK_BD}")"
cd "${TEST_WORKSPACE}"
}
teardown_workspace() {
cd /
rm -rf "${TEST_WORKSPACE}"
}
Bash Note:
${VAR}and$(cmd)look similar but do completely different things.${BATS_TEST_TMPDIR}is parameter expansion—it retrieves the value of the variable.$(dirname "${MOCK_BD}")is command substitution—it runsdirnameand captures its output. The braces in${VAR}are optional for simple names ($VARworks too) but required when concatenating:${VAR}_suffixvs the broken$VAR_suffix.
# .test/test_helper.bash (continued)
# Mock the 'bd' command that your CLI depends on
# Uses heredoc (<<EOF) to write a multi-line script to a file
setup_mock_bd() {
local issues_json="$1"
cat > "${MOCK_BD}" <<EOF
#!/usr/bin/env bash
case "\$1" in
list)
cat <<'ISSUES'
${issues_json}
ISSUES
;;
show)
# Return single issue based on \$2 (issue ID)
echo '{"id":"'"$2"'","title":"Mock issue","status":"open"}'
;;
*)
echo "Mock bd: Unknown command \$1" >&2
exit 1
;;
esac
EOF
chmod +x "${MOCK_BD}"
export PATH="$(dirname "${MOCK_BD}"):${PATH}"
}
# JSON assertion helper
assert_json_field() {
local json="$1"
local field="$2"
local expected="$3"
local actual=$(echo "$json" | jq -r "$field")
[[ "$actual" == "$expected" ]] || {
echo "Expected ${field}='${expected}', got '${actual}'"
return 1
}
}
# File content helpers
assert_file_contains() {
local file="$1"
local expected="$2"
grep -q "$expected" "$file" || {
echo "File $file does not contain '$expected'"
return 1
}
}
Bash Note:
<<EOF ... EOFis a heredoc (Here Documents)—a way to embed multi-line strings. Variables like${issues_json}are expanded inside. Use<<'EOF'(quoted delimiter) to prevent expansion when you want literal$characters in the output. Thecat > file <<EOFpattern writes the heredoc content to a file.
Bash Note:
localdeclares a variable scoped to the current function (local). Withoutlocal, variables are global and leak into other functions—a common source of test pollution. Always uselocalfor function parameters and temporary values.
Bash Note:
[[ "$actual" == "$expected" ]]uses double brackets, a bash-specific conditional ([[). Unlike single brackets, double brackets don't require quoting variables to prevent word splitting, support pattern matching with==, and allow&&/||inside the expression. The|| { ... }pattern runs the block only if the test fails—a compact way to handle errors withoutif/else.
Now your tests can load this:
# .test/sync.bats
#!/usr/bin/env bats
load bats/bats-support/load
load bats/bats-assert/load
load bats/bats-file/load
load test_helper
setup() {
setup_workspace
}
teardown() {
teardown_workspace
}
@test "syncs with bd issues" {
# Setup mock bd command to return fake issues
local mock_issues='[
{"id":"abc-123","title":"Fix the widget","status":"open"},
{"id":"def-456","title":"Refactor gizmo","status":"done"}
]'
setup_mock_bd "$mock_issues"
# Run your CLI that calls 'bd list' internally
run mycli sync
assert_success
assert_output --partial "Synced 2 issues"
# Verify the sync created local files
assert_file_exist "${TEST_WORKSPACE}/.myapp/issues.json"
}
Mocking External Commands 🔗
Your CLI probably calls external tools. Git. curl. jq. Whatever.
Mock them:
# .test/test_helper.bash (add to existing file)
setup_mock_git() {
cat > "${BATS_TEST_TMPDIR}/bin/git" <<'EOF'
#!/usr/bin/env bash
case "$1" in
rev-parse)
echo "abc123def456" # Fake commit hash
;;
status)
echo "On branch main"
echo "nothing to commit, working tree clean"
;;
*)
exit 1
;;
esac
EOF
chmod +x "${BATS_TEST_TMPDIR}/bin/git"
export PATH="${BATS_TEST_TMPDIR}/bin:${PATH}"
}
Then in your test:
# .test/deploy.bats (excerpt)
@test "records git commit in metadata" {
setup_mock_git
run mycli deploy
assert_success
# Verify it captured the fake commit hash
local metadata=$(cat .myapp/last-deploy.json)
assert_json_field "$metadata" ".commit" "abc123def456"
}
Testing JSON Output 🔗
CLI tools love JSON. Test it properly:
# .test/json-output.bats (excerpt)
@test "job status returns valid JSON" {
# Create a job first
run mycli jobs create "Test job"
assert_success
local job_id=$(echo "$output" | jq -r '.job_id')
# Query job status
run mycli jobs show "$job_id"
assert_success
# Validate JSON structure
echo "$output" | jq . > /dev/null || fail "Invalid JSON"
# Check specific fields
assert_json_field "$output" ".job_id" "$job_id"
assert_json_field "$output" ".state" "pending"
assert_json_field "$output" ".title" "Test job"
}
This is real integration testing. You're not stubbing out JSON parsing—you're testing the actual output your users will see.
Level 3: Background Processes and State Machines 🔗
Here's where BATS gets interesting.
Real CLI tools do async things. They wait for conditions. They poll. They recover from failures. They manage state transitions.
Testing Background Processes 🔗
Say your CLI has a --wait flag that blocks until a job completes. How do you test that?
# .test/async.bats
#!/usr/bin/env bats
load bats/bats-support/load
load bats/bats-assert/load
load bats/bats-file/load
load test_helper
setup() {
setup_workspace
}
teardown() {
teardown_workspace
}
@test "waits for job completion" {
# Create a pending job directly in the file system
local job_id="job-$(date +%s)"
local pending_job='{
"job_id":"'${job_id}'",
"title":"Background test job",
"state":"pending",
"created_at":"'$(date -Iseconds)'"
}'
echo "$pending_job" > "${TEST_WORKSPACE}/.myapp/queue.jsonl"
# Start the wait command in the background
mycli jobs show "$job_id" --wait --timeout=10s \
> "${TEST_WORKSPACE}/output.txt" 2>&1 &
local wait_pid=$!
# Give it a moment to start
sleep 1
# Simulate job completion by moving it to done state
local completed_job='{
"job_id":"'${job_id}'",
"title":"Background test job",
"state":"completed",
"created_at":"'$(date -Iseconds)'",
"completed_at":"'$(date -Iseconds)'"
}'
rm -f "${TEST_WORKSPACE}/.myapp/queue.jsonl"
echo "$completed_job" > "${TEST_WORKSPACE}/.myapp/done.jsonl"
# Wait for the background process to finish
wait $wait_pid
local exit_code=$?
# Verify it exited successfully
assert_equal $exit_code 0
# Check the output
run cat "${TEST_WORKSPACE}/output.txt"
assert_output --partial "completed successfully"
}
Bash Note: The
&at the end of a command runs it in the background (Job Control).$!is a special variable containing the PID of the last background process (Special Parameters). Thewaitbuiltin blocks until the specified PID exits and sets$?to its exit code. This pattern—background a process, do something, then wait for it—is essential for testing async CLI behavior.
Bash Note:
$(date +%s)uses command substitution (Command Substitution) to capture a command's stdout as a string. The$()syntax is preferred over backticks because it nests cleanly and is easier to read.
You're testing the actual polling logic, the actual file watching, the actual timeout behavior. Not a mock. Not a stub. The real thing.
Testing Timeout Behavior 🔗
What happens when things don't complete?
# .test/async.bats (continued)
@test "wait times out if job never completes" {
local job_id="job-timeout-test"
local pending_job='{"job_id":"'${job_id}'","state":"pending"}'
echo "$pending_job" > "${TEST_WORKSPACE}/.myapp/queue.jsonl"
# Start wait with short timeout
mycli jobs show "$job_id" --wait --timeout=2s \
> "${TEST_WORKSPACE}/output.txt" 2>&1 &
local wait_pid=$!
# Don't complete the job - let it timeout
wait $wait_pid
local exit_code=$?
# Should exit with error
assert_equal $exit_code 1
run cat "${TEST_WORKSPACE}/output.txt"
assert_output --partial "timeout"
}
Testing State Machine Transitions 🔗
Job queues are state machines. Jobs move between states. Some transitions are valid. Some aren't.
# .test/state-machine.bats (excerpt)
@test "prevents invalid state transitions" {
local job_id="state-test-job"
# Create completed job
local completed_job='{"job_id":"'${job_id}'","state":"completed"}'
echo "$completed_job" > "${TEST_WORKSPACE}/.myapp/done.jsonl"
# Try to start a completed job (invalid transition)
run mycli jobs start "$job_id"
assert_failure
assert_output --partial "Cannot start job in completed state"
# Verify job state didn't change
run mycli jobs show "$job_id"
assert_json_field "$output" ".state" "completed"
}
Testing Time-Dependent Behavior 🔗
The hard part. Jobs with heartbeats. Stale locks. Orphan recovery.
# .test/recovery.bats (excerpt)
@test "recovers orphaned jobs with stale heartbeats" {
# Create job with old heartbeat (2 minutes ago)
local stale_time=$(date -Iseconds -d '2 minutes ago')
local job_id="orphan-job"
local orphan_job='{
"job_id":"'${job_id}'",
"state":"running",
"started_at":"'${stale_time}'",
"heartbeat_at":"'${stale_time}'"
}'
echo "$orphan_job" > "${TEST_WORKSPACE}/.myapp/active.jsonl"
# Run recovery command
run mycli jobs recover
assert_success
assert_output --partial "Recovered 1 orphaned job"
# Verify job moved back to queue
assert_file_exist "${TEST_WORKSPACE}/.myapp/queue.jsonl"
refute_file_exist "${TEST_WORKSPACE}/.myapp/active.jsonl"
# Verify job state reset
run mycli jobs show "$job_id"
assert_json_field "$output" ".state" "pending"
}
This test manipulates time by creating timestamps in the past. It then verifies that your recovery logic correctly identifies stale jobs and transitions them.
Testing Concurrent Operations 🔗
Multiple processes writing to the same files. The nightmare scenario.
# .test/concurrency.bats (excerpt)
@test "handles concurrent job creation" {
# Start 5 job creations in parallel
for i in {1..5}; do
mycli jobs create "Concurrent job $i" &
done
# Wait for all background processes
wait
# Verify all 5 jobs were created
run mycli jobs list
assert_success
local job_count=$(echo "$output" | jq '. | length')
assert_equal "$job_count" "5"
# Verify no duplicate job IDs
local unique_ids=$(echo "$output" | jq -r '.[].job_id' | sort | uniq | wc -l)
assert_equal "$unique_ids" "5"
}
If your CLI uses file locking or atomic writes, this test will catch races.
Running Your Test Suite 🔗
# run-tests.sh
#!/usr/bin/env bash
set -euo pipefail
# Run all BATS tests using the local install
./.test/bats/bats/bin/bats .test/*.bats
# Or for more verbose output
# ./.test/bats/bats/bin/bats --tap .test/*.bats
# Or with timing
# ./.test/bats/bats/bin/bats --formatter tap --timing .test/*.bats
Make it executable:
chmod +x run-tests.sh
CI Integration 🔗
If you committed the .test/bats/ directory (recommended), CI is trivial:
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
bats:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run BATS tests
run: ./.test/bats/bats/bin/bats .test/*.bats
No installation step needed. The test framework is already in your repo.
If you prefer not to commit the bats libraries, run the install script first:
# .github/workflows/test.yml (alternative)
name: Tests
on: [push, pull_request]
jobs:
bats:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install BATS
run: ./install-bats-libs.sh
- name: Run BATS tests
run: ./.test/bats/bats/bin/bats .test/*.bats
Now every push runs your full integration test suite.
Tips for Keeping Tests Fast 🔗
BATS tests are real integration tests. They're slower than unit tests. That's fine. But you don't want them to be slow.
Don't repeat expensive setup:
# .test/expensive-setup.bats (example pattern)
# SLOW - creates workspace every test
setup() {
setup_workspace
mycli init # Expensive operation
}
# FAST - use setup_file for one-time setup
setup_file() {
export SHARED_WORKSPACE=$(mktemp -d)
cd "$SHARED_WORKSPACE"
mycli init
}
teardown_file() {
rm -rf "$SHARED_WORKSPACE"
}
Use --filter during development:
# Only run tests matching pattern
bats --filter "concurrent" test/*.bats
Parallelize with --jobs:
# Run tests in parallel (requires bats-core >= 1.5.0)
bats --jobs 4 test/*.bats
When Not to Use BATS 🔗
BATS is for testing bash scripts and CLI tools. It's not for:
- Testing web UIs (use Playwright, Cypress, etc.)
- Unit testing Go/Rust/Python code (use your language's test framework)
- Load testing (use k6, Locust, etc.)
But if you're testing the actual user experience of a CLI tool—the thing someone runs from their terminal—BATS is perfect.
The Point 🔗
Bash isn't a toy language. It's not "just scripts." It's the orchestration layer for most of the software infrastructure on the planet.
If you're building CLI tools meant to be composed and chained together, your integration tests should reflect that reality. Test them in the environment they'll actually run: bash, with real files, real processes, real timing.
BATS gives you the structure to do that without losing your mind. Setup and teardown that works. Assertions that read like English. Helpers that let you mock external dependencies without rewriting your entire tool.
Your bash scripts deserve tests. BATS makes it possible.
Further Reading: