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 <span class="hljs-built_in">test/libs/bats
git <span class="hljs-built_in">clone https://github.com/bats-core/bats-support.git <span class="hljs-built_in">test/libs/bats-support
git <span class="hljs-built_in">clone https://github.com/bats-core/bats-assert.git <span class="hljs-built_in">test/libs/bats-assert
git <span class="hljs-built_in">clone https://github.com/bats-core/bats-file.git <span class="hljs-built_in">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
@<span class="hljs-built_in">test <span class="hljs-string">"addition using bc" {
result=<span class="hljs-string">"$(echo 2+2 | bc)"
[ <span class="hljs-string">"$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
<span class="hljs-function">setup() {
<span class="hljs-comment"># Create a temp directory for each test
TEST_DIR=<span class="hljs-string">"$(mktemp -d)"
<span class="hljs-built_in">export TEST_DIR
}
<span class="hljs-function">teardown() {
<span class="hljs-comment"># Clean up after each test
<span class="hljs-built_in">rm -rf <span class="hljs-string">"$TEST_DIR"
}
@<span class="hljs-built_in">test <span class="hljs-string">"script creates output file" {
run ./scripts/generate-report.sh --output <span class="hljs-string">"$TEST_DIR/report.txt"
[ <span class="hljs-string">"$status" -eq 0 ]
[ -f <span class="hljs-string">"$TEST_DIR/report.txt" ]
}
@<span class="hljs-built_in">test <span class="hljs-string">"script fails on missing input" {
run ./scripts/generate-report.sh --output <span class="hljs-string">"$TEST_DIR/report.txt" --input /nonexistent
[ <span class="hljs-string">"$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 <span class="hljs-string">"curl fails gracefully on unreachable host" {
run curl --connect-timeout 1 http://192.0.2.1/
[ <span class="hljs-string">"$status" -ne 0 ]
[[ <span class="hljs-string">"$output" == *<span class="hljs-string">"Connection refused"* ]] <span class="hljs-pipe">|| [[ <span class="hljs-string">"$output" == *<span class="hljs-string">"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 <span class="hljs-string">'libs/bats-support/load'
load <span class="hljs-string">'libs/bats-assert/load'
@<span class="hljs-built_in">test <span class="hljs-string">"deploy script prints success message" {
run ./scripts/deploy.sh --<span class="hljs-built_in">env staging
assert_success
assert_output --partial <span class="hljs-string">"Deployment complete"
}
@<span class="hljs-built_in">test <span class="hljs-string">"deploy script fails with invalid env" {
run ./scripts/deploy.sh --<span class="hljs-built_in">env production_typo
assert_failure
assert_output --partial <span class="hljs-string">"Unknown environment"
}
@<span class="hljs-built_in">test <span class="hljs-string">"config generator produces valid JSON" {
run ./scripts/generate-config.sh --service api
assert_success
run jq . <<< <span class="hljs-string">"$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 <span class="hljs-string">'libs/bats-support/load'
load <span class="hljs-string">'libs/bats-assert/load'
<span class="hljs-comment"># Mock aws CLI to avoid real AWS calls
<span class="hljs-function">aws() {
<span class="hljs-built_in">echo <span class="hljs-string">"MOCK: aws $*"
<span class="hljs-built_in">return 0
}
<span class="hljs-built_in">export -f aws
@<span class="hljs-built_in">test <span class="hljs-string">"deploy script calls aws s3 sync" {
run ./scripts/deploy-assets.sh --bucket my-bucket --<span class="hljs-built_in">dir ./dist
assert_success
assert_output --partial <span class="hljs-string">"MOCK: aws s3 sync"
}For more complex mocking where different calls need different behavior, use a counter or a fixture file:
curl() {
<span class="hljs-built_in">local call_count_file=<span class="hljs-string">"/tmp/bats-curl-calls"
<span class="hljs-built_in">local count
count=$(<span class="hljs-built_in">cat <span class="hljs-string">"$call_count_file" 2>/dev/null <span class="hljs-pipe">|| <span class="hljs-built_in">echo 0)
<span class="hljs-built_in">echo $((count + <span class="hljs-number">1)) > <span class="hljs-string">"$call_count_file"
<span class="hljs-keyword">case <span class="hljs-string">"$count" <span class="hljs-keyword">in
0) <span class="hljs-built_in">echo <span class="hljs-string">'{"status": "pending"}'; <span class="hljs-built_in">return 0 <span class="hljs-pipe">;;
1) <span class="hljs-built_in">echo <span class="hljs-string">'{"status": "running"}'; <span class="hljs-built_in">return 0 <span class="hljs-pipe">;;
2) <span class="hljs-built_in">echo <span class="hljs-string">'{"status": "success"}'; <span class="hljs-built_in">return 0 <span class="hljs-pipe">;;
*) <span class="hljs-built_in">return 1 <span class="hljs-pipe">;;
<span class="hljs-keyword">esac
}
<span class="hljs-built_in">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
<span class="hljs-comment">#!/usr/bin/env bash
<span class="hljs-built_in">set -euo pipefail
<span class="hljs-function">usage() {
<span class="hljs-built_in">echo <span class="hljs-string">"Usage: $0 --input FILE --output DIR [--format json|csv]" >&2
<span class="hljs-built_in">exit 1
}
FORMAT=<span class="hljs-string">"json"
INPUT=<span class="hljs-string">""
OUTPUT=<span class="hljs-string">""
<span class="hljs-keyword">while [[ <span class="hljs-variable">$# -gt 0 ]]; <span class="hljs-keyword">do
<span class="hljs-keyword">case <span class="hljs-string">"$1" <span class="hljs-keyword">in
--input) INPUT=<span class="hljs-string">"$2"; <span class="hljs-built_in">shift 2 <span class="hljs-pipe">;;
--output) OUTPUT=<span class="hljs-string">"$2"; <span class="hljs-built_in">shift 2 <span class="hljs-pipe">;;
--format) FORMAT=<span class="hljs-string">"$2"; <span class="hljs-built_in">shift 2 <span class="hljs-pipe">;;
*) usage <span class="hljs-pipe">;;
<span class="hljs-keyword">esac
<span class="hljs-keyword">done
[[ -z <span class="hljs-string">"$INPUT" <span class="hljs-pipe">|| -z <span class="hljs-string">"$OUTPUT" ]] && usage
[[ -f <span class="hljs-string">"$INPUT" ]] <span class="hljs-pipe">|| { <span class="hljs-built_in">echo <span class="hljs-string">"Error: Input file not found: $INPUT" >&2; <span class="hljs-built_in">exit 2; }
[[ <span class="hljs-string">"$FORMAT" =~ ^(json|csv)$ ]] <span class="hljs-pipe">|| { <span class="hljs-built_in">echo <span class="hljs-string">"Error: Invalid format: $FORMAT" >&2; <span class="hljs-built_in">exit 3; }
<span class="hljs-built_in">mkdir -p <span class="hljs-string">"$OUTPUT"
<span class="hljs-comment"># ... processing logic ...
<span class="hljs-built_in">echo <span class="hljs-string">"Processed $(wc -l < "$INPUT") lines to <span class="hljs-variable">$OUTPUT/<span class="hljs-variable">$FORMAT-output.<span class="hljs-variable">$FORMAT"The test file:
#!/usr/bin/env bats
load <span class="hljs-string">'libs/bats-support/load'
load <span class="hljs-string">'libs/bats-assert/load'
load <span class="hljs-string">'libs/bats-file/load'
SCRIPT=<span class="hljs-string">"./scripts/process-logs.sh"
<span class="hljs-function">setup() {
TEST_DIR=<span class="hljs-string">"$(mktemp -d)"
INPUT_FILE=<span class="hljs-string">"$TEST_DIR/input.log"
OUTPUT_DIR=<span class="hljs-string">"$TEST_DIR/output"
<span class="hljs-comment"># Create a test input file
<span class="hljs-built_in">printf <span class="hljs-string">"line one\nline two\nline three\n" > <span class="hljs-string">"$INPUT_FILE"
}
<span class="hljs-function">teardown() {
<span class="hljs-built_in">rm -rf <span class="hljs-string">"$TEST_DIR"
}
@<span class="hljs-built_in">test <span class="hljs-string">"fails without required arguments" {
run <span class="hljs-string">"$SCRIPT"
assert_failure
assert_output --partial <span class="hljs-string">"Usage:"
}
@<span class="hljs-built_in">test <span class="hljs-string">"fails with missing --input" {
run <span class="hljs-string">"$SCRIPT" --output <span class="hljs-string">"$OUTPUT_DIR"
assert_failure
assert_output --partial <span class="hljs-string">"Usage:"
}
@<span class="hljs-built_in">test <span class="hljs-string">"fails when input file does not exist" {
run <span class="hljs-string">"$SCRIPT" --input /nonexistent/file.log --output <span class="hljs-string">"$OUTPUT_DIR"
[ <span class="hljs-string">"$status" -eq 2 ]
assert_output --partial <span class="hljs-string">"Input file not found"
}
@<span class="hljs-built_in">test <span class="hljs-string">"fails with invalid format" {
run <span class="hljs-string">"$SCRIPT" --input <span class="hljs-string">"$INPUT_FILE" --output <span class="hljs-string">"$OUTPUT_DIR" --format xml
[ <span class="hljs-string">"$status" -eq 3 ]
assert_output --partial <span class="hljs-string">"Invalid format"
}
@<span class="hljs-built_in">test <span class="hljs-string">"succeeds with valid arguments and default format" {
run <span class="hljs-string">"$SCRIPT" --input <span class="hljs-string">"$INPUT_FILE" --output <span class="hljs-string">"$OUTPUT_DIR"
assert_success
assert_output --partial <span class="hljs-string">"3 lines"
assert_dir_exists <span class="hljs-string">"$OUTPUT_DIR"
}
@<span class="hljs-built_in">test <span class="hljs-string">"succeeds with csv format" {
run <span class="hljs-string">"$SCRIPT" --input <span class="hljs-string">"$INPUT_FILE" --output <span class="hljs-string">"$OUTPUT_DIR" --format csv
assert_success
assert_file_exists <span class="hljs-string">"$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 <span class="hljs-built_in">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" <span class="hljs-built_in">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
<span class="hljs-function">create_test_env() {
TEST_DIR=<span class="hljs-string">"$(mktemp -d)"
<span class="hljs-built_in">export TEST_DIR
<span class="hljs-built_in">export CONFIG_FILE=<span class="hljs-string">"$TEST_DIR/config.yaml"
<span class="hljs-built_in">cat > <span class="hljs-string">"$CONFIG_FILE" <<<span class="hljs-string">EOF
environment: test
log_level: debug
EOF
}
<span class="hljs-function">cleanup_test_env() {
<span class="hljs-built_in">rm -rf <span class="hljs-string">"${TEST_DIR:-}"
}Load it in test files with:
load '../helpers/common'
load <span class="hljs-string">'../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 <span class="hljs-string">"requires docker" {
<span class="hljs-built_in">command -v docker &>/dev/null <span class="hljs-pipe">|| skip <span class="hljs-string">"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