Bash script testing with BATS (Bash Automated Testing System): test structure, assertions, setup/teardown, mocking external commands, CI integration, and coverage strategies.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
git, curl, or docker# npm (recommended for project-local install)
npm install --save-dev bats bats-support bats-assert
# Homebrew (macOS)
brew install bats-core
# Verify
bats --version
tests/
unit/ # function-level tests (source the script)
integration/ # full script execution tests
fixtures/ # static test data files
test_helper/
bats-support/ # helper library
bats-assert/ # assertion library
bats.config # optional BATS configuration
{
"scripts": {
"test:shell": "bats tests/ --recursive",
"test:shell:tap": "bats tests/ --recursive --tap"
}
}
#!/usr/bin/env bats
# Load helpers (adjust path to your project structure)
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
# ── Setup / Teardown ──────────────────────────────────────────────────────────
setup() {
# Runs before EACH @test
TEST_TMP=$(mktemp -d)
# Point scripts to test fixtures
export CONFIG_DIR="$TEST_TMP"
}
teardown() {
# Runs after EACH @test — always runs even if test fails
rm -rf "$TEST_TMP"
}
setup_file() {
# Runs once before all tests in file
export TEST_SERVER_PID=$(start_test_server &)
}
teardown_file() {
# Runs once after all tests in file
kill "$TEST_SERVER_PID" 2>/dev/null || true
}
# ── Tests ─────────────────────────────────────────────────────────────────────
@test "exits 0 on valid input" {
run ./scripts/process.sh valid-input
assert_success
}
@test "exits 1 with missing argument" {
run ./scripts/process.sh
assert_failure
assert_output --partial "Usage:"
}
@test "outputs expected content" {
run ./scripts/generate.sh
assert_output "expected output"
}
@test "creates output file" {
run ./scripts/generate.sh "$TEST_TMP/result.txt"
assert_success
assert [ -f "$TEST_TMP/result.txt" ]
}
assert_success # exit code 0
assert_failure # exit code != 0
assert_output "exact" # stdout equals exactly
assert_output --partial "part" # stdout contains substring
assert_output --regexp "^prefix" # stdout matches regex
refute_output # stdout is empty
assert_line "line content" # any line equals
assert_line --index 0 "first" # specific line equals
assert_line --partial "part" # any line contains
Create a test_helper/mock_bin/ directory with fake commands:
# tests/test_helper/mock_bin/curl
#!/usr/bin/env bash
echo '{"status":"ok"}'
exit 0
In your test:
setup() {
# Prepend mock bin to PATH
export PATH="$BATS_TEST_DIRNAME/test_helper/mock_bin:$PATH"
}
@test "calls curl with correct URL" {
# Override curl for this test only
curl() {
echo "MOCK_CURL_CALLED: $*" >> "$TEST_TMP/calls.log"
echo '{"result":"mocked"}'
}
export -f curl
source ./scripts/fetch-data.sh
fetch_data "https://api.example.com/data"
assert [ -f "$TEST_TMP/calls.log" ]
run cat "$TEST_TMP/calls.log"
assert_output --partial "https://api.example.com/data"
}
# tests/test_helper/mock_bin/git
#!/usr/bin/env bash
echo "$0 $*" >> "${MOCK_CALLS_LOG:-/tmp/mock_calls.log}"
case "$1" in
status) echo "nothing to commit" ;;
push) echo "Everything up-to-date" ;;
*) echo "mock git: $*" ;;
esac
Source the script to test individual functions without running main:
#!/usr/bin/env bats
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
# Source the script — the BASH_SOURCE guard prevents main() from running
setup() {
source "$BATS_TEST_DIRNAME/../scripts/utils.sh"
}
@test "validate_path accepts relative paths" {
run validate_path "subdir/file.txt"
assert_success
}
@test "validate_path rejects .. traversal" {
run validate_path "../../../etc/passwd"
assert_failure
assert_output --partial "Invalid path"
}
@test "log_info writes to stderr" {
run log_info "test message"
assert_output --partial "[INFO]"
assert_output --partial "test message"
}
@test "returns 0 on success" {
run ./scripts/process.sh good-input
assert_equal "$status" 0
}
@test "returns 2 on invalid argument" {
run ./scripts/process.sh --invalid-flag
assert_equal "$status" 2
}
name: Shell Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install BATS
run: npm ci
- name: Run shell tests
run: npm run test:shell
- name: Lint scripts
run: |
sudo apt-get install -y shellcheck
shellcheck scripts/*.sh
bats-support, bats-assert)setup() creates isolated temp dir, teardown() removes itset -euo pipefail active