Claude
Skills
Sign in
Back

bats-testing-patterns

Included with Lifetime
$97 forever

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.

Cloud & DevOps

What this skill does


# 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/$cm

Related in Cloud & DevOps