Shell Scripting BATS Testing Intermediate May 2026

Shell Scripting Testing Shell Scripts

Write automated tests for bash scripts using BATS (Bash Automated Testing System). Test functions, exit codes, output, and file side-effects. Run tests in CI and measure coverage with manual tracing.

Untested scripts are technical debt waiting to explode. BATS gives bash the same test-driven workflow you'd expect in any programming language — describe blocks, assertion helpers, setup/teardown, and a clean pass/fail report. Once you've written tests for your core functions, refactoring becomes safe and deployments become predictable.

BASH
# ── Install BATS and helpers ──────────────────────────────
git clone https://github.com/bats-core/bats-core.git
cd bats-core && ./install.sh /usr/local

# Helper libraries
git clone https://github.com/bats-core/bats-support.git test/lib/bats-support
git clone https://github.com/bats-core/bats-assert.git  test/lib/bats-assert
git clone https://github.com/bats-core/bats-file.git    test/lib/bats-file

# Or via package manager
brew install bats-core         # macOS
apt  install bats              # Ubuntu 20+

# Verify
bats --version                 # Bats 1.x.x
BASH
#!/usr/bin/env bats
# tests/test_utils.bats — Tests for lib/utils.sh

# Load helpers
load 'lib/bats-support/load'
load 'lib/bats-assert/load'
load 'lib/bats-file/load'

# Setup runs before each test
setup() {
  source "${BATS_TEST_DIRNAME}/../lib/utils.sh"
  export TMPDIR
  TMPDIR=$(mktemp -d)
}

# Teardown runs after each test
teardown() {
  rm -rf "${TMPDIR}"
}

# ── Tests ─────────────────────────────────────────────────
@test "validate_hostname accepts valid hostnames" {
  run validate_hostname "prod-db-01"
  assert_success
  run validate_hostname "web.example.com"
  assert_success
}

@test "validate_hostname rejects invalid hostnames" {
  run validate_hostname "prod db"            # space
  assert_failure
  run validate_hostname "prod;rm -rf /"     # injection
  assert_failure
  run validate_hostname ""                   # empty
  assert_failure
}

@test "validate_port accepts valid ports" {
  run validate_port "3306"
  assert_success
  run validate_port "65535"
  assert_success
}

@test "validate_port rejects invalid ports" {
  run validate_port "0"
  assert_failure
  run validate_port "65536"
  assert_failure
  run validate_port "abc"
  assert_failure
}
BASH
#!/usr/bin/env bats
# tests/test_backup.bats

load 'lib/bats-support/load'
load 'lib/bats-assert/load'
load 'lib/bats-file/load'

setup() {
  export BACKUP_DIR
  BACKUP_DIR=$(mktemp -d)
  export DB_HOST="localhost"
  export DB_USER="test"
  export DB_PASS="test"
}

teardown() { rm -rf "${BACKUP_DIR}"; }

@test "backup creates output file" {
  run ./bin/backup -o "${BACKUP_DIR}" testdb
  assert_success
  assert_file_exist "${BACKUP_DIR}/testdb_"*".sql"
}

@test "backup with -c creates gzipped file" {
  run ./bin/backup -c -o "${BACKUP_DIR}" testdb
  assert_success
  assert_file_exist "${BACKUP_DIR}/testdb_"*".sql.gz"
}

@test "backup fails without database name" {
  run ./bin/backup -o "${BACKUP_DIR}"
  assert_failure
  assert_output --partial "database name required"
}

@test "backup prints version with -V" {
  run ./bin/backup -V
  assert_success
  assert_output --regexp "v[0-9]+\.[0-9]+\.[0-9]+"
}

@test "backup logs to stderr on error" {
  DB_HOST="nonexistent" run ./bin/backup testdb
  assert_failure
  # stderr is accessible via $output when captured
  assert_output --partial "ERROR"
}
BASH
#!/usr/bin/env bats
# Mock external commands to test without real dependencies

setup() {
  source "${BATS_TEST_DIRNAME}/../lib/network.sh"
  # Create a bin dir in PATH for mock commands
  MOCK_BIN=$(mktemp -d)
  export PATH="${MOCK_BIN}:${PATH}"
}

teardown() { rm -rf "${MOCK_BIN}"; }

create_mock() {
  local cmd="${1}" exit_code="${2:-0}" output="${3:-}"
  cat > "${MOCK_BIN}/${cmd}" << MOCK
#!/usr/bin/env bash
echo "${output}"
exit ${exit_code}
MOCK
  chmod +x "${MOCK_BIN}/${cmd}"
}

@test "check_http succeeds when curl returns 200" {
  create_mock curl 0 "200"   # mock curl to return "200"
  run check_http "http://example.com/health"
  assert_success
}

@test "check_http fails when curl returns 500" {
  create_mock curl 0 "500"
  run check_http "http://example.com/health"
  assert_failure
}

@test "check_http fails when curl exits non-zero" {
  create_mock curl 1 ""    # curl fails (network error)
  run check_http "http://example.com/health"
  assert_failure
}
BASH
# ── Run all tests ─────────────────────────────────────────
bats tests/                      # run all .bats files in dir
bats tests/test_utils.bats       # run specific file
bats --tap tests/                # TAP format output
bats --junit tests/              # JUnit XML for CI
bats -j 4 tests/                 # parallel (4 jobs)

# ── GitHub Actions CI ─────────────────────────────────────
# .github/workflows/test.yml
# name: Test
# on: [push, pull_request]
# jobs:
#   test:
#     runs-on: ubuntu-latest
#     steps:
#       - uses: actions/checkout@v4
#       - run: sudo apt-get install -y bats
#       - run: bats tests/

# ── Makefile for convenience ──────────────────────────────
# test:
#     bats tests/
# lint:
#     shellcheck bin/* lib/*.sh
# check: lint test
Key Test passed Test failed Skipped
bash — bats test suite
vriddh@prod-01:~/myapp-scripts$bats tests/
tests/test_utils.bats
✓ validate_hostname accepts valid hostnames
✓ validate_hostname rejects invalid hostnames
✓ validate_port accepts valid ports
✓ validate_port rejects invalid ports
tests/test_backup.bats
✓ backup creates output file
✓ backup with -c creates gzipped file
✓ backup fails without database name
✗ backup logs to stderr on error
(in test file tests/test_backup.bats, line 42)
7 tests, 1 failure
✔ Testing rules — Use setup() for per-test initialisation and teardown() for cleanup. Always test both success and failure paths. Mock external commands by prepending a temp dir to $PATH. Use assert_file_exist, assert_output, and assert_success/failure for readable assertions. Run shellcheck and bats together in CI — they catch different classes of bugs.