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.
| Package | Provides |
|---|---|
bats-core | The test runner, @test syntax, TAP output |
bats-support | Helper functions: fail, assert output formatting |
bats-assert | Assertion library: assert_output, assert_success, etc. |
bats-file | Filesystem 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/
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 outputmake test-ci— runs with JUnit XML output toreports/make test-unitandmake test-integration— run subsetsmake lint— runsshellcheckon all.shfilesmake test-watch— re-runs tests on file change (usinginotifywaitorfswatch)- 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