How to Test Bash Scripts with Bats (Bash Unit Testing)

Tested on RHEL 10.2 (Coughlan)
Package bash 5.2.26-6.el10
bats 1.11.1-1.el10_0
parallel 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
Short on time?Beta Get the gist of this 12-minute read in seconds, then ask follow-up questions

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:

bash
mkdir -p /tmp/bash-bats-lab

Write a tiny script under that directory:

bash
printf '#!/usr/bin/env bash\necho "Hello World"\n' > /tmp/bash-bats-lab/hello.sh
chmod +x /tmp/bash-bats-lab/hello.sh

Add a one-case .bats file beside the script:

bash
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" ]
}
BATS

Run it with --formatter tap so the transcript matches whether or not your shell is connected to a terminal:

bash
bats --formatter tap /tmp/bash-bats-lab/hello.bats

Sample output:

output
1..1
ok 1 script prints expected output

The 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:

bash
sudo dnf install -y bats

Confirm the version — examples here were captured against Bats 1.11.x:

bash
bats --version

Sample output:

output
Bats 1.11.1

Install GNU parallel only when you plan to use parallel jobs:

bash
sudo dnf install -y parallel

See 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:

bash
mkdir -p /tmp/bash-bats-lab/{bin,lib,test/fixtures}

Write check-config:

bash
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
SCRIPT

Make it executable and add fixtures:

bash
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:

bash
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" ]
}
BATS

Run the file:

bash
bats --formatter tap /tmp/bash-bats-lab/test/check-config.bats

Sample output:

output
1..1
ok 1 check-config prints ok line for valid file

The 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[@]} — $output split into lines

Append a filename-with-spaces case:

bash
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" ]
}
BATS

Run the suite again:

bash
bats /tmp/bash-bats-lab/test/check-config.bats

For expected non-zero exits, assert $status explicitly:

bash
run "$CHECK_CONFIG"
[ "$status" -eq 2 ]

Bats also accepts the expected status on run itself:

bash
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 !:

bash
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:

bash
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"* ]]
}
BATS

After run --separate-stderr, Bats sets $stderr and ${stderr_lines[@]} separately from $output and ${lines[@]}. Run the suite:

bash
bats /tmp/bash-bats-lab/test/check-config.bats

Use --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:

bash
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>"* ]]
}
BATS

Run the file:

bash
bats /tmp/bash-bats-lab/test/check-config.bats

When 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:

bash
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" ]
}
BATS

setup_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:

bash
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" ]
}
BATS

Put reusable logic in a library and test it with load:

bash
cat <<'LIB' > /tmp/bash-bats-lab/lib/config_lib.sh
basename_config() {
  basename -- "$1"
}
LIB

Append a function test to the same .bats file:

bash
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" ]
}
BATS

Run the suite:

bash
bats /tmp/bash-bats-lab/test/check-config.bats

Test Pipelines with bats_pipe

Piping outside run does not capture the pipeline the way most people expect:

bash
# Wrong: the pipe is outside run
run "$CHECK_CONFIG" "${FIXTURE_DIR}/app.conf" | grep ok

Use bats_pipe so run sees the full pipeline:

bash
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:* ]]
}
BATS

Escape | 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/:

bash
mkdir -p /tmp/bash-bats-lab/test/test_helper

Clone bats-support into that directory:

bash
git clone --depth 1 https://github.com/bats-core/bats-support.git /tmp/bash-bats-lab/test/test_helper/bats-support

Clone bats-assert beside it:

bash
git clone --depth 1 https://github.com/bats-core/bats-assert.git /tmp/bash-bats-lab/test/test_helper/bats-assert

Wire the libraries into a helper test file:

bash
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"
}
BATS

For filesystem-heavy scripts, bats-file adds assertions for files, directories, and related properties.


Run Selected and Parallel Tests

Filter by name while debugging:

bash
bats --filter 'spaces' /tmp/bash-bats-lab/test/check-config.bats

Skip 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.

bash
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.bats

bats test/ runs only .bats files directly in test/, not nested subdirectories. For project tests under test/unit/:

bash
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:

bash
bats --print-output-on-failure /tmp/bash-bats-lab/test/check-config.bats

Trace commands as they run:

bash
bats --trace /tmp/bash-bats-lab/test/check-config.bats

Combine --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:

yaml
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/*.bats

Generate JUnit XML for CI dashboards:

bash
mkdir -p /tmp/bash-bats-lab/reports
bats --report-formatter junit --output /tmp/bash-bats-lab/reports /tmp/bash-bats-lab/test/check-config.bats

The 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


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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)