--- name: bats description: "Bash Automated Testing System (BATS) for TDD-style testing of shell scripts. Use when: (1) Writing unit or integration tests for Bash scripts, (2) Testing CLI tools or shell functions, (3) Setting up test infrastructure with setup/teardown hooks, (4) Mocking external commands (curl, git, docker), (5) Generating JUnit reports for CI/CD, (6) Debugging test failures or flaky tests, (7) Implementing test-driven development for shell scripts." --- # BATS Testing Framework BATS (Bash Automated Testing System) is a TAP-compliant testing framework for Bash 3.2+. Think of it as **JUnit for Bash**—structured, repeatable testing for shell scripts. ## Workflow Decision Tree ### Creating New Test Suite 1. Initialize project structure (see "Project Setup" below) 2. Create test files with `.bats` extension 3. Load helper libraries in `setup()` 4. Write tests using `@test` blocks ### Writing Tests - **Testing script output?** → Use `run` + `assert_output` - **Testing exit codes?** → Use `run` + `assert_success/assert_failure` - **Testing file operations?** → Use `bats-file` assertions - **Mocking external commands?** → See [gotchas.md](references/gotchas.md#mocking-external-commands) ### Debugging Failures - **Test hangs?** → Check for background tasks holding FD 3 - **Pipes don't work?** → Use `bash -c` wrapper or `bats_pipe` - **Negation doesn't fail?** → Use `run !` (BATS 1.5+) - **Variables disappear?** → Don't use `run` for assignments - See [gotchas.md](references/gotchas.md) for complete troubleshooting ## Project Setup ### Recommended Structure ``` project/ ├── src/ │ └── my_script.sh ├── test/ │ ├── bats/ # bats-core submodule │ ├── test_helper/ │ │ ├── bats-support/ # Output formatting │ │ ├── bats-assert/ # Assertions │ │ ├── bats-file/ # Filesystem assertions │ │ └── common-setup.bash # Shared setup logic │ ├── unit/ │ │ └── parser.bats │ └── integration/ │ └── api.bats └── .gitmodules ``` ### Initialize Submodules ```bash git submodule add https://github.com/bats-core/bats-core.git test/bats git submodule add https://github.com/bats-core/bats-support.git test/test_helper/bats-support git submodule add https://github.com/bats-core/bats-assert.git test/test_helper/bats-assert git submodule add https://github.com/bats-core/bats-file.git test/test_helper/bats-file ``` ### Common Setup Helper Create `test/test_helper/common-setup.bash`: ```bash _common_setup() { load "$BATS_TEST_DIRNAME/test_helper/bats-support/load" load "$BATS_TEST_DIRNAME/test_helper/bats-assert/load" load "$BATS_TEST_DIRNAME/test_helper/bats-file/load" PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" export PATH="$PROJECT_ROOT/src:$PATH" } ``` ## Test File Template ```bash #!/usr/bin/env bats setup_file() { # Runs ONCE before all tests in file (expensive setup) export SHARED_RESOURCE="initialized" } setup() { # Runs before EACH test load 'test_helper/common-setup' _common_setup TEST_DIR="$BATS_TEST_TMPDIR" } teardown() { # Runs after EACH test (cleanup) rm -rf "$TEST_DIR" 2>/dev/null || true } teardown_file() { # Runs ONCE after all tests (final cleanup) unset SHARED_RESOURCE } @test "describe expected behavior" { run my_command arg1 arg2 assert_success assert_output --partial "expected substring" } ``` ## The `run` Helper `run` captures exit status and output in a subshell: ```bash run command arg1 arg2 # Available after run: $status # Exit code $output # Combined stdout+stderr ${lines[@]} # Array of output lines ${lines[0]} # First line # Implicit status checks (BATS 1.5+) run -1 failing_command # Expect exit code 1 run ! command # Expect non-zero exit run --separate-stderr cmd # Separate $output and $stderr ``` **Critical**: `run` always returns 0 to BATS. Always check `$status` explicitly or use assertions. ## Core Assertions (bats-assert) ```bash # Exit status assert_success # $status == 0 assert_failure # $status != 0 assert_failure 1 # $status == 1 # Output assert_output "exact match" assert_output --partial "substring" assert_output --regexp "^[0-9]+$" # Lines assert_line "any line matches" assert_line --index 0 "first line" assert_line --partial "substring" # Negations refute_output "not this" refute_line "not in output" ``` ## File Assertions (bats-file) ```bash assert_file_exists "/path/to/file" assert_dir_exists "/path/to/dir" assert_file_executable "/path/to/script" assert_file_not_empty "/path/to/file" assert_file_contains "/path/to/file" "search text" ``` ## Temporary Directories | Variable | Scope | Use Case | |----------|-------|----------| | `$BATS_TEST_TMPDIR` | Per test | **Always use for isolation** | | `$BATS_FILE_TMPDIR` | Per file | Shared fixtures in `setup_file` | | `$BATS_RUN_TMPDIR` | Per run | Rarely needed | ```bash @test "file operations" { echo "data" > "$BATS_TEST_TMPDIR/file.txt" run process_file "$BATS_TEST_TMPDIR/file.txt" assert_success # Automatically cleaned up } ``` ## Mocking External Commands Mock via PATH manipulation: ```bash @test "mock curl" { mkdir -p "$BATS_TEST_TMPDIR/bin" cat > "$BATS_TEST_TMPDIR/bin/curl" <<'EOF' #!/bin/bash echo '{"status":"ok"}' EOF chmod +x "$BATS_TEST_TMPDIR/bin/curl" export PATH="$BATS_TEST_TMPDIR/bin:$PATH" run script_using_curl assert_output --partial "status" } ``` ## Running Tests ```bash # Basic execution bats test/ # All tests bats -r test/ # Recursive bats --jobs 4 test/ # Parallel # Filtering bats --filter "login" test/ # By name regex bats --filter-tags api,!slow test/ # By tags bats --filter-status failed test/ # Re-run failures # Output formats bats --formatter junit --output ./reports test/ # JUnit for CI bats --timing test/ # Show durations ``` ## Tagging Tests ```bash # bats test_tags=api,smoke @test "user login" { } # Run tagged tests bats --filter-tags api test/ # Has 'api' bats --filter-tags api,!slow test/ # Has 'api' but not 'slow' ``` ## Skip Tests ```bash @test "not ready" { skip "Feature not implemented" } @test "requires docker" { command -v docker || skip "Docker not installed" run docker ps } ``` ## CI/CD Integration ### GitHub Actions ```yaml - name: Run tests run: ./test/bats/bin/bats --formatter junit --output ./reports test/ - name: Publish results uses: EnricoMi/publish-unit-test-result-action@v2 if: always() with: files: reports/report.xml ``` ### GitLab CI ```yaml test: script: - bats --formatter junit --output reports/ test/ artifacts: reports: junit: reports/report.xml ``` ## Reference Documentation - **Common pitfalls and debugging**: See [references/gotchas.md](references/gotchas.md) - **Complete assertion reference**: See [references/assertions.md](references/assertions.md) - **Real-world project examples**: See [references/projects.md](references/projects.md) - **CI/CD integration patterns**: See [references/ci-integration.md](references/ci-integration.md) ## Quick Troubleshooting | Problem | Solution | |---------|----------| | Test passes but should fail | Use `assert_failure` or check `$status` | | Pipes don't work with `run` | Use `run bash -c "cmd1 \| cmd2"` | | `! true` doesn't fail test | Use `run ! true` (BATS 1.5+) | | Variables lost after `run` | Don't use `run` for assignments | | Test hangs indefinitely | Close FD 3 for background tasks: `cmd 3>&- &` | | Output has ANSI colors | Use `strip_colors` helper or `NO_COLOR=1` | ## Code Style - Use `run` for capturing output, direct execution for state changes - Always check `$status` or use assertions - Prefer `$BATS_TEST_TMPDIR` over hardcoded paths - Mock external dependencies, not internal logic - Name tests to describe expected behavior