bats-testing-patterns

Comprehensive guide for writing shell script tests using Bats (Bash Automated Testing System). Use when writing or improving tests for Bash/shell scripts, creating test fixtures, mocking commands, or setting up CI/CD for shell script testing. Includes patterns for assertions, setup/teardown, mocking, fixtures, and integration with GitHub Actions.

16 stars

Best use case

bats-testing-patterns is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

Comprehensive guide for writing shell script tests using Bats (Bash Automated Testing System). Use when writing or improving tests for Bash/shell scripts, creating test fixtures, mocking commands, or setting up CI/CD for shell script testing. Includes patterns for assertions, setup/teardown, mocking, fixtures, and integration with GitHub Actions.

Teams using bats-testing-patterns should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/bats-testing-patterns/SKILL.md --create-dirs "https://raw.githubusercontent.com/diegosouzapw/awesome-omni-skill/main/skills/cli-automation/bats-testing-patterns/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/bats-testing-patterns/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How bats-testing-patterns Compares

Feature / Agentbats-testing-patternsStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

Comprehensive guide for writing shell script tests using Bats (Bash Automated Testing System). Use when writing or improving tests for Bash/shell scripts, creating test fixtures, mocking commands, or setting up CI/CD for shell script testing. Includes patterns for assertions, setup/teardown, mocking, fixtures, and integration with GitHub Actions.

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# Bats Testing Patterns

## Overview

Bats (Bash Automated Testing System) provides a TAP-compliant testing framework for shell scripts. This skill documents proven patterns for writing effective, maintainable shell script tests that catch bugs early and document expected behavior.

**Use this skill when:**

- Writing tests for Bash or shell scripts
- Creating test fixtures and mock data for shell testing
- Setting up test infrastructure for shell-based tools
- Debugging failing shell tests
- Integrating shell tests into CI/CD pipelines

## Core Testing Patterns

### Basic Test Structure

Every Bats test file is a shell script with a `.bats` extension:

```bash
#!/usr/bin/env bats

@test "Test description goes here" {
    # Test code
    [ condition ]
}
```

**Key Points:**

- Use descriptive test names that explain what is being verified
- Each `@test` block is an independent test
- Tests should be focused on one specific behavior
- Use the shebang `#!/usr/bin/env bats` at the top

### Exit Code Assertions

Test command success and failure explicitly:

```bash
#!/usr/bin/env bats

@test "Command succeeds as expected" {
    run echo "hello"
    [ "$status" -eq 0 ]
}

@test "Command fails as expected" {
    run false
    [ "$status" -ne 0 ]
}

@test "Command returns specific exit code" {
    run bash -c "exit 127"
    [ "$status" -eq 127 ]
}

@test "Can capture command result" {
    run echo "hello"
    [ $status -eq 0 ]
    [ "$output" = "hello" ]
}
```

**Best Practice:** Always use `run` to capture command output and exit status. The `run` command sets `$status`, `$output`, and `$lines` variables for assertions.

### Output Assertions

Verify command output matches expectations:

```bash
#!/usr/bin/env bats

@test "Output matches exact string" {
    result=$(echo "hello world")
    [ "$result" = "hello world" ]
}

@test "Output contains substring" {
    result=$(echo "hello world")
    [[ "$result" == *"world"* ]]
}

@test "Output matches regex pattern" {
    result=$(date +%Y)
    [[ "$result" =~ ^[0-9]{4}$ ]]
}

@test "Multi-line output comparison" {
    run printf "line1\nline2\nline3"
    [ "$output" = "line1
line2
line3" ]
}

@test "Using lines array for output" {
    run printf "line1\nline2\nline3"
    [ "${lines[0]}" = "line1" ]
    [ "${lines[1]}" = "line2" ]
    [ "${lines[2]}" = "line3" ]
    [ "${#lines[@]}" -eq 3 ]
}
```

**Tip:** Use the `$lines` array when testing multi-line output - it's cleaner than string comparison.

### File Assertions

Test file operations and attributes:

```bash
#!/usr/bin/env bats

setup() {
    TEST_DIR=$(mktemp -d)
    export TEST_DIR
}

teardown() {
    rm -rf "$TEST_DIR"
}

@test "File is created successfully" {
    [ ! -f "$TEST_DIR/output.txt" ]
    echo "content" > "$TEST_DIR/output.txt"
    [ -f "$TEST_DIR/output.txt" ]
}

@test "File contents match expected" {
    echo "expected content" > "$TEST_DIR/output.txt"
    [ "$(cat "$TEST_DIR/output.txt")" = "expected content" ]
}

@test "File is readable" {
    touch "$TEST_DIR/test.txt"
    [ -r "$TEST_DIR/test.txt" ]
}

@test "File has correct permissions (Linux)" {
    touch "$TEST_DIR/test.txt"
    chmod 644 "$TEST_DIR/test.txt"
    [ "$(stat -c %a "$TEST_DIR/test.txt")" = "644" ]
}

@test "File has correct permissions (macOS)" {
    touch "$TEST_DIR/test.txt"
    chmod 644 "$TEST_DIR/test.txt"
    [ "$(stat -f %OLp "$TEST_DIR/test.txt")" = "644" ]
}

@test "File size is correct" {
    echo -n "12345" > "$TEST_DIR/test.txt"
    [ "$(wc -c < "$TEST_DIR/test.txt")" -eq 5 ]
}

@test "Directory structure is created" {
    mkdir -p "$TEST_DIR/sub/nested/deep"
    [ -d "$TEST_DIR/sub/nested/deep" ]
}
```

**Platform Note:** File permission checking differs between Linux (`stat -c`) and macOS (`stat -f`). Test on your target platform or provide compatibility helpers.

## Setup and Teardown Patterns

### Basic Setup and Teardown

Execute code before and after each test:

```bash
#!/usr/bin/env bats

setup() {
    # Runs before EACH test
    TEST_DIR=$(mktemp -d)
    export TEST_DIR

    # Source the script under test
    source "${BATS_TEST_DIRNAME}/../bin/script.sh"
}

teardown() {
    # Runs after EACH test
    rm -rf "$TEST_DIR"
}

@test "Test using TEST_DIR" {
    touch "$TEST_DIR/file.txt"
    [ -f "$TEST_DIR/file.txt" ]
}

@test "Second test has clean TEST_DIR" {
    # TEST_DIR is recreated fresh for each test
    [ ! -f "$TEST_DIR/file.txt" ]
}
```

**Critical:** The `setup()` and `teardown()` functions run before and after EACH test, ensuring test isolation.

### Setup with Test Resources

Create fixtures and test data:

```bash
#!/usr/bin/env bats

setup() {
    # Create directory structure
    TEST_DIR=$(mktemp -d)
    mkdir -p "$TEST_DIR/data/input"
    mkdir -p "$TEST_DIR/data/output"

    # Create test fixtures
    echo "line1" > "$TEST_DIR/data/input/file1.txt"
    echo "line2" > "$TEST_DIR/data/input/file2.txt"
    echo "line3" > "$TEST_DIR/data/input/file3.txt"

    # Initialize environment variables
    export DATA_DIR="$TEST_DIR/data"
    export INPUT_DIR="$DATA_DIR/input"
    export OUTPUT_DIR="$DATA_DIR/output"
    
    # Source the script being tested
    source "${BATS_TEST_DIRNAME}/../scripts/process_files.sh"
}

teardown() {
    rm -rf "$TEST_DIR"
}

@test "Processes all input files" {
    process_files "$INPUT_DIR" "$OUTPUT_DIR"
    [ -f "$OUTPUT_DIR/file1.txt" ]
    [ -f "$OUTPUT_DIR/file2.txt" ]
    [ -f "$OUTPUT_DIR/file3.txt" ]
}

@test "Handles empty input directory" {
    rm -rf "$INPUT_DIR"/*
    process_files "$INPUT_DIR" "$OUTPUT_DIR"
    [ "$(ls -A "$OUTPUT_DIR")" = "" ]
}
```

### Global Setup/Teardown

Run expensive setup once for all tests:

```bash
#!/usr/bin/env bats

# Load shared test utilities
load test_helper

# setup_file runs ONCE before all tests in the file
setup_file() {
    export SHARED_RESOURCE=$(mktemp -d)
    export SHARED_DB="$SHARED_RESOURCE/test.db"
    
    # Expensive operation: initialize database
    echo "Creating test database..."
    sqlite3 "$SHARED_DB" < "${BATS_TEST_DIRNAME}/fixtures/schema.sql"
}

# teardown_file runs ONCE after all tests in the file
teardown_file() {
    rm -rf "$SHARED_RESOURCE"
}

# setup runs before each test (optional)
setup() {
    # Per-test setup if needed
    export TEST_ID=$(date +%s%N)
}

@test "First test uses shared resource" {
    [ -f "$SHARED_DB" ]
    sqlite3 "$SHARED_DB" "SELECT COUNT(*) FROM users;"
}

@test "Second test uses same shared resource" {
    [ -f "$SHARED_DB" ]
    # Database persists between tests
    sqlite3 "$SHARED_DB" "INSERT INTO users (name) VALUES ('test_$TEST_ID');"
}
```

**Use Case:** Global setup/teardown is perfect for expensive operations like database initialization, server startup, or large file downloads that can be shared across tests.

## Mocking and Stubbing Patterns

### Function Mocking

Override functions for testing:

```bash
#!/usr/bin/env bats

# Mock external command
curl() {
    echo '{"status": "success", "data": "mocked"}'
    return 0
}

@test "Function uses mocked curl" {
    export -f curl
    
    # Source script that calls curl
    source "${BATS_TEST_DIRNAME}/../scripts/api_client.sh"
    
    result=$(fetch_data "https://api.example.com/data")
    [[ "$result" == *"mocked"* ]]
}

@test "Mock can simulate failure" {
    curl() {
        echo "Connection refused"
        return 1
    }
    export -f curl
    
    source "${BATS_TEST_DIRNAME}/../scripts/api_client.sh"
    run fetch_data "https://api.example.com/data"
    [ "$status" -ne 0 ]
}
```

### Command Stubbing with PATH Manipulation

Create stub commands that override system commands:

```bash
#!/usr/bin/env bats

setup() {
    # Create stub directory
    STUBS_DIR="$BATS_TEST_TMPDIR/stubs"
    mkdir -p "$STUBS_DIR"

    # Prepend to PATH so stubs are found first
    export PATH="$STUBS_DIR:$PATH"
}

teardown() {
    rm -rf "$STUBS_DIR"
}

create_stub() {
    local cmd="$1"
    local output="$2"
    local exit_code="${3:-0}"

    cat > "$STUBS_DIR/$cmd" <<EOF
#!/bin/bash
echo "$output"
exit $exit_code
EOF
    chmod +x "$STUBS_DIR/$cmd"
}

@test "Function works with stubbed curl" {
    create_stub curl '{"status": "ok"}' 0
    
    source "${BATS_TEST_DIRNAME}/../scripts/api_client.sh"
    run fetch_api_status
    [ "$status" -eq 0 ]
    [[ "$output" == *"ok"* ]]
}

@test "Function handles stubbed curl failure" {
    create_stub curl "curl: (6) Could not resolve host" 6
    
    source "${BATS_TEST_DIRNAME}/../scripts/api_client.sh"
    run fetch_api_status
    [ "$status" -ne 0 ]
}

@test "Can stub multiple commands" {
    create_stub git "commit abc123" 0
    create_stub docker "Container started" 0
    
    # Test code that uses both git and docker
    run git status
    [[ "$output" == *"abc123"* ]]
    
    run docker ps
    [[ "$output" == *"started"* ]]
}
```

**Powerful Pattern:** PATH manipulation allows stubbing any command without modifying the code under test.

### Environment Variable Stubbing

Test different configurations:

```bash
#!/usr/bin/env bats

@test "Function uses environment override" {
    export LOG_LEVEL="DEBUG"
    export API_ENDPOINT="https://staging.example.com"
    
    source "${BATS_TEST_DIRNAME}/../scripts/config.sh"
    run get_config_value "log_level"
    [[ "$output" == *"DEBUG"* ]]
}

@test "Function uses defaults when vars unset" {
    unset LOG_LEVEL
    unset API_ENDPOINT
    
    source "${BATS_TEST_DIRNAME}/../scripts/config.sh"
    run get_config_value "log_level"
    [[ "$output" == *"INFO"* ]]  # Default value
}

@test "Function handles missing config file" {
    export CONFIG_FILE="/nonexistent/config.yaml"
    
    source "${BATS_TEST_DIRNAME}/../scripts/config.sh"
    run load_config
    [ "$status" -ne 0 ]
    [[ "$output" == *"not found"* ]]
}
```

## Fixture Management

### Using Fixture Files

Organize test data in a dedicated directory:

```bash
#!/usr/bin/env bats

# Directory structure:
# tests/
# ├── fixtures/
# │   ├── input.json
# │   ├── expected_output.json
# │   └── schema.sql
# └── script.bats

setup() {
    FIXTURES_DIR="${BATS_TEST_DIRNAME}/fixtures"
    WORK_DIR=$(mktemp -d)
    export WORK_DIR
}

teardown() {
    rm -rf "$WORK_DIR"
}

@test "Process fixture file produces expected output" {
    # Copy fixture to working directory
    cp "$FIXTURES_DIR/input.json" "$WORK_DIR/input.json"

    # Run processing function
    source "${BATS_TEST_DIRNAME}/../scripts/processor.sh"
    run process_json "$WORK_DIR/input.json" "$WORK_DIR/output.json"
    [ "$status" -eq 0 ]

    # Compare output with expected fixture
    diff "$WORK_DIR/output.json" "$FIXTURES_DIR/expected_output.json"
}

@test "Handles malformed input file" {
    echo "invalid json" > "$WORK_DIR/bad.json"
    
    source "${BATS_TEST_DIRNAME}/../scripts/processor.sh"
    run process_json "$WORK_DIR/bad.json" "$WORK_DIR/output.json"
    [ "$status" -ne 0 ]
    [[ "$output" == *"invalid"* ]]
}
```

**Organization Tip:** Keep fixtures in a `fixtures/` directory alongside your test files for easy maintenance.

### Dynamic Fixture Generation

Generate test data programmatically:

```bash
#!/usr/bin/env bats

generate_csv_fixture() {
    local rows="$1"
    local file="$2"

    echo "id,name,email" > "$file"
    for i in $(seq 1 "$rows"); do
        echo "$i,User$i,user$i@example.com" >> "$file"
    done
}

generate_log_fixture() {
    local lines="$1"
    local file="$2"

    for i in $(seq 1 "$lines"); do
        echo "[$(date -Iseconds)] INFO: Log entry $i" >> "$file"
    done
}

@test "Handles large CSV file" {
    generate_csv_fixture 10000 "$BATS_TEST_TMPDIR/large.csv"
    
    source "${BATS_TEST_DIRNAME}/../scripts/csv_parser.sh"
    run parse_csv "$BATS_TEST_TMPDIR/large.csv"
    [ "$status" -eq 0 ]
    [ "$(wc -l < "$BATS_TEST_TMPDIR/large.csv")" -eq 10001 ]  # Header + 10000 rows
}

@test "Handles log file with 1000 entries" {
    generate_log_fixture 1000 "$BATS_TEST_TMPDIR/app.log"
    
    source "${BATS_TEST_DIRNAME}/../scripts/log_analyzer.sh"
    run analyze_logs "$BATS_TEST_TMPDIR/app.log"
    [ "$status" -eq 0 ]
}
```

**Benefit:** Dynamic fixtures make tests more flexible and can test edge cases like performance with large datasets.

## Advanced Testing Patterns

### Testing Error Conditions

Ensure proper error handling:

```bash
#!/usr/bin/env bats

@test "Fails gracefully with missing required file" {
    source "${BATS_TEST_DIRNAME}/../scripts/processor.sh"
    run process_file "/nonexistent/file.txt"
    [ "$status" -ne 0 ]
    [[ "$output" == *"not found"* || "$output" == *"No such file"* ]]
}

@test "Fails with helpful message for invalid input" {
    source "${BATS_TEST_DIRNAME}/../scripts/processor.sh"
    run process_file ""
    [ "$status" -ne 0 ]
    [[ "$output" == *"Usage:"* || "$output" == *"required"* ]]
}

@test "Handles permission denied gracefully" {
    touch "$BATS_TEST_TMPDIR/readonly.txt"
    chmod 000 "$BATS_TEST_TMPDIR/readonly.txt"
    
    source "${BATS_TEST_DIRNAME}/../scripts/processor.sh"
    run process_file "$BATS_TEST_TMPDIR/readonly.txt"
    [ "$status" -ne 0 ]
    [[ "$output" == *"Permission denied"* || "$output" == *"cannot read"* ]]
    
    # Cleanup: restore permissions
    chmod 644 "$BATS_TEST_TMPDIR/readonly.txt"
}

@test "Provides usage help with invalid option" {
    source "${BATS_TEST_DIRNAME}/../scripts/processor.sh"
    run process_file --invalid-option
    [ "$status" -ne 0 ]
    [[ "$output" == *"Usage:"* ]]
}

@test "Validates input format" {
    echo "not a number" > "$BATS_TEST_TMPDIR/invalid.txt"
    
    source "${BATS_TEST_DIRNAME}/../scripts/numeric_processor.sh"
    run process_numbers "$BATS_TEST_TMPDIR/invalid.txt"
    [ "$status" -ne 0 ]
    [[ "$output" == *"invalid"* || "$output" == *"number"* ]]
}
```

**Best Practice:** Test error paths thoroughly - they're often overlooked but critical for user experience.

### Testing with External Dependencies

Handle optional dependencies gracefully:

```bash
#!/usr/bin/env bats

setup() {
    # Check for required tools
    if ! command -v jq &>/dev/null; then
        skip "jq is not installed - required for JSON tests"
    fi

    source "${BATS_TEST_DIRNAME}/../scripts/json_processor.sh"
}

@test "JSON parsing works with jq" {
    run parse_json '{"name": "test", "value": 42}'
    [ "$status" -eq 0 ]
    [[ "$output" == *"test"* ]]
}

@test "Can process complex nested JSON" {
    skip_if_missing jq
    
    json='{"users": [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]}'
    run extract_user_names "$json"
    [ "$status" -eq 0 ]
    [[ "$output" == *"Alice"* ]]
    [[ "$output" == *"Bob"* ]]
}

skip_if_missing() {
    local tool="$1"
    if ! command -v "$tool" &>/dev/null; then
        skip "$tool is not installed"
    fi
}
```

**Graceful Degradation:** Use `skip` to mark tests that can't run in the current environment instead of failing them.

### Testing Shell Compatibility

Ensure scripts work across different shells:

```bash
#!/usr/bin/env bats

@test "Script works in bash" {
    bash "${BATS_TEST_DIRNAME}/../scripts/portable.sh" --version
}

@test "Script works in sh (POSIX mode)" {
    sh "${BATS_TEST_DIRNAME}/../scripts/portable.sh" --version
}

@test "Script works in dash" {
    if ! command -v dash &>/dev/null; then
        skip "dash not installed"
    fi
    dash "${BATS_TEST_DIRNAME}/../scripts/portable.sh" --version
}

@test "Script uses POSIX-compliant syntax" {
    # Check for bash-specific features
    ! grep -q 'function ' "${BATS_TEST_DIRNAME}/../scripts/portable.sh"
    ! grep -q '\[\[' "${BATS_TEST_DIRNAME}/../scripts/portable.sh"
    ! grep -q '=~' "${BATS_TEST_DIRNAME}/../scripts/portable.sh"
}
```

**Portability Tip:** If your script should work on different systems, test with multiple shells to catch compatibility issues.

### Parallel Test Execution

Test concurrent operations:

```bash
#!/usr/bin/env bats

@test "Multiple operations can run concurrently" {
    source "${BATS_TEST_DIRNAME}/../scripts/parallel_processor.sh"
    
    # Start multiple background processes
    for i in {1..5}; do
        process_item "$i" "$BATS_TEST_TMPDIR/output_$i.txt" &
    done
    
    # Wait for all to complete
    wait
    
    # Verify all outputs
    for i in {1..5}; do
        [ -f "$BATS_TEST_TMPDIR/output_$i.txt" ]
    done
}

@test "Concurrent file operations don't conflict" {
    source "${BATS_TEST_DIRNAME}/../scripts/file_handler.sh"
    
    # Create multiple files concurrently
    for i in {1..10}; do
        (
            echo "Content $i" > "$BATS_TEST_TMPDIR/file_$i.txt"
        ) &
    done
    wait
    
    # Verify no data corruption
    [ "$(ls "$BATS_TEST_TMPDIR"/file_*.txt | wc -l)" -eq 10 ]
}
```

## Test Organization with Helpers

### test_helper.sh Pattern

Create reusable test utilities:

```bash
#!/usr/bin/env bash
# File: tests/test_helper.bash

# Source script under test
export SCRIPT_DIR="${BATS_TEST_DIRNAME%/*}/scripts"
export BIN_DIR="${BATS_TEST_DIRNAME%/*}/bin"

# Common assertion helpers
assert_file_exists() {
    local file="$1"
    if [ ! -f "$file" ]; then
        echo "Expected file to exist: $file" >&2
        return 1
    fi
}

assert_file_contains() {
    local file="$1"
    local pattern="$2"
    
    if [ ! -f "$file" ]; then
        echo "File does not exist: $file" >&2
        return 1
    fi

    if ! grep -q "$pattern" "$file"; then
        echo "File does not contain pattern: $pattern" >&2
        echo "File contents:" >&2
        cat "$file" >&2
        return 1
    fi
}

assert_output_contains() {
    local pattern="$1"
    if [[ ! "$output" == *"$pattern"* ]]; then
        echo "Output does not contain: $pattern" >&2
        echo "Actual output:" >&2
        echo "$output" >&2
        return 1
    fi
}

# Setup helpers
create_test_workspace() {
    export TEST_WORKSPACE=$(mktemp -d)
    mkdir -p "$TEST_WORKSPACE/input"
    mkdir -p "$TEST_WORKSPACE/output"
    mkdir -p "$TEST_WORKSPACE/temp"
}

cleanup_test_workspace() {
    if [ -n "$TEST_WORKSPACE" ] && [ -d "$TEST_WORKSPACE" ]; then
        rm -rf "$TEST_WORKSPACE"
    fi
}

# Stub helpers
create_command_stub() {
    local cmd="$1"
    local output="$2"
    local exit_code="${3:-0}"
    local stub_dir="${STUBS_DIR:-$BATS_TEST_TMPDIR/stubs}"
    
    mkdir -p "$stub_dir"
    
    cat > "$stub_dir/$cmd" <<EOF
#!/bin/bash
echo "$output"
exit $exit_code
EOF
    chmod +x "$stub_dir/$cmd"
    export PATH="$stub_dir:$PATH"
}
```

**Usage in tests:**

```bash
#!/usr/bin/env bats

load test_helper

setup() {
    create_test_workspace
}

teardown() {
    cleanup_test_workspace
}

@test "Uses test helper for assertions" {
    echo "content" > "$TEST_WORKSPACE/output/result.txt"
    
    assert_file_exists "$TEST_WORKSPACE/output/result.txt"
    assert_file_contains "$TEST_WORKSPACE/output/result.txt" "content"
}

@test "Uses helper to create stubs" {
    create_command_stub "git" "commit abc123" 0
    
    run git status
    assert_output_contains "abc123"
}
```

**Benefits:**

- Reduces code duplication across test files
- Provides consistent error messages
- Makes tests more readable and maintainable

## CI/CD Integration

### GitHub Actions Workflow

```yaml
name: Shell Script Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        shell: [bash, dash, sh]

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Install Bats
        run: |
          npm install --global bats

      - name: Run tests with ${{ matrix.shell }}
        run: |
          export TEST_SHELL=${{ matrix.shell }}
          bats tests/*.bats

      - name: Run tests with TAP output
        if: always()
        run: |
          bats tests/*.bats --tap | tee test_output.tap

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results-${{ matrix.shell }}
          path: test_output.tap
```

**Advanced workflow with parallel execution:**

```yaml
name: Comprehensive Shell Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
        bats-version: ['1.11.0']
      fail-fast: false

    steps:
      - uses: actions/checkout@v4

      - name: Setup Bats
        uses: bats-core/bats-action@2.0.0

      - name: Run unit tests
        run: bats tests/unit/*.bats --timing

      - name: Run integration tests
        run: bats tests/integration/*.bats --timing

      - name: Run tests in parallel
        run: bats tests/*.bats --jobs 4 --timing

      - name: Generate coverage report
        if: matrix.os == 'ubuntu-latest'
        run: |
          # Optional: Use kcov or similar for coverage
          echo "Coverage reporting setup here"
```

### Makefile Integration

```makefile
.PHONY: test test-verbose test-tap test-unit test-integration test-parallel clean

# Default test target
test:
    @echo "Running all tests..."
    bats tests/*.bats

# Verbose output for debugging
test-verbose:
    @echo "Running tests with verbose output..."
    bats tests/*.bats --verbose

# TAP format output
test-tap:
    @echo "Running tests with TAP output..."
    bats tests/*.bats --tap

# Run only unit tests
test-unit:
    @echo "Running unit tests..."
    bats tests/unit/*.bats

# Run only integration tests
test-integration:
    @echo "Running integration tests..."
    bats tests/integration/*.bats

# Run tests in parallel for speed
test-parallel:
    @echo "Running tests in parallel..."
    bats tests/*.bats --jobs 4

# Watch mode for TDD workflow
test-watch:
    @echo "Watching for changes..."
    while true; do \
        make test; \
        inotifywait -qre close_write tests/ scripts/; \
    done

# Clean up test artifacts
clean:
    @echo "Cleaning up test artifacts..."
    rm -rf tests/tmp/
    rm -f test_output.tap
    rm -f coverage/

# Run linting on shell scripts
lint:
    @echo "Linting shell scripts..."
    shellcheck scripts/*.sh
    shellcheck tests/*.bats

# Full validation: lint + test
validate: lint test
    @echo "✓ All checks passed"
```

**Usage:**

```bash
# Run all tests
make test

# Run with verbose output for debugging
make test-verbose

# Run tests in parallel for CI
make test-parallel

# Run only integration tests
make test-integration

# Full validation before commit
make validate
```

## Best Practices Summary

### Test Quality

1. **One assertion per test** - Tests should verify a single behavior
2. **Descriptive test names** - Use clear, complete sentences
3. **Test independence** - Tests should not depend on execution order
4. **Clean up resources** - Always remove temporary files in teardown
5. **Test both paths** - Verify success AND failure scenarios

### Test Organization

1. **Group related tests** - Use separate files for unit vs integration tests
2. **Use fixtures** - Store test data in dedicated `fixtures/` directory
3. **Create helpers** - Extract common patterns into `test_helper.bash`
4. **Document complex setups** - Explain unusual test patterns with comments

### Performance

1. **Use global setup** - Run expensive operations once with `setup_file`
2. **Run in parallel** - Use `bats --jobs N` for faster test execution
3. **Mock external calls** - Stub network requests and slow commands
4. **Keep tests fast** - Each test should complete in milliseconds

### Maintainability

1. **Follow conventions** - Consistent naming and structure
2. **Version control fixtures** - Check in test data files
3. **Update tests with code** - Keep tests in sync with implementation
4. **Review test failures** - Investigate and fix flaky tests immediately

### CI/CD Integration

1. **Run tests automatically** - On every push and pull request
2. **Test multiple environments** - Different OS and shell versions
3. **Generate reports** - Use TAP output for test dashboards
4. **Fail fast** - Stop CI on test failures

## Common Pitfalls and Solutions

### Pitfall: Tests Pass but Code is Broken

**Problem:** Tests don't actually verify the behavior

**Solution:** Always watch tests fail first (TDD approach)

```bash
# Write test first
@test "Function returns correct value" {
    run my_function "input"
    [ "$output" = "expected" ]
}

# Run and verify it FAILS (function doesn't exist yet)
# Then implement the function
# Run again and verify it PASSES
```

### Pitfall: Tests Depend on System State

**Problem:** Tests pass on developer machine but fail in CI

**Solution:** Isolate tests with proper setup/teardown

```bash
setup() {
    # Create isolated environment
    export HOME="$BATS_TEST_TMPDIR/home"
    export XDG_CONFIG_HOME="$HOME/.config"
    mkdir -p "$XDG_CONFIG_HOME"
    
    # Set up clean PATH
    export PATH="/usr/local/bin:/usr/bin:/bin"
}
```

### Pitfall: Flaky Tests Due to Timing

**Problem:** Tests occasionally fail due to race conditions

**Solution:** Use proper synchronization

```bash
@test "Waits for background process" {
    my_background_task &
    local pid=$!
    
    # Wait with timeout
    for i in {1..30}; do
        if [ -f "$BATS_TEST_TMPDIR/done.txt" ]; then
            break
        fi
        sleep 0.1
    done
    
    wait $pid
    [ -f "$BATS_TEST_TMPDIR/done.txt" ]
}
```

### Pitfall: Hard to Debug Failures

**Problem:** Test fails but output doesn't show why

**Solution:** Add diagnostic output

```bash
@test "Processes file correctly" {
    run process_file "$input"
    
    # Add diagnostic output on failure
    if [ "$status" -ne 0 ]; then
        echo "Command failed with status: $status" >&2
        echo "Output:" >&2
        echo "$output" >&2
        echo "Input file contents:" >&2
        cat "$input" >&2
    fi
    
    [ "$status" -eq 0 ]
}
```

## Additional Resources

### Official Documentation

- **Bats Core**: <https://github.com/bats-core/bats-core>
- **Bats Docs**: <https://bats-core.readthedocs.io/>
- **TAP Protocol**: <https://testanything.org/>

### Bats Libraries

- **bats-support**: <https://github.com/bats-core/bats-support> - Additional assertions
- **bats-assert**: <https://github.com/bats-core/bats-assert> - Helpful assertion functions
- **bats-file**: <https://github.com/bats-core/bats-file> - File system assertions

### Testing Methodology

- **Test-Driven Development**: <https://en.wikipedia.org/wiki/Test-driven_development>
- **Testing Best Practices**: Write tests that document behavior, not implementation

### Shell Testing Tools

- **ShellCheck**: <https://www.shellcheck.net/> - Static analysis for shell scripts
- **shfmt**: <https://github.com/mvdan/sh> - Shell script formatter
- **bashate**: <https://github.com/openstack/bashate> - Bash style checker

## Quick Reference

### Common Bats Variables

- `$status` - Exit code of last `run` command
- `$output` - Combined stdout/stderr of last `run` command
- `$lines` - Array of output lines from last `run` command
- `${lines[0]}` - First line of output
- `${#lines[@]}` - Number of output lines
- `$BATS_TEST_DIRNAME` - Directory containing the test file
- `$BATS_TEST_FILENAME` - Filename of the test file
- `$BATS_TEST_NAME` - Name of the current test
- `$BATS_TEST_TMPDIR` - Temporary directory for the current test

### Common Assertions

```bash
# Exit codes
[ "$status" -eq 0 ]      # Success
[ "$status" -ne 0 ]      # Failure
[ "$status" -eq 127 ]    # Specific code

# String comparison
[ "$output" = "exact" ]                # Exact match
[[ "$output" == *"substring"* ]]       # Contains
[[ "$output" =~ ^pattern$ ]]           # Regex match

# File tests
[ -f "$file" ]           # File exists
[ -d "$dir" ]            # Directory exists
[ -r "$file" ]           # Readable
[ -w "$file" ]           # Writable
[ -x "$file" ]           # Executable
[ -s "$file" ]           # Not empty

# Numeric comparison
[ "$count" -eq 5 ]       # Equal
[ "$count" -gt 0 ]       # Greater than
[ "$count" -lt 10 ]      # Less than
```

### Useful Patterns

```bash
# Run command and capture output
run command arg1 arg2

# Skip test conditionally
skip "Reason for skipping"
skip_if_missing "jq"

# Load helper functions
load test_helper

# Create temp directory
TEST_DIR=$(mktemp -d)

# Check command exists
command -v tool &>/dev/null

# Stub a command
PATH="/path/to/stubs:$PATH"
```

Related Skills

dbt-transformation-patterns

16
from diegosouzapw/awesome-omni-skill

Master dbt (data build tool) for analytics engineering with model organization, testing, documentation, and incremental strategies. Use when building data transformations, creating data models, or ...

data-fetching-patterns

16
from diegosouzapw/awesome-omni-skill

Explains data fetching strategies including fetch on render, fetch then render, render as you fetch, and server-side data fetching. Use when implementing data loading, optimizing loading performance, or choosing between client and server data fetching.

airflow-dag-patterns

16
from diegosouzapw/awesome-omni-skill

Build production Apache Airflow DAGs with best practices for operators, sensors, testing, and deployment. Use when creating data pipelines, orchestrating workflows, or scheduling batch jobs.

ai-product-patterns

16
from diegosouzapw/awesome-omni-skill

Builds AI-native products using OpenAI's development philosophy and modern AI UX patterns. Use when integrating AI features, designing for model improvements, implementing evals as product specs, or creating AI-first experiences. Based on Kevin Weil (OpenAI CPO) on building for future models, hybrid approaches, and cost optimization.

ai-powered-pentesting

16
from diegosouzapw/awesome-omni-skill

Guide for AI-powered penetration testing tools, red teaming frameworks, and autonomous security agents.

ab-testing-analyzer

16
from diegosouzapw/awesome-omni-skill

全面的AB测试分析工具,支持实验设计、统计检验、用户分群分析和可视化报告生成。用于分析产品改版、营销活动、功能优化等AB测试结果,提供统计显著性检验和深度洞察。

a2a-executor-patterns

16
from diegosouzapw/awesome-omni-skill

Agent-to-Agent (A2A) executor implementation patterns for task handling, execution management, and agent coordination. Use when building A2A executors, implementing task handlers, creating agent execution flows, or when user mentions A2A protocol, task execution, agent executors, task handlers, or agent coordination.

GitOps Patterns

16
from diegosouzapw/awesome-omni-skill

ArgoCD ApplicationSets, progressive delivery, Harness GitX, and multi-cluster GitOps patterns

dotnet-gha-patterns

16
from diegosouzapw/awesome-omni-skill

Composes GitHub Actions workflows. Reusable workflows, composite actions, matrix, caching.

cli-e2e-testing

16
from diegosouzapw/awesome-omni-skill

CLI E2E testing patterns with BATS - parallelization, state sharing, and timeout management

bash-defensive-patterns

16
from diegosouzapw/awesome-omni-skill

Master defensive Bash programming techniques for production-grade scripts. Use when writing robust shell scripts, CI/CD pipelines, or system utilities requiring fault tolerance and safety.

apollo-client-patterns

16
from diegosouzapw/awesome-omni-skill

Use when implementing Apollo Client patterns for queries, mutations, cache management, and local state in React applications.