Advanced Testing with BATS

Chapter 8 — Advanced Testing with BATS

BATS (Bash Automated Testing System) turns shell scripts into first-class testable software. Where ad-hoc if checks give you fragile one-off verification, BATS gives you structured test suites with TAP output, fixtures, mocking, and CI integration. This chapter covers the full depth of BATS: test organisation, lifecycle hooks, filesystem fixtures, command mocking, testing scripts that modify the system, and wiring everything into a pipeline.

1 — BATS Architecture and Installation

Modern BATS is split across three repositories. You need all three for a complete setup.

PackageProvides
bats-coreThe test runner, @test syntax, TAP output
bats-supportHelper functions: fail, assert output formatting
bats-assertAssertion library: assert_output, assert_success, etc.
bats-fileFilesystem assertions: assert_file_exists, assert_dir_empty, etc.
# Install via git submodules (recommended for project-local install)
git submodule add https://github.com/bats-core/bats-core    test/bats
git submodule add https://github.com/bats-core/bats-support  test/test_helper/bats-support
git submodule add https://github.com/bats-core/bats-assert   test/test_helper/bats-assert
git submodule add https://github.com/bats-core/bats-file     test/test_helper/bats-file

# Or via npm (if Node is available)
npm install --save-dev bats

# Or via Homebrew / apt
brew install bats-core
apt-get install bats

# Run a test file
bats test/my_test.bats

# Run all .bats files recursively
bats --recursive test/

# Parallel execution — run test files in parallel
bats --jobs 4 --recursive test/

# Output formats
bats --formatter tap        test/   # TAP (default)
bats --formatter pretty     test/   # coloured human output
bats --formatter junit      test/   # JUnit XML for CI systems
bats --report-formatter junit --output reports/ test/

2 — Test File Structure

#!/usr/bin/env bats
# test/mylib.bats

# Load helper libraries
setup_file() {
  # Runs ONCE before the first test in this file
  load 'test_helper/bats-support/load'
  load 'test_helper/bats-assert/load'
  load 'test_helper/bats-file/load'

  # Make the script under test available
  export SCRIPT="${BATS_TEST_DIRNAME}/../bin/myapp.sh"
  # BATS_TEST_DIRNAME is the directory containing this .bats file
}

teardown_file() {
  # Runs ONCE after the last test in this file
  # Good for: removing shared DB state, stopping a server, etc.
  :
}

setup() {
  # Runs before EVERY individual test
  # BATS_TEST_TMPDIR is a per-test temp directory (auto-cleaned)
  TEST_HOME="${BATS_TEST_TMPDIR}/home"
  mkdir -p "$TEST_HOME"
}

teardown() {
  # Runs after EVERY individual test — even if the test failed
  # BATS_TEST_TMPDIR is automatically removed after teardown
  :
}

# A basic test
@test "myapp exits 0 with valid input" {
  run "$SCRIPT" --input "hello"
  assert_success
}

@test "myapp prints expected output" {
  run "$SCRIPT" --input "hello"
  assert_output "HELLO"
}

@test "myapp exits 1 with empty input" {
  run "$SCRIPT" --input ""
  assert_failure
  assert_output --partial "input required"
}

3 — The run Command and Assertions

The run command executes a command and captures its output and exit status without causing the test to fail. The results land in three variables: $status, $output, and $lines (array).

@test "run captures status and output" {
  run printf '%s\n%s\n' "line one" "line two"

  # $status — numeric exit code
  [ "$status" -eq 0 ]

  # $output — full stdout as a single string
  [ "$output" = "line one
line two" ]

  # $lines — indexed array of output lines
  [ "${lines[0]}" = "line one" ]
  [ "${lines[1]}" = "line two" ]
}

# run --separate-stderr (bats-core 1.5+): captures stdout and stderr separately
@test "stderr is separate from stdout" {
  run --separate-stderr bash -c 'echo out; echo err >&2'
  assert_output "out"
  assert_stderr "err"
}

# bats-assert assertions
@test "assert_output variants" {
  run echo "The quick brown fox"
  assert_output                         # non-empty
  assert_output "The quick brown fox"    # exact match
  assert_output --partial "brown"        # substring
  assert_output --regexp  'quick .* fox' # ERE regex
  refute_output --partial "cat"          # must NOT contain
}

@test "assert_line checks individual lines" {
  run printf 'alpha\nbeta\ngamma\n'
  assert_line "alpha"                    # any line equals "alpha"
  assert_line --index 1 "beta"           # line[1] == "beta"
  assert_line --partial "amm"            # any line contains "amm"
  refute_line "delta"                    # "delta" must not appear
}

# assert_equal, assert_not_equal — direct value comparison
@test "assert_equal" {
  local result
  result=$(echo "hello" | tr 'a-z' 'A-Z')
  assert_equal "$result" "HELLO"
}

4 — Fixtures and Test Isolation

BATS provides per-test isolation through $BATS_TEST_TMPDIR — a unique temporary directory created before each test and deleted after its teardown. Build all test filesystem state inside it.

setup() {
  load 'test_helper/bats-support/load'
  load 'test_helper/bats-assert/load'
  load 'test_helper/bats-file/load'

  # Build a reproducible directory structure for each test
  FIXTURE_DIR="${BATS_TEST_TMPDIR}/fixture"
  mkdir -p "$FIXTURE_DIR"/{src,dest,logs}

  # Populate with known-content files
  printf 'line1\nline2\nline3\n' > "$FIXTURE_DIR/src/input.txt"
  printf 'ERROR: disk full\nINFO: started\nWARN: retry\n' \
    > "$FIXTURE_DIR/logs/app.log"
}

@test "script copies file to dest" {
  run ../bin/copy_files.sh "$FIXTURE_DIR/src" "$FIXTURE_DIR/dest"
  assert_success
  assert_file_exists "$FIXTURE_DIR/dest/input.txt"
}

@test "script leaves source intact" {
  run ../bin/copy_files.sh "$FIXTURE_DIR/src" "$FIXTURE_DIR/dest"
  assert_file_exists     "$FIXTURE_DIR/src/input.txt"
  assert_file_not_empty  "$FIXTURE_DIR/src/input.txt"
}

@test "log parser counts errors" {
  run ../bin/parse_log.sh "$FIXTURE_DIR/logs/app.log"
  assert_success
  assert_output --partial "errors: 1"
}

Static fixture files

# Store static fixture files alongside your tests
# Conventional layout:
#   test/
#   ├── fixtures/
#   │   ├── config_valid.conf
#   │   ├── config_malformed.conf
#   │   └── sample_data.csv
#   └── myapp_test.bats

setup() {
  # BATS_TEST_DIRNAME always points to the directory of the .bats file
  FIXTURES="${BATS_TEST_DIRNAME}/fixtures"
}

@test "accepts valid config" {
  run ../bin/myapp.sh --config "$FIXTURES/config_valid.conf"
  assert_success
}

@test "rejects malformed config" {
  run ../bin/myapp.sh --config "$FIXTURES/config_malformed.conf"
  assert_failure
  assert_output --partial "invalid config"
}

5 — Mocking Commands with PATH Manipulation

The cleanest way to mock an external command in BATS is to create a fake executable on a PATH that is prepended for the duration of the test. The real command is never called; your mock controls what happens.

setup() {
  load 'test_helper/bats-support/load'
  load 'test_helper/bats-assert/load'

  # Create a directory for mock executables, prepend to PATH
  MOCK_DIR="${BATS_TEST_TMPDIR}/mocks"
  mkdir -p "$MOCK_DIR"
  export PATH="$MOCK_DIR:$PATH"
}

# Helper: create a mock that always succeeds and records its arguments
make_mock() {
  local name="$1"; shift
  local script="${MOCK_DIR}/${name}"
  printf '#!/usr/bin/env bash\nprintf "%%s\n" "$*" >> "%s/%s.calls"\n%s\n' \
    "$MOCK_DIR" "$name" "${*:-exit 0}" > "$script"
  chmod +x "$script"
}

# Helper: read recorded calls
mock_calls() {
  cat "${MOCK_DIR}/${1}.calls" 2>/dev/null
}

# Helper: count how many times a command was called
mock_call_count() {
  wc -l < "${MOCK_DIR}/${1}.calls" 2>/dev/null || echo 0
}

@test "deploy script calls systemctl reload" {
  make_mock systemctl
  make_mock rsync

  run ../bin/deploy.sh staging

  assert_success
  # Verify rsync was called exactly once
  assert_equal "$(mock_call_count rsync)" "1"
  # Verify systemctl was called with "reload nginx"
  run mock_calls systemctl
  assert_output --partial "reload nginx"
}

@test "deploy script handles rsync failure" {
  # Mock rsync to fail
  make_mock rsync 'exit 1'
  make_mock systemctl

  run ../bin/deploy.sh staging
  assert_failure
  # systemctl should NOT have been called after rsync failure
  assert_equal "$(mock_call_count systemctl)" "0"
}

Mocking with output

# Mock that prints specific output (useful for testing command substitutions)
make_mock_output() {
  local name="$1"
  local output="$2"
  local exit_code="${3:-0}"
  printf '#!/usr/bin/env bash\nprintf "%%s\n" %s\nexit %d\n' \
    "$(printf '%q' "$output")" "$exit_code" > "${MOCK_DIR}/${name}"
  chmod +x "${MOCK_DIR}/${name}"
}

@test "script uses hostname in output" {
  make_mock_output hostname "testserver-01"

  run ../bin/status.sh
  assert_output --partial "testserver-01"
}

@test "script handles curl failure gracefully" {
  make_mock_output curl "Connection refused" 7   # exit 7 = curl connect fail

  run ../bin/health_check.sh
  assert_failure
  assert_output --partial "health check failed"
}

6 — Testing Scripts That Modify the Filesystem

setup() {
  load 'test_helper/bats-support/load'
  load 'test_helper/bats-assert/load'
  load 'test_helper/bats-file/load'

  # Every test gets its own isolated filesystem
  FS_ROOT="${BATS_TEST_TMPDIR}/fs"
  mkdir -p "$FS_ROOT"/{etc,var/log,home/alice,backup}

  # Point the script's config at our fake root
  export APP_ROOT="$FS_ROOT"
  export CONFIG_DIR="$FS_ROOT/etc"
  export LOG_DIR="$FS_ROOT/var/log"
}

@test "install creates config file with correct permissions" {
  run ../bin/install.sh
  assert_success
  assert_file_exists       "$FS_ROOT/etc/myapp.conf"
  assert_file_permissions  "$FS_ROOT/etc/myapp.conf" 600
  assert_file_owner        "$FS_ROOT/etc/myapp.conf" "$USER"
}

@test "backup script creates archive in backup dir" {
  # Seed data to back up
  printf 'important data\n' > "$FS_ROOT/var/log/app.log"

  run ../bin/backup.sh "$FS_ROOT/var/log" "$FS_ROOT/backup"
  assert_success
  assert_dir_not_empty "$FS_ROOT/backup"
}

@test "cleanup script removes files older than 30 days" {
  # Create a file with a known old timestamp
  local old_file="$FS_ROOT/var/log/old.log"
  local new_file="$FS_ROOT/var/log/new.log"
  touch "$old_file" "$new_file"
  # Back-date old_file by 31 days using touch -d
  touch -d '31 days ago' "$old_file"

  run ../bin/cleanup.sh "$FS_ROOT/var/log" --days 30
  assert_success
  assert_file_not_exist  "$old_file"
  assert_file_exists     "$new_file"
}

@test "cleanup in dry-run mode does not delete anything" {
  local old_file="$FS_ROOT/var/log/old.log"
  touch "$old_file"
  touch -d '31 days ago' "$old_file"

  run ../bin/cleanup.sh "$FS_ROOT/var/log" --days 30 --dry-run
  assert_success
  assert_file_exists  "$old_file"   # must still be there
  assert_output --partial "would delete"
}

7 — Advanced Mocking Techniques

Mocking functions within a sourced library

# When testing a library (not a script), source it and override functions
setup() {
  load 'test_helper/bats-support/load'
  load 'test_helper/bats-assert/load'
  # Source the library under test
  source "${BATS_TEST_DIRNAME}/../lib/deploy_lib.sh"
}

@test "notify_slack is called on success" {
  # Override the real notify_slack with a mock in the same shell
  SLACK_CALLED=0
  notify_slack() { (( SLACK_CALLED++ )) || true; }

  # Call the function under test (which internally calls notify_slack)
  run deploy_app staging
  assert_success
  assert_equal "$SLACK_CALLED" "1"
}

Stateful mocks — returning different values on successive calls

@test "retry logic calls command up to 3 times" {
  # First two calls fail, third succeeds
  CALL_COUNT=0
  flaky_service() {
    (( CALL_COUNT++ )) || true
    (( CALL_COUNT < 3 )) && return 1   # fail first 2 times
    return 0
  }

  run retry_until_success 5 flaky_service
  assert_success
  assert_equal "$CALL_COUNT" "3"
}

# File-backed stateful mock (works for child-process mocks too)
setup() {
  MOCK_DIR="${BATS_TEST_TMPDIR}/mocks"
  mkdir -p "$MOCK_DIR"
  export PATH="$MOCK_DIR:$PATH"
  # Mock that fails twice then succeeds — uses a counter file
  printf '#!/usr/bin/env bash
COUNT_FILE="%s/ping.count"
count=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
echo $(( count + 1 )) > "$COUNT_FILE"
(( count >= 2 )) && exit 0 || exit 1
' "$MOCK_DIR" > "$MOCK_DIR/ping"
  chmod +x "$MOCK_DIR/ping"
}

@test "wait_for_host retries on ping failure" {
  run ../bin/wait_for_host.sh example.com
  assert_success
  # ping should have been called 3 times (2 failures + 1 success)
  assert_equal "$(< "$MOCK_DIR/ping.count")" "3"
}

8 — Testing Edge Cases and Error Paths

# Test that a script fails correctly — not just that it fails
@test "missing required argument prints usage" {
  run ../bin/myapp.sh   # no args
  assert_failure
  assert_output --partial "Usage:"
  assert_equal "$status" "1"
}

# Test specific exit codes
@test "file not found returns exit 2" {
  run ../bin/myapp.sh --input /nonexistent/file
  assert_equal "$status" "2"
}

# Test behaviour with unusual filenames (spaces, special chars)
@test "handles filename with spaces" {
  local f="$BATS_TEST_TMPDIR/file with spaces.txt"
  printf 'data\n' > "$f"
  run ../bin/process.sh "$f"
  assert_success
}

@test "handles empty file" {
  local f="$BATS_TEST_TMPDIR/empty.txt"
  touch "$f"
  run ../bin/process.sh "$f"
  assert_success
  assert_output --partial "0 lines processed"
}

# Skipping tests conditionally
@test "docker-based test" {
  skip_if_missing docker
  run ../bin/containerise.sh
  assert_success
}

# skip_if_missing helper
skip_if_missing() {
  command -v "$1" &>/dev/null || skip "$1 not available"
}

# skip built-in — marks test as skipped rather than failed
@test "root-only operation" {
  (( EUID == 0 )) || skip "requires root"
  run ../bin/set_caps.sh
  assert_success
}

9 — CI/CD Integration

# .github/workflows/test.yml
# ── GitHub Actions ─────────────────────────────────────────────────
name: Tests
on: [push, pull_request]
jobs:
  bats:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive      # fetch bats-* submodules
      - name: Install bats
        run: sudo apt-get install -y bats
      - name: Run tests
        run: bats --formatter junit --output reports/ --recursive test/
      - name: Publish results
        uses: mikepenz/action-junit-report@v4
        if: always()
        with:
          report_paths: 'reports/*.xml'
# Makefile targets for local development
test:
	bats --recursive test/

test-watch:
	while true; do \
	  inotifywait -qre close_write bin/ lib/ test/ 2>/dev/null; \
	  clear; bats --formatter pretty --recursive test/; \
	done

test-coverage:
	# kcov is the closest thing to coverage for bash
	kcov --include-path=bin/,lib/ coverage/ \
	  bats --recursive test/
Recommended project layout:
project/
├── bin/                  ← scripts under test
├── lib/                  ← sourced libraries under test
├── test/
│   ├── fixtures/         ← static input files
│   ├── test_helper/      ← bats-support, bats-assert, bats-file (submodules)
│   ├── unit/             ← tests for individual functions
│   │   └── lib_utils.bats
│   └── integration/      ← end-to-end script tests
│       └── deploy.bats
└── Makefile

Exercises

Exercise 1 — Test a string utility library

Given this library at lib/strings.sh:

str_trim()  { local s="$1"; s="${s#"${s%%[! $'\t']*}"}"; printf '%s' "${s%"${s##*[! $'\t']}"}"; }
str_upper() { printf '%s' "${1^^}"; }
str_lower() { printf '%s' "${1,,}"; }
str_contains() { [[ "$1" == *"$2"* ]]; }
str_repeat() { printf "${1}%.0s" $(seq 1 "$2"); }

Write a complete BATS test file covering: normal cases for all five functions, edge cases (empty string, single char, string that IS the needle), and at least one failure case per function. Use setup to load the library and helpers. Include a test for a deliberately broken behaviour — pick one function to implement incorrectly, verify the test catches it, then fix it.

#!/usr/bin/env bats
# test/unit/strings_test.bats

setup() {
  load '../test_helper/bats-support/load'
  load '../test_helper/bats-assert/load'
  source "${BATS_TEST_DIRNAME}/../../lib/strings.sh"
}

# ── str_trim ──────────────────────────────────────────────────────
@test "str_trim: removes leading spaces" {
  run str_trim "   hello"
  assert_output "hello"
}
@test "str_trim: removes trailing spaces" {
  run str_trim "hello   "
  assert_output "hello"
}
@test "str_trim: removes both ends" {
  run str_trim "  hello world  "
  assert_output "hello world"
}
@test "str_trim: empty string returns empty" {
  run str_trim ""
  assert_output ""
}
@test "str_trim: all whitespace returns empty" {
  run str_trim "     "
  assert_output ""
}

# ── str_upper / str_lower ─────────────────────────────────────────
@test "str_upper: lowercases to uppercase" {
  run str_upper "hello world"
  assert_output "HELLO WORLD"
}
@test "str_upper: already uppercase is unchanged" {
  run str_upper "HELLO"
  assert_output "HELLO"
}
@test "str_lower: uppercases to lowercase" {
  run str_lower "HELLO WORLD"
  assert_output "hello world"
}
@test "str_lower: mixed case" {
  run str_lower "HeLLo"
  assert_output "hello"
}

# ── str_contains ─────────────────────────────────────────────────
@test "str_contains: needle in middle" {
  run str_contains "hello world" "lo wo"
  assert_success
}
@test "str_contains: needle IS the string" {
  run str_contains "hello" "hello"
  assert_success
}
@test "str_contains: needle not present" {
  run str_contains "hello" "xyz"
  assert_failure
}
@test "str_contains: empty needle is always found" {
  run str_contains "hello" ""
  assert_success
}

# ── str_repeat ────────────────────────────────────────────────────
@test "str_repeat: repeats 3 times" {
  run str_repeat "ab" 3
  assert_output "ababab"
}
@test "str_repeat: zero times returns empty" {
  run str_repeat "x" 0
  assert_output ""
}
@test "str_repeat: single char" {
  run str_repeat "-" 5
  assert_output "-----"
}

Exercise 2 — Mock-based deploy test

Write a BATS test file for the following bin/deploy.sh script. Use PATH-based mocks for rsync, systemctl, and curl. Cover: happy path (all succeed), rsync failure (systemctl not called), systemctl failure (curl still called for alert), and missing argument.

#!/usr/bin/env bash
set -euo pipefail
ENV="${1:?Usage: deploy.sh ENV}"
notify() { curl -s "$WEBHOOK_URL" -d "status=$1"; }
notify starting
rsync -a dist/ "deploy@${ENV}.example.com:/var/www/"
systemctl --host "$ENV.example.com" reload nginx
notify done
#!/usr/bin/env bats
# test/integration/deploy_test.bats

setup() {
  load '../test_helper/bats-support/load'
  load '../test_helper/bats-assert/load'

  MOCK_DIR="${BATS_TEST_TMPDIR}/mocks"
  mkdir -p "$MOCK_DIR"
  export PATH="$MOCK_DIR:$PATH"
  export WEBHOOK_URL="http://fake-webhook"
  SCRIPT="${BATS_TEST_DIRNAME}/../../bin/deploy.sh"
}

_make_mock() {
  local name="$1" exit_code="${2:-0}"
  local calls_file="$MOCK_DIR/$name.calls"
  printf '#!/usr/bin/env bash\nprintf "%%s\n" "$*" >> "%s"\nexit %d\n' \
    "$calls_file" "$exit_code" > "$MOCK_DIR/$name"
  chmod +x "$MOCK_DIR/$name"
}

_call_count() {
  wc -l < "$MOCK_DIR/$1.calls" 2>/dev/null || printf '0'
}

_calls() {
  cat "$MOCK_DIR/$1.calls" 2>/dev/null || true
}

# ── Tests ─────────────────────────────────────────────────────────

@test "happy path: all commands called in order" {
  _make_mock rsync
  _make_mock systemctl
  _make_mock curl

  run bash "$SCRIPT" staging
  assert_success

  # curl called twice (starting + done)
  assert_equal "$(_call_count curl)"       "2"
  assert_equal "$(_call_count rsync)"      "1"
  assert_equal "$(_call_count systemctl)"  "1"

  # Verify notification content
  run _calls curl
  assert_line --partial "status=starting"
  assert_line --partial "status=done"
}

@test "rsync failure: systemctl not called" {
  _make_mock rsync 1   # rsync fails
  _make_mock systemctl
  _make_mock curl

  run bash "$SCRIPT" staging
  assert_failure
  assert_equal "$(_call_count systemctl)" "0"
}

@test "systemctl failure: curl still called for done/error" {
  _make_mock rsync
  _make_mock systemctl 1   # systemctl fails
  _make_mock curl

  # set -e means the script exits after systemctl fails —
  # 'done' notification is not sent; that is expected behaviour
  run bash "$SCRIPT" staging
  assert_failure
  # 'starting' notification should have been sent
  run _calls curl
  assert_line --partial "status=starting"
}

@test "missing argument exits 1 with usage message" {
  _make_mock rsync
  _make_mock systemctl
  _make_mock curl

  run bash "$SCRIPT"   # no ENV argument
  assert_failure
  assert_output --partial "Usage:"
  # No commands should have been called
  assert_equal "$(_call_count curl)"  "0"
  assert_equal "$(_call_count rsync)" "0"
}

Exercise 3 — Filesystem mutation test

Write a BATS test suite for a bin/rotate_logs.sh LOGDIR MAX_DAYS script that deletes files older than MAX_DAYS and compresses files older than half that age. Use touch -d to seed files with specific ages. Test: files younger than MAX_DAYS/2 are untouched; files between MAX_DAYS/2 and MAX_DAYS are compressed (renamed to .gz); files older than MAX_DAYS are deleted; the script refuses to operate on a path outside /var/log (when run against the real filesystem — mock this with an environment variable override).

#!/usr/bin/env bats
# test/integration/rotate_logs_test.bats

setup() {
  load '../test_helper/bats-support/load'
  load '../test_helper/bats-assert/load'
  load '../test_helper/bats-file/load'

  LOGDIR="${BATS_TEST_TMPDIR}/logs"
  mkdir -p "$LOGDIR"

  # Override the allowed base path for testing
  export LOG_BASE_PATH="$BATS_TEST_TMPDIR"

  MAX_DAYS=30

  # Seed test files at specific ages
  # young: 5 days old — should be untouched
  touch -d '5 days ago'  "$LOGDIR/young.log"
  # middle: 20 days old — older than MAX/2 (15), should be compressed
  touch -d '20 days ago' "$LOGDIR/middle.log"
  # old: 35 days old — older than MAX (30), should be deleted
  touch -d '35 days ago' "$LOGDIR/old.log"
  SCRIPT="${BATS_TEST_DIRNAME}/../../bin/rotate_logs.sh"
}

@test "young file is untouched" {
  run bash "$SCRIPT" "$LOGDIR" "$MAX_DAYS"
  assert_success
  assert_file_exists    "$LOGDIR/young.log"
  assert_file_not_exist "$LOGDIR/young.log.gz"
}

@test "middle-aged file is compressed" {
  run bash "$SCRIPT" "$LOGDIR" "$MAX_DAYS"
  assert_success
  assert_file_not_exist "$LOGDIR/middle.log"
  assert_file_exists    "$LOGDIR/middle.log.gz"
}

@test "old file is deleted" {
  run bash "$SCRIPT" "$LOGDIR" "$MAX_DAYS"
  assert_success
  assert_file_not_exist "$LOGDIR/old.log"
  assert_file_not_exist "$LOGDIR/old.log.gz"
}

@test "path outside LOG_BASE_PATH is rejected" {
  # Override to a strict base path that LOGDIR doesn't satisfy
  export LOG_BASE_PATH=/var/log
  run bash "$SCRIPT" "$LOGDIR" "$MAX_DAYS"
  assert_failure
  assert_output --partial "outside"
  # Verify no files were touched
  assert_file_exists "$LOGDIR/old.log"
}

@test "dry-run reports actions without modifying filesystem" {
  run bash "$SCRIPT" "$LOGDIR" "$MAX_DAYS" --dry-run
  assert_success
  assert_file_exists "$LOGDIR/old.log"
  assert_file_exists "$LOGDIR/middle.log"
  assert_output --partial "would delete"
  assert_output --partial "would compress"
}

Exercise 4 — CI configuration and Makefile

Write a complete Makefile and .github/workflows/test.yml for a project that has BATS tests. Requirements:

  • make test — runs all tests with pretty output
  • make test-ci — runs with JUnit XML output to reports/
  • make test-unit and make test-integration — run subsets
  • make lint — runs shellcheck on all .sh files
  • make test-watch — re-runs tests on file change (using inotifywait or fswatch)
  • The GitHub Actions workflow runs lint then tests, publishes JUnit results, and fails the workflow if any test fails
# Makefile
BATS       := test/bats/bin/bats
TEST_DIR   := test
REPORT_DIR := reports
SHELL_SRC  := $(shell find bin lib -name '*.sh' 2>/dev/null)

.PHONY: test test-ci test-unit test-integration lint test-watch clean

test:
	$(BATS) --formatter pretty --recursive $(TEST_DIR)/

test-ci: | $(REPORT_DIR)
	$(BATS) --formatter junit --output $(REPORT_DIR) \
	        --recursive $(TEST_DIR)/

test-unit:
	$(BATS) --formatter pretty --recursive $(TEST_DIR)/unit/

test-integration:
	$(BATS) --formatter pretty --recursive $(TEST_DIR)/integration/

lint:
	shellcheck $(SHELL_SRC)

test-watch:
	@if command -v inotifywait >/dev/null 2&1; then \
	  while true; do \
	    inotifywait -qre close_write bin/ lib/ $(TEST_DIR)/ 2/dev/null; \
	    clear; $(MAKE) test; \
	  done; \
	elif command -v fswatch >/dev/null 2&1; then \
	  fswatch -o bin/ lib/ $(TEST_DIR)/ | xargs -n1 sh -c 'clear; $(MAKE) test'; \
	else \
	  echo "Install inotify-tools (Linux) or fswatch (macOS)"; exit 1; \
	fi

$(REPORT_DIR):
	mkdir -p $(REPORT_DIR)

clean:
	rm -rf $(REPORT_DIR)
# .github/workflows/test.yml
name: CI

on:
  push:
    branches: [main, 'feature/**']
  pull_request:

jobs:
  lint-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Install dependencies
        run: |
          sudo apt-get update -q
          sudo apt-get install -y shellcheck

      - name: Lint
        run: make lint

      - name: Run tests
        run: make test-ci

      - name: Publish test results
        uses: mikepenz/action-junit-report@v4
        if: always()
        with:
          report_paths: 'reports/*.xml'
          fail_on_failure: true