| name | bash-testing |
| description | Bash script testing with BATS (Bash Automated Testing System): test structure, assertions, setup/teardown, mocking external commands, CI integration, and coverage strategies. |
Bash Testing Skill
When to Activate
- Writing tests for a new shell script
- Adding tests to an untested script
- Setting up BATS in a project
- Debugging why a BATS test is failing
- Mocking external commands in tests
- Adding BATS to a CI pipeline so shell scripts are tested alongside application code on every pull request
- Isolating a flaky integration test that depends on a real external command like
git, curl, or docker
- Verifying every exit code path in a complex script that branches on multiple error conditions
- Writing unit tests for individual Bash functions by sourcing the script under test rather than executing it whole
BATS Setup
Installation
npm install --save-dev bats bats-support bats-assert
brew install bats-core
bats --version
Project structure
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
package.json integration
{
"scripts": {
"test:shell": "bats tests/ --recursive",
"test:shell:tap": "bats tests/ --recursive --tap"
}
}
Test File Structure
#!/usr/bin/env bats
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
setup() {
TEST_TMP=$(mktemp -d)
export CONFIG_DIR="$TEST_TMP"
}
teardown() {
rm -rf "$TEST_TMP"
}
setup_file() {
export TEST_SERVER_PID=$(start_test_server &)
}
teardown_file() {
kill "$TEST_SERVER_PID" 2>/dev/null || true
}
@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 ]
}
Assertions (bats-assert)
assert_success
assert_failure
assert_output "exact"
assert_output --partial "part"
assert_output --regexp "^prefix"
refute_output
assert_line "line content"
assert_line --index 0 "first"
assert_line --partial "part"
Mocking External Commands
PATH-based mocking (recommended)
Create a test_helper/mock_bin/ directory with fake commands:
echo '{"status":"ok"}'
exit 0
In your test:
setup() {
export PATH="$BATS_TEST_DIRNAME/test_helper/mock_bin:$PATH"
}
Inline function override
@test "calls curl with correct URL" {
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"
}
Mock with call recording
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
Unit Testing Functions (source mode)
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'
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"
}
Testing Exit Codes
@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
}
CI Integration
GitHub Actions
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
Checklist