BATS: Automated Testing for Bash Scripts

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-core

Via npm (works anywhere Node is installed):

npm install --save-dev bats

Clone 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-file

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

Output:

 ✓ addition using bc

1 test, 0 failures

Each @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 0
  • assert_failure — exit code is non-zero
  • assert_output "exact string" — output matches exactly
  • assert_output --partial "substring" — output contains substring
  • assert_output --regexp "pattern" — output matches regex
  • refute_output --partial "string" — output does NOT contain string
  • assert_line "string" — any output line matches
  • assert_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 curl

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

Run 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.tap

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

A 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 explicitlyassert_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

Read more

Start now free