Claude Agent Skill · by Wshobson

Bats Testing Patterns

If you're writing shell scripts for production, you need tests, and this skill shows you how to build them with Bats. It covers the essentials like setup/teardo

Install
Terminal · npx
$npx skills add https://github.com/wshobson/agents --skill bats-testing-patterns
Works with Paperclip

How Bats Testing Patterns fits into a Paperclip company.

Bats Testing Patterns drops into any Paperclip agent that handles this kind of work. Assign it to a specialist inside a pre-configured PaperclipOrg company and the skill becomes available on every heartbeat — no prompt engineering, no tool wiring.

S
SaaS FactoryPaired

Pre-configured AI company — 18 agents, 18 skills, one-time purchase.

$27$59
Explore pack
Source file
SKILL.md624 lines
Expand
---name: bats-testing-patternsdescription: Master Bash Automated Testing System (Bats) for comprehensive shell script testing. Use when writing tests for shell scripts, CI/CD pipelines, or requiring test-driven development of shell utilities.--- # Bats Testing Patterns Comprehensive guidance for writing comprehensive unit tests for shell scripts using Bats (Bash Automated Testing System), including test patterns, fixtures, and best practices for production-grade shell testing. ## When to Use This Skill - Writing unit tests for shell scripts- Implementing test-driven development (TDD) for scripts- Setting up automated testing in CI/CD pipelines- Testing edge cases and error conditions- Validating behavior across different shell environments- Building maintainable test suites for scripts- Creating fixtures for complex test scenarios- Testing multiple shell dialects (bash, sh, dash) ## Bats Fundamentals ### What is Bats? Bats (Bash Automated Testing System) is a TAP (Test Anything Protocol) compliant testing framework for shell scripts that provides: - Simple, natural test syntax- TAP output format compatible with CI systems- Fixtures and setup/teardown support- Assertion helpers- Parallel test execution ### Installation ```bash# macOS with Homebrewbrew install bats-core # Ubuntu/Debiangit clone https://github.com/bats-core/bats-core.gitcd bats-core./install.sh /usr/local # From npm (Node.js)npm install --global bats # Verify installationbats --version``` ### File Structure ```project/├── bin/│   ├── script.sh│   └── helper.sh├── tests/│   ├── test_script.bats│   ├── test_helper.sh│   ├── fixtures/│   │   ├── input.txt│   │   └── expected_output.txt│   └── helpers/│       └── mocks.bash└── README.md``` ## Basic Test Structure ### Simple Test File ```bash#!/usr/bin/env bats # Load test helper if presentload test_helper # Setup runs before each testsetup() {    export TMPDIR=$(mktemp -d)} # Teardown runs after each testteardown() {    rm -rf "$TMPDIR"} # Test: simple assertion@test "Function returns 0 on success" {    run my_function "input"    [ "$status" -eq 0 ]} # Test: output verification@test "Function outputs correct result" {    run my_function "test"    [ "$output" = "expected output" ]} # Test: error handling@test "Function returns 1 on missing argument" {    run my_function    [ "$status" -eq 1 ]}``` ## Assertion Patterns ### Exit Code Assertions ```bash#!/usr/bin/env bats @test "Command succeeds" {    run true    [ "$status" -eq 0 ]} @test "Command fails as expected" {    run false    [ "$status" -ne 0 ]} @test "Command returns specific exit code" {    run my_function --invalid    [ "$status" -eq 127 ]} @test "Can capture command result" {    run echo "hello"    [ $status -eq 0 ]    [ "$output" = "hello" ]}``` ### Output Assertions ```bash#!/usr/bin/env bats @test "Output matches string" {    result=$(echo "hello world")    [ "$result" = "hello world" ]} @test "Output contains substring" {    result=$(echo "hello world")    [[ "$result" == *"world"* ]]} @test "Output matches pattern" {    result=$(date +%Y)    [[ "$result" =~ ^[0-9]{4}$ ]]} @test "Multi-line output" {    run printf "line1\nline2\nline3"    [ "$output" = "line1line2line3" ]} @test "Lines variable contains output" {    run printf "line1\nline2\nline3"    [ "${lines[0]}" = "line1" ]    [ "${lines[1]}" = "line2" ]    [ "${lines[2]}" = "line3" ]}``` ### File Assertions ```bash#!/usr/bin/env bats @test "File is created" {    [ ! -f "$TMPDIR/output.txt" ]    my_function > "$TMPDIR/output.txt"    [ -f "$TMPDIR/output.txt" ]} @test "File contents match expected" {    my_function > "$TMPDIR/output.txt"    [ "$(cat "$TMPDIR/output.txt")" = "expected content" ]} @test "File is readable" {    touch "$TMPDIR/test.txt"    [ -r "$TMPDIR/test.txt" ]} @test "File has correct permissions" {    touch "$TMPDIR/test.txt"    chmod 644 "$TMPDIR/test.txt"    [ "$(stat -f %OLp "$TMPDIR/test.txt")" = "644" ]} @test "File size is correct" {    echo -n "12345" > "$TMPDIR/test.txt"    [ "$(wc -c < "$TMPDIR/test.txt")" -eq 5 ]}``` ## Setup and Teardown Patterns ### Basic Setup and Teardown ```bash#!/usr/bin/env bats setup() {    # Create test directory    TEST_DIR=$(mktemp -d)    export TEST_DIR     # Source script under test    source "${BATS_TEST_DIRNAME}/../bin/script.sh"} teardown() {    # Clean up temporary directory    rm -rf "$TEST_DIR"} @test "Test using TEST_DIR" {    touch "$TEST_DIR/file.txt"    [ -f "$TEST_DIR/file.txt" ]}``` ### Setup with Resources ```bash#!/usr/bin/env bats setup() {    # Create directory structure    mkdir -p "$TMPDIR/data/input"    mkdir -p "$TMPDIR/data/output"     # Create test fixtures    echo "line1" > "$TMPDIR/data/input/file1.txt"    echo "line2" > "$TMPDIR/data/input/file2.txt"     # Initialize environment    export DATA_DIR="$TMPDIR/data"    export INPUT_DIR="$DATA_DIR/input"    export OUTPUT_DIR="$DATA_DIR/output"} teardown() {    rm -rf "$TMPDIR/data"} @test "Processes input files" {    run my_process_script "$INPUT_DIR" "$OUTPUT_DIR"    [ "$status" -eq 0 ]    [ -f "$OUTPUT_DIR/file1.txt" ]}``` ### Global Setup/Teardown ```bash#!/usr/bin/env bats # Load shared setup from test_helper.shload test_helper # setup_file runs once before all testssetup_file() {    export SHARED_RESOURCE=$(mktemp -d)    echo "Expensive setup" > "$SHARED_RESOURCE/data.txt"} # teardown_file runs once after all teststeardown_file() {    rm -rf "$SHARED_RESOURCE"} @test "First test uses shared resource" {    [ -f "$SHARED_RESOURCE/data.txt" ]} @test "Second test uses shared resource" {    [ -d "$SHARED_RESOURCE" ]}``` ## Mocking and Stubbing Patterns ### Function Mocking ```bash#!/usr/bin/env bats # Mock external commandmy_external_tool() {    echo "mocked output"    return 0} @test "Function uses mocked tool" {    export -f my_external_tool    run my_function    [[ "$output" == *"mocked output"* ]]}``` ### Command Stubbing ```bash#!/usr/bin/env bats setup() {    # Create stub directory    STUBS_DIR="$TMPDIR/stubs"    mkdir -p "$STUBS_DIR"     # Add to PATH    export PATH="$STUBS_DIR:$PATH"} create_stub() {    local cmd="$1"    local output="$2"    local code="${3:-0}"     cat > "$STUBS_DIR/$cmd" <<EOF#!/bin/bashecho "$output"exit $codeEOF    chmod +x "$STUBS_DIR/$cmd"} @test "Function works with stubbed curl" {    create_stub curl "{ \"status\": \"ok\" }" 0    run my_api_function    [ "$status" -eq 0 ]}``` ### Variable Stubbing ```bash#!/usr/bin/env bats @test "Function handles environment override" {    export MY_SETTING="override_value"    run my_function    [ "$status" -eq 0 ]    [[ "$output" == *"override_value"* ]]} @test "Function uses default when var unset" {    unset MY_SETTING    run my_function    [ "$status" -eq 0 ]    [[ "$output" == *"default"* ]]}``` ## Fixture Management ### Using Fixture Files ```bash#!/usr/bin/env bats # Fixture directory: tests/fixtures/ setup() {    FIXTURES_DIR="${BATS_TEST_DIRNAME}/fixtures"    WORK_DIR=$(mktemp -d)    export WORK_DIR} teardown() {    rm -rf "$WORK_DIR"} @test "Process fixture file" {    # Copy fixture to work directory    cp "$FIXTURES_DIR/input.txt" "$WORK_DIR/input.txt"     # Run function    run my_process_function "$WORK_DIR/input.txt"     # Compare output    diff "$WORK_DIR/output.txt" "$FIXTURES_DIR/expected_output.txt"}``` ### Dynamic Fixture Generation ```bash#!/usr/bin/env bats generate_fixture() {    local lines="$1"    local file="$2"     for i in $(seq 1 "$lines"); do        echo "Line $i content" >> "$file"    done} @test "Handle large input file" {    generate_fixture 1000 "$TMPDIR/large.txt"    run my_function "$TMPDIR/large.txt"    [ "$status" -eq 0 ]    [ "$(wc -l < "$TMPDIR/large.txt")" -eq 1000 ]}``` ## Advanced Patterns ### Testing Error Conditions ```bash#!/usr/bin/env bats @test "Function fails with missing file" {    run my_function "/nonexistent/file.txt"    [ "$status" -ne 0 ]    [[ "$output" == *"not found"* ]]} @test "Function fails with invalid input" {    run my_function ""    [ "$status" -ne 0 ]} @test "Function fails with permission denied" {    touch "$TMPDIR/readonly.txt"    chmod 000 "$TMPDIR/readonly.txt"    run my_function "$TMPDIR/readonly.txt"    [ "$status" -ne 0 ]    chmod 644 "$TMPDIR/readonly.txt"  # Cleanup} @test "Function provides helpful error message" {    run my_function --invalid-option    [ "$status" -ne 0 ]    [[ "$output" == *"Usage:"* ]]}``` ### Testing with Dependencies ```bash#!/usr/bin/env bats setup() {    # Check for required tools    if ! command -v jq &>/dev/null; then        skip "jq is not installed"    fi     export SCRIPT="${BATS_TEST_DIRNAME}/../bin/script.sh"} @test "JSON parsing works" {    skip_if ! command -v jq &>/dev/null    run my_json_parser '{"key": "value"}'    [ "$status" -eq 0 ]}``` ### Testing Shell Compatibility ```bash#!/usr/bin/env bats @test "Script works in bash" {    bash "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1} @test "Script works in sh (POSIX)" {    sh "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1} @test "Script works in dash" {    if command -v dash &>/dev/null; then        dash "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1    else        skip "dash not installed"    fi}``` ### Parallel Execution ```bash#!/usr/bin/env bats @test "Multiple independent operations" {    run bash -c 'for i in {1..10}; do        my_operation "$i" &    done    wait'    [ "$status" -eq 0 ]} @test "Concurrent file operations" {    for i in {1..5}; do        my_function "$TMPDIR/file$i" &    done    wait    [ -f "$TMPDIR/file1" ]    [ -f "$TMPDIR/file5" ]}``` ## Test Helper Pattern ### test_helper.sh ```bash#!/usr/bin/env bash # Source script under testexport SCRIPT_DIR="${BATS_TEST_DIRNAME%/*}/bin" # Common test utilitiesassert_file_exists() {    if [ ! -f "$1" ]; then        echo "Expected file to exist: $1"        return 1    fi} assert_file_equals() {    local file="$1"    local expected="$2"     if [ ! -f "$file" ]; then        echo "File does not exist: $file"        return 1    fi     local actual=$(cat "$file")    if [ "$actual" != "$expected" ]; then        echo "File contents do not match"        echo "Expected: $expected"        echo "Actual: $actual"        return 1    fi} # Create temporary test directorysetup_test_dir() {    export TEST_DIR=$(mktemp -d)} cleanup_test_dir() {    rm -rf "$TEST_DIR"}``` ## Integration with CI/CD ### GitHub Actions Workflow ```yamlname: Tests on: [push, pull_request] jobs:  test:    runs-on: ubuntu-latest     steps:      - uses: actions/checkout@v3       - name: Install Bats        run: |          npm install --global bats       - name: Run Tests        run: |          bats tests/*.bats       - name: Run Tests with Tap Reporter        run: |          bats tests/*.bats --tap | tee test_output.tap``` ### Makefile Integration ```makefile.PHONY: test test-verbose test-tap test:	bats tests/*.bats test-verbose:	bats tests/*.bats --verbose test-tap:	bats tests/*.bats --tap test-parallel:	bats tests/*.bats --parallel 4 coverage: test	# Optional: Generate coverage reports``` ## Best Practices 1. **Test one thing per test** - Single responsibility principle2. **Use descriptive test names** - Clearly states what is being tested3. **Clean up after tests** - Always remove temporary files in teardown4. **Test both success and failure paths** - Don't just test happy path5. **Mock external dependencies** - Isolate unit under test6. **Use fixtures for complex data** - Makes tests more readable7. **Run tests in CI/CD** - Catch regressions early8. **Test across shell dialects** - Ensure portability9. **Keep tests fast** - Run in parallel when possible10. **Document complex test setup** - Explain unusual patterns