BATS: Automated Testing for Bash Scripts
Shell scripts are the connective tissue of most infrastructure: they bootstrap servers, run deployments, process log files, and glue together a dozen other tools. Yet most teams test bash scripts the same way they have for decades — by running them manually and hoping nothing breaks. BATS (Bash Automated Testing System) changes that. With bats-core, you get a proper test runner, rich assertions, and CI integration for your bash codebase, all without leaving the shell.
This guide walks through everything from installation to CI integration, with practical examples you can adapt to your own scripts.
What is BATS?
BATS is a TAP-compliant testing framework for bash. Each test is a function annotated with @test, and the output follows the Test Anything Protocol format, making it compatible with most CI systems and test reporters. The project lives at github.com/bats-core/bats-core and is the actively maintained fork of the original bats project.
The companion libraries bats-assert, bats-support, and bats-file add richer assertions and better failure output.
Installation
Via Homebrew (macOS/Linux):
brew install bats-coreVia npm (works anywhere Node is installed):
npm install --save-dev batsClone directly (portable, good for CI):
git clone https://github.com/bats-core/bats-core.git test/libs/bats
git clone https://github.com/bats-core/bats-support.git test/libs/bats-support
git clone https://github.com/bats-core/bats-assert.git test/libs/bats-assert
git clone https://github.com/bats-core/bats-file.git test/libs/bats-fileThe clone approach is the most portable and works identically on every machine without requiring package managers. It also lets you pin exact versions via submodules, which is worth doing for any project that treats tests as a hard quality gate.
Basic Test Structure
A BATS test file uses the .bats extension. Here is the simplest possible example:
#!/usr/bin/env bats
@test "addition using bc" {
result="$(echo 2+2 | bc)"
[ "$result" -eq 4 ]
}Run it with:
bats test/basic.batsOutput:
✓ addition using bc
1 test, 0 failuresEach @test block is an independent test. If any command in the block returns a non-zero exit code, the test fails. Standard bash conditionals, [[ ]], and [ ] all work as you would expect.
Setup and Teardown
Use setup and teardown functions to run code before and after each test. These are the equivalent of beforeEach/afterEach in JavaScript testing frameworks.
#!/usr/bin/env bats
setup() {
# Create a temp directory for each test
TEST_DIR="$(mktemp -d)"
export TEST_DIR
}
teardown() {
# Clean up after each test
rm -rf "$TEST_DIR"
}
@test "script creates output file" {
run ./scripts/generate-report.sh --output "$TEST_DIR/report.txt"
[ "$status" -eq 0 ]
[ -f "$TEST_DIR/report.txt" ]
}
@test "script fails on missing input" {
run ./scripts/generate-report.sh --output "$TEST_DIR/report.txt" --input /nonexistent
[ "$status" -ne 0 ]
}There are also setup_file and teardown_file hooks that run once per test file rather than per test — useful for expensive setup like starting a server or creating a large fixture.
Using run and Checking Status and Output
The run helper is central to BATS. It executes a command and captures its stdout, stderr, and exit code into $output, $stderr, and $status respectively, without causing the test to fail immediately on a non-zero exit.
@test "curl fails gracefully on unreachable host" {
run curl --connect-timeout 1 http://192.0.2.1/
[ "$status" -ne 0 ]
[[ "$output" == *"Connection refused"* ]] || [[ "$output" == *"timed out"* ]]
}Without run, a failing command would abort the test immediately. With run, you can inspect what happened.
Richer Assertions with bats-assert
The bats-assert library gives you expressive assertion functions. Load both bats-support (required by bats-assert) and bats-assert at the top of your test file:
#!/usr/bin/env bats
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
@test "deploy script prints success message" {
run ./scripts/deploy.sh --env staging
assert_success
assert_output --partial "Deployment complete"
}
@test "deploy script fails with invalid env" {
run ./scripts/deploy.sh --env production_typo
assert_failure
assert_output --partial "Unknown environment"
}
@test "config generator produces valid JSON" {
run ./scripts/generate-config.sh --service api
assert_success
run jq . <<< "$output"
assert_success
}The key assertions:
assert_success— exit code is 0assert_failure— exit code is non-zeroassert_output "exact string"— output matches exactlyassert_output --partial "substring"— output contains substringassert_output --regexp "pattern"— output matches regexrefute_output --partial "string"— output does NOT contain stringassert_line "string"— any output line matchesassert_line --index 0 "string"— specific output line matches
When assertions fail, bats-assert prints a detailed diff showing expected vs actual, which is far more useful than a bare [ ] failure.
Mocking Commands
Bash makes command mocking straightforward: define a function with the same name as the command you want to mock, and it takes precedence over the real binary for the duration of that shell session.
#!/usr/bin/env bats
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
# Mock aws CLI to avoid real AWS calls
aws() {
echo "MOCK: aws $*"
return 0
}
export -f aws
@test "deploy script calls aws s3 sync" {
run ./scripts/deploy-assets.sh --bucket my-bucket --dir ./dist
assert_success
assert_output --partial "MOCK: aws s3 sync"
}For more complex mocking where different calls need different behavior, use a counter or a fixture file:
curl() {
local call_count_file="/tmp/bats-curl-calls"
local count
count=$(cat "$call_count_file" 2>/dev/null || echo 0)
echo $((count + 1)) > "$call_count_file"
case "$count" in
0) echo '{"status": "pending"}'; return 0 ;;
1) echo '{"status": "running"}'; return 0 ;;
2) echo '{"status": "success"}'; return 0 ;;
*) return 1 ;;
esac
}
export -f curlThis technique works for git, docker, kubectl, aws, gcloud — any external tool your script calls.
Testing Real Bash Scripts: A Complete Example
Suppose you have a script that parses arguments, validates inputs, and processes files:
# scripts/process-logs.sh
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 --input FILE --output DIR [--format json|csv]" >&2
exit 1
}
FORMAT="json"
INPUT=""
OUTPUT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--input) INPUT="$2"; shift 2 ;;
--output) OUTPUT="$2"; shift 2 ;;
--format) FORMAT="$2"; shift 2 ;;
*) usage ;;
esac
done
[[ -z "$INPUT" || -z "$OUTPUT" ]] && usage
[[ -f "$INPUT" ]] || { echo "Error: Input file not found: $INPUT" >&2; exit 2; }
[[ "$FORMAT" =~ ^(json|csv)$ ]] || { echo "Error: Invalid format: $FORMAT" >&2; exit 3; }
mkdir -p "$OUTPUT"
# ... processing logic ...
echo "Processed $(wc -l < "$INPUT") lines to $OUTPUT/$FORMAT-output.$FORMAT"The test file:
#!/usr/bin/env bats
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
load 'libs/bats-file/load'
SCRIPT="./scripts/process-logs.sh"
setup() {
TEST_DIR="$(mktemp -d)"
INPUT_FILE="$TEST_DIR/input.log"
OUTPUT_DIR="$TEST_DIR/output"
# Create a test input file
printf "line one\nline two\nline three\n" > "$INPUT_FILE"
}
teardown() {
rm -rf "$TEST_DIR"
}
@test "fails without required arguments" {
run "$SCRIPT"
assert_failure
assert_output --partial "Usage:"
}
@test "fails with missing --input" {
run "$SCRIPT" --output "$OUTPUT_DIR"
assert_failure
assert_output --partial "Usage:"
}
@test "fails when input file does not exist" {
run "$SCRIPT" --input /nonexistent/file.log --output "$OUTPUT_DIR"
[ "$status" -eq 2 ]
assert_output --partial "Input file not found"
}
@test "fails with invalid format" {
run "$SCRIPT" --input "$INPUT_FILE" --output "$OUTPUT_DIR" --format xml
[ "$status" -eq 3 ]
assert_output --partial "Invalid format"
}
@test "succeeds with valid arguments and default format" {
run "$SCRIPT" --input "$INPUT_FILE" --output "$OUTPUT_DIR"
assert_success
assert_output --partial "3 lines"
assert_dir_exists "$OUTPUT_DIR"
}
@test "succeeds with csv format" {
run "$SCRIPT" --input "$INPUT_FILE" --output "$OUTPUT_DIR" --format csv
assert_success
assert_file_exists "$OUTPUT_DIR/csv-output.csv"
}This covers argument parsing, missing files, invalid options, and the happy path — all the things that commonly break when bash scripts evolve.
Running Tests
Run all tests in a directory:
bats test/Run a specific file:
bats test/process-logs.batsRun with verbose output (show test names even when passing):
bats --verbose-run test/Parallel execution — BATS supports running test files in parallel. This is especially useful when you have dozens of test files and each takes a few seconds:
bats --jobs 4 test/Individual tests within a file always run sequentially to avoid shared state issues, but separate files run in parallel workers.
Filter tests by name using --filter:
bats --filter "fails" test/GitHub Actions CI Integration
# .github/workflows/test.yml
name: Shell Script Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive # if bats libs are submodules
- name: Install bats-core
run: |
git clone https://github.com/bats-core/bats-core.git /tmp/bats
sudo /tmp/bats/install.sh /usr/local
- name: Install bats libraries
run: |
mkdir -p test/libs
git clone https://github.com/bats-core/bats-support.git test/libs/bats-support
git clone https://github.com/bats-core/bats-assert.git test/libs/bats-assert
git clone https://github.com/bats-core/bats-file.git test/libs/bats-file
- name: Run tests
run: bats --jobs 4 test/
- name: Generate TAP report
if: always()
run: bats --formatter tap test/ > test-results.tap
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results.tapThe --formatter tap flag outputs TAP format, which many CI dashboards can parse and display as a proper test report.
Organizing a BATS Test Suite
For a medium-sized project, this directory structure works well:
test/
libs/
bats/
bats-support/
bats-assert/
bats-file/
helpers/
common.bash # shared setup functions, helper assertions
mocks.bash # reusable command mocks
unit/
argument-parsing.bats
config-validation.bats
integration/
deploy-pipeline.bats
log-processing.batsA shared helpers/common.bash file keeps repetitive setup DRY:
# test/helpers/common.bash
create_test_env() {
TEST_DIR="$(mktemp -d)"
export TEST_DIR
export CONFIG_FILE="$TEST_DIR/config.yaml"
cat > "$CONFIG_FILE" <<EOF
environment: test
log_level: debug
EOF
}
cleanup_test_env() {
rm -rf "${TEST_DIR:-}"
}Load it in test files with:
load '../helpers/common'
load '../helpers/mocks'Common Patterns and Pitfalls
Always use run before checking $status — if you call a command directly and it fails, BATS marks the test failed before you can inspect why.
Export mocked functions — function mocks only apply to subshells if you export -f function_name. Without the export, your script runs in a subshell and uses the real binary.
Use $BATS_TEST_TMPDIR — BATS provides this variable pointing to a per-test temp directory that is automatically cleaned up. It is more reliable than rolling your own temp directory in setup.
Test exit codes explicitly — assert_success and assert_failure are clearer than [ "$status" -eq 0 ], but when you need a specific non-zero code, use [ "$status" -eq 3 ] directly.
Skip tests conditionally — use skip "reason" inside a @test block to mark tests as skipped rather than deleting them:
@test "requires docker" {
command -v docker &>/dev/null || skip "docker not available"
run docker ps
assert_success
}BATS is not glamorous, but it fills a real gap. Bash scripts that lack automated tests are a source of silent regressions that only surface in production at 3am. A BATS suite gives you fast feedback, reproducible failures, and a foundation to refactor with confidence.
HelpMeTest adds 24/7 monitoring and AI test generation to complement your BATS test suite — start free at helpmetest.com