| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10bats 1.11.1-1.el10_0parallel 20260822-3.el10_2 (only for --jobs) |
| Applies to | Linux distributions with Bash and bats-core installed |
| Privilege | Normal user |
| Scope | Bats (bats-core) for Bash unit testing: .bats files, run, exit status and stdout/stderr capture (including --separate-stderr), run -N, setup/teardown and file hooks, BATS_TEST_TMPDIR, sourcing libraries, bats_pipe, optional bats-support/bats-assert, parallel --jobs, debugging flags, and CI JUnit reports. Does not replace ShellCheck static analysis. |
| Related guides | Bash function Bash error handling |
Bash does not ship a built-in test runner. Bats (Bash Automated Testing System) runs .bats files so you can assert on exit codes and captured output instead of hand-rolling if checks.
The mental model is: script → .bats file → @test → run → $status / $output → bats file.bats. Create a lab directory, then try the smallest complete example:
mkdir -p /tmp/bash-bats-labWrite a tiny script under that directory:
printf '#!/usr/bin/env bash\necho "Hello World"\n' > /tmp/bash-bats-lab/hello.sh
chmod +x /tmp/bash-bats-lab/hello.shAdd a one-case .bats file beside the script:
cat <<'BATS' > /tmp/bash-bats-lab/hello.bats
@test "script prints expected output" {
run /tmp/bash-bats-lab/hello.sh
[ "$status" -eq 0 ]
[ "$output" = "Hello World" ]
}
BATSRun it with --formatter tap so the transcript matches whether or not your shell is connected to a terminal:
bats --formatter tap /tmp/bash-bats-lab/hello.batsSample output:
1..1
ok 1 script prints expected outputThe rest of this guide tests a small check-config CLI — success on stdout, diagnostics on stderr, usage exit code 2, fixtures, sourced helpers, and CI wiring — so you see how those pieces fit a real script.
What Is Bats?
Bats is a Bash-based testing framework commonly used for shell scripts and command-line programs. Each case is a function marked @test in a file ending in .bats. The runner prints TAP-style ok / not ok lines.
Each test runs in its own process, so variable changes made inside one test do not carry over to another. Use setup, setup_file, fixtures, or exported setup variables when tests need the same initial environment.
| Tool | What it checks |
|---|---|
| Bats | Runtime behavior — exit codes, output, files, and integration |
ShellCheck does not execute your usage() function; Bats does. Use both.
Install Bats
Bats itself needs only the bats package. GNU parallel (or a compatible tool such as rush) is required only when you pass --jobs.
| Platform | Installation |
|---|---|
| RHEL / Rocky / Alma (EPEL) | sudo dnf install bats |
| Fedora | sudo dnf install bats |
| Ubuntu / Debian | sudo apt install bats |
| macOS | brew install bats-core |
| npm | npm install -g bats |
Distribution repositories may provide older Bats releases. Run bats --version and use an upstream installation method if the packaged version is older than the version required by your tests.
On the lab host used for this article:
sudo dnf install -y batsConfirm the version — examples here were captured against Bats 1.11.x:
bats --versionSample output:
Bats 1.11.1Install GNU parallel only when you plan to use parallel jobs:
sudo dnf install -y parallelSee the upstream installation guide for source installs, Docker images, and other platforms.
Create a Script Designed to Be Testable
Separate machine-readable stdout from diagnostics, document exit codes, and guard main so tests can source the file without side effects.
Create the project directories the examples write into:
mkdir -p /tmp/bash-bats-lab/{bin,lib,test/fixtures}Write check-config:
cat <<'SCRIPT' > /tmp/bash-bats-lab/bin/check-config
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf 'usage: check-config <file>\n' >&2
exit 2
}
normalize_mode() {
case "${CHECK_CONFIG_MODE:-enabled}" in
enabled|disabled) printf '%s' "${CHECK_CONFIG_MODE:-enabled}" ;;
*)
printf 'error: invalid mode: %s\n' "${CHECK_CONFIG_MODE}" >&2
return 3
;;
esac
}
validate_file() {
local file="$1"
if [[ ! -f "$file" ]]; then
printf 'error: file not found: %s\n' "$file" >&2
return 1
fi
if [[ ! -r "$file" ]]; then
printf 'error: file not readable: %s\n' "$file" >&2
return 1
fi
return 0
}
emit_result() {
local file="$1"
local mode
mode=$(normalize_mode) || return $?
printf 'ok:%s:%s\n' "$mode" "$file"
}
main() {
[[ $# -eq 1 ]] || usage
local file="$1"
validate_file "$file" || exit 1
emit_result "$file"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
SCRIPTMake it executable and add fixtures:
chmod +x /tmp/bash-bats-lab/bin/check-config
printf 'enabled\n' > /tmp/bash-bats-lab/test/fixtures/app.conf
printf 'disabled\n' > "/tmp/bash-bats-lab/test/fixtures/my app.conf"Write Your First .bats Test
Bats test files usually live under test/. Add shared paths once per file in setup_file:
cat <<'BATS' > /tmp/bash-bats-lab/test/check-config.bats
bats_require_minimum_version 1.11.0
setup_file() {
export LAB_ROOT="${BATS_TEST_DIRNAME}/.."
export CHECK_CONFIG="${LAB_ROOT}/bin/check-config"
export FIXTURE_DIR="${LAB_ROOT}/test/fixtures"
}
@test "check-config prints ok line for valid file" {
run "$CHECK_CONFIG" "${FIXTURE_DIR}/app.conf"
[ "$status" -eq 0 ]
[ "$output" = "ok:enabled:${FIXTURE_DIR}/app.conf" ]
}
BATSRun the file:
bats --formatter tap /tmp/bash-bats-lab/test/check-config.batsSample output:
1..1
ok 1 check-config prints ok line for valid fileThe 1..1 line is TAP syntax: one test was scheduled and one ran. Use --formatter tap whenever you need this deterministic format in notes or CI logs.
Test Exit Codes and Output with run
Use run when you need to inspect a command's exit status or captured output — especially for expected failures. If you only need to verify that a command succeeds and do not need $output, you can often invoke the command directly inside the test.
run exposes:
$status— exit code of the command$output— combined stdout and stderr (trimmed trailing newlines) by default${lines[@]}—$outputsplit into lines
Append a filename-with-spaces case:
cat <<'BATS' >> /tmp/bash-bats-lab/test/check-config.bats
@test "check-config handles filenames with spaces" {
run "$CHECK_CONFIG" "${FIXTURE_DIR}/my app.conf"
[ "$status" -eq 0 ]
[ "${lines[0]}" = "ok:enabled:${FIXTURE_DIR}/my app.conf" ]
}
BATSRun the suite again:
bats /tmp/bash-bats-lab/test/check-config.batsFor expected non-zero exits, assert $status explicitly:
run "$CHECK_CONFIG"
[ "$status" -eq 2 ]Bats also accepts the expected status on run itself:
run -2 "$CHECK_CONFIG"run -2 fails the test if the command exits with any status other than 2. To accept any non-zero exit status, use run !:
run ! "$CHECK_CONFIG" "${FIXTURE_DIR}/missing.conf"Explicit [ "$status" -eq N ] checks are easy to read; run -N encodes the expected status in the invocation.
Test stdout and stderr Separately
By default, run merges stderr into $output. When stdout must stay clean — as with check-config — use run --separate-stderr:
cat <<'BATS' >> /tmp/bash-bats-lab/test/check-config.bats
@test "missing file writes diagnostic to stderr" {
run --separate-stderr "$CHECK_CONFIG" "${FIXTURE_DIR}/missing.conf"
[ "$status" -eq 1 ]
[ -z "$output" ]
[[ "$stderr" == *"error: file not found"* ]]
}
BATSAfter run --separate-stderr, Bats sets $stderr and ${stderr_lines[@]} separately from $output and ${lines[@]}. Run the suite:
bats /tmp/bash-bats-lab/test/check-config.batsUse --separate-stderr when you need distinct stdout and stderr assertions. Use the default run when you do not need to distinguish stdout from stderr.
Test Error Cases
Add a usage check with run -2:
cat <<'BATS' >> /tmp/bash-bats-lab/test/check-config.bats
@test "check-config rejects missing arguments with usage" {
run --separate-stderr -2 "$CHECK_CONFIG"
[ -z "$output" ]
[[ "$stderr" == *"usage: check-config <file>"* ]]
}
BATSRun the file:
bats /tmp/bash-bats-lab/test/check-config.batsWhen you expect a command to fail and need to inspect its status or output, use run, run -N, or run ! instead of relying on a bare non-zero command and Bats' errexit behavior.
Use setup, teardown, and Fixtures
setup runs before each @test; teardown runs afterward, including when the test fails. Use them for per-test files under BATS_TEST_TMPDIR:
cat <<'BATS' > /tmp/bash-bats-lab/test/hooks.bats
setup() {
TEST_FILE="${BATS_TEST_TMPDIR}/config"
printf 'enabled\n' >"$TEST_FILE"
}
teardown() {
rm -f "$TEST_FILE"
}
@test "setup creates a readable config file" {
[ -r "$TEST_FILE" ]
[ "$(<"$TEST_FILE")" = "enabled" ]
}
BATSsetup_file and teardown_file run once per .bats file — the check-config tests already export CHECK_CONFIG and FIXTURE_DIR there.
Suite-wide hooks belong in a separate setup_suite.bash. For this layout, that means test/setup_suite.bash. Bats automatically looks for it beside the first .bats file in the suite; --setup-suite-file can specify another location. Do not define setup_suite inside arbitrary .bats files — Bats warns that it will not run as the suite hook there. Use suite hooks when many files share one database or mock service; most script suites only need per-file hooks.
Each test gets a private directory in $BATS_TEST_TMPDIR. Use it instead of hard-coded /tmp paths that collide under --jobs — the same idea as Bash mktemp in production scripts.
Test Environment Variables and Bash Functions
Prefix VAR=value before run to scope an override to one invocation:
cat <<'BATS' >> /tmp/bash-bats-lab/test/check-config.bats
@test "check-config respects CHECK_CONFIG_MODE" {
CHECK_CONFIG_MODE=disabled run "$CHECK_CONFIG" "${FIXTURE_DIR}/app.conf"
[ "$status" -eq 0 ]
[ "$output" = "ok:disabled:${FIXTURE_DIR}/app.conf" ]
}
BATSPut reusable logic in a library and test it with load:
cat <<'LIB' > /tmp/bash-bats-lab/lib/config_lib.sh
basename_config() {
basename -- "$1"
}
LIBAppend a function test to the same .bats file:
cat <<'BATS' >> /tmp/bash-bats-lab/test/check-config.bats
@test "basename_config returns the final path segment" {
load "${BATS_TEST_DIRNAME}/../lib/config_lib.sh"
run basename_config "/etc/myapp/prod.conf"
[ "$status" -eq 0 ]
[ "$output" = "prod.conf" ]
}
BATSRun the suite:
bats /tmp/bash-bats-lab/test/check-config.batsTest Pipelines with bats_pipe
Piping outside run does not capture the pipeline the way most people expect:
# Wrong: the pipe is outside run
run "$CHECK_CONFIG" "${FIXTURE_DIR}/app.conf" | grep okUse bats_pipe so run sees the full pipeline:
cat <<'BATS' >> /tmp/bash-bats-lab/test/check-config.bats
@test "pipeline output contains ok prefix" {
run bats_pipe "$CHECK_CONFIG" "${FIXTURE_DIR}/app.conf" \| grep '^ok:'
[ "$status" -eq 0 ]
[[ "$output" == ok:enabled:* ]]
}
BATSEscape | as \| so it is passed to bats_pipe as an argument instead of being interpreted as a pipeline by the test's shell.
Improve Assertions with bats-assert
Plain [ "$output" = "expected" ] fails with little context. bats-assert shows expected versus actual output — the main reason teams add it beyond readability.
Clone bats-support and bats-assert into test/test_helper/:
mkdir -p /tmp/bash-bats-lab/test/test_helperClone bats-support into that directory:
git clone --depth 1 https://github.com/bats-core/bats-support.git /tmp/bash-bats-lab/test/test_helper/bats-supportClone bats-assert beside it:
git clone --depth 1 https://github.com/bats-core/bats-assert.git /tmp/bash-bats-lab/test/test_helper/bats-assertWire the libraries into a helper test file:
cat <<'BATS' > /tmp/bash-bats-lab/test/assert_helpers.bats
setup() {
export LAB_ROOT="${BATS_TEST_DIRNAME}/.."
export CHECK_CONFIG="${LAB_ROOT}/bin/check-config"
export FIXTURE_DIR="${LAB_ROOT}/test/fixtures"
BATS_LIB_PATH="${BATS_TEST_DIRNAME}/test_helper"
bats_load_library bats-support
bats_load_library bats-assert
}
@test "assert_output shows mismatches clearly" {
run "$CHECK_CONFIG" "${FIXTURE_DIR}/app.conf"
assert_success
assert_output "ok:enabled:${FIXTURE_DIR}/app.conf"
}
BATSFor filesystem-heavy scripts, bats-file adds assertions for files, directories, and related properties.
Run Selected and Parallel Tests
Filter by name while debugging:
bats --filter 'spaces' /tmp/bash-bats-lab/test/check-config.batsSkip a test temporarily with the skip command inside the test body, or tag tests and filter with --filter-tags when you organize larger suites.
--jobs N enables parallel execution across files and within files. Test execution order is not guaranteed, so tests must not depend on another test running first or share mutable state. GNU parallel (or rush) must be on PATH. Run the suite serially before enabling --jobs.
bats --jobs 2 /tmp/bash-bats-lab/test/check-config.bats /tmp/bash-bats-lab/test/hooks.bats /tmp/bash-bats-lab/test/assert_helpers.batsbats test/ runs only .bats files directly in test/, not nested subdirectories. For project tests under test/unit/:
bats --recursive /tmp/bash-bats-lab/test/unit/Do not run bats --recursive test/ when test/test_helper/ contains cloned upstream repos — those repositories ship their own .bats files. Point --recursive at your test directories only, or list .bats files explicitly.
Debug Failed Bats Tests
When a test fails, print captured output automatically:
bats --print-output-on-failure /tmp/bash-bats-lab/test/check-config.batsTrace commands as they run:
bats --trace /tmp/bash-bats-lab/test/check-config.batsCombine --filter with --print-output-on-failure while fixing one case. bats --count test/ reports how many tests would run without executing them.
Run Bats in CI
A minimal GitHub Actions job pins the Bats version to match this article's bats_require_minimum_version 1.11.0 tests. Distribution apt packages on Ubuntu runners are often older — Noble ships Bats 1.10.0, which is below the version these examples require.
The support-path and assert-path inputs install bats-support and bats-assert into test/test_helper/, matching the BATS_LIB_PATH layout from the bats-assert section earlier in this guide.
Bats exits 0 when all tests pass and 1 when any test fails, so a normal shell step fails the job without log parsing:
jobs:
bats:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Bats
uses: bats-core/bats-action@4.0.0
with:
bats-version: "1.11.1"
support-path: ${{ github.workspace }}/test/test_helper/bats-support
assert-path: ${{ github.workspace }}/test/test_helper/bats-assert
- name: Run tests
run: bats test/*.batsGenerate JUnit XML for CI dashboards:
mkdir -p /tmp/bash-bats-lab/reports
bats --report-formatter junit --output /tmp/bash-bats-lab/reports /tmp/bash-bats-lab/test/check-config.batsThe report lands in reports/report.xml. Add GNU parallel to the workflow only when the test step passes --jobs.
Common Testing Mistakes
| Mistake | Why it hurts | Better approach |
|---|---|---|
Calling a failing command without run |
Non-zero exit fails the test early | run or run -N, then assert |
run cmd | grep |
Pipe is outside run |
run bats_pipe cmd | grep … |
Shared /tmp paths |
Parallel runs race | BATS_TEST_TMPDIR per test |
Default run when streams differ |
stderr pollutes $output |
run --separate-stderr |
setup_suite inside a .bats file |
setup_suite will not run as the suite hook |
test/setup_suite.bash |
Expecting nested tests without -r |
Subdirectory .bats files are skipped |
bats --recursive test/ |
run for every success-only check |
Extra noise | Invoke the command directly when output is not needed |
Summary
Bats unit-tests Bash scripts through .bats files, @test cases, and run for exit status and captured output. The check-config walkthrough covers success stdout, stderr diagnostics with --separate-stderr, usage failures with run -2, fixtures, environment overrides, and sourced helpers.
Use setup, teardown, and BATS_TEST_TMPDIR for isolated state; add bats-assert when you want clearer failure messages. Run the suite serially first, then --jobs once tests do not share mutable paths. Wire your Bats test files into CI and emit JUnit reports when the CI dashboard needs structured results.
References
- bats-core documentation
- Installation
- Writing tests
- Usage and CLI options
- bats-core on GitHub
- bats-support
- bats-assert
- bats-file
Frequently Asked Questions
Is Bats only for Bash programs?
No. Bats tests are written in Bash, but the commands under test do not have to be Bash scripts. You can use Bats to test shell scripts, CLI tools, and other UNIX programs by running them and asserting on their exit status and output.
How do I test a command that should fail?
Wrap it in run, assert $status, or use run -N when you expect a specific exit code. Do not call the failing command bare when you need to inspect the result.
How do I test Bash functions?
Put functions in a library or guard main with [[ "${BASH_SOURCE[0]}" == "${0}" ]], then load the file and run the function inside a @test.
How do I test stderr separately in Bats?
Use run --separate-stderr command, then assert on $stderr and ${stderr_lines[@]} while keeping $output for stdout only.
Why is Bats not finding tests in subdirectories?
bats test/ only runs .bats files directly in that directory. Use bats --recursive test/unit/ (or another directory that contains only your tests) to include nested layouts.

