Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/asymmetric-al-bats-testing-patterns/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✓ | ▲ Improved | 179% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 115% | 0% |
| case-18 | ✗→✓ | ▲ Improved | 195% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 214% | 0% |
| case-05 | ✓→✓ | = Same ✓ | 735% | 0% |
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.
Bats (Bash Automated Testing System) is a TAP (Test Anything Protocol) compliant testing framework for shell scripts that provides:
bash# macOS with Homebrew brew install bats-core # Ubuntu/Debian git clone https://github.com/bats-core/bats-core.git cd bats-core ./install.sh /usr/local # From npm (Node.js) npm install --global bats # Windows (run tests in a POSIX shell) npm install --global bats # Use Git Bash, MSYS2, or WSL to run examples in this skill. # PowerShell/CMD are not drop-in compatible with these shell snippets. # Verify installation bats --version
project/
├── bin/
│ ├── script.sh
│ └── helper.sh
├── tests/
│ ├── test_script.bats
│ ├── test_helper.sh
│ ├── fixtures/
│ │ ├── input.txt
│ │ └── expected_output.txt
│ └── helpers/
│ └── mocks.bash
└── README.mdbash#!/usr/bin/env bats # Load test helper if present load test_helper # Setup runs before each test setup() { export TMPDIR=$(mktemp -d) } # Teardown runs after each test teardown() { 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 ] }
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" ] }
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" = "line1 line2 line3" ] } @test "Lines variable contains output" { run printf "line1\nline2\nline3" [ "${lines[0]}" = "line1" ] [ "${lines[1]}" = "line2" ] [ "${lines[2]}" = "line3" ] }
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" if mode=$(stat -c %a "$TMPDIR/test.txt" 2>/dev/null); then [ "$mode" = "644" ] elif mode=$(stat -f %OLp "$TMPDIR/test.txt" 2>/dev/null); then [ "$mode" = "644" ] else skip "No compatible stat format for permission checks on this platform" fi } @test "File size is correct" { echo -n "12345" > "$TMPDIR/test.txt" [ "$(wc -c < "$TMPDIR/test.txt")" -eq 5 ] }
Permission assertions can be platform-sensitive on Windows-native filesystems. For strict permission-bit checks (chmod, stat), run tests in WSL or conditionally skip when mode bits are not reliably enforced.
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" ] }
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" ] }
bash#!/usr/bin/env bats # Load shared setup from test_helper.sh load test_helper # setup_file runs once before all tests setup_file() { export SHARED_RESOURCE=$(mktemp -d) echo "Expensive setup" > "$SHARED_RESOURCE/data.txt" } # teardown_file runs once after all tests teardown_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" ] }
bash#!/usr/bin/env bats # Mock external command my_external_tool() { echo "mocked output" return 0 } @test "Function uses mocked tool" { export -f my_external_tool run my_function [[ "$output" == *"mocked output"* ]] }
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 #!/usr/bin/env bash echo "$output" exit $code EOF chmod +x "$STUBS_DIR/$cmd" } @test "Function works with stubbed curl" { create_stub curl "{ \"status\": \"ok\" }" 0 run my_api_function [ "$status" -eq 0 ] }
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"* ]] }
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" }
bash#!/usr/bin/env bats generate_fixture() { local lines="$1" local file="$2" i=1 while [ "$i" -le "$lines" ]; do echo "Line $i content" >> "$file" i=$((i + 1)) 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 ] }
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" || skip "chmod 000 unsupported on this platform" if mode=$(stat -c %a "$TMPDIR/readonly.txt" 2>/dev/null); then [ "$mode" = "000" ] || skip "Permission bits are not enforced on this filesystem" elif mode=$(stat -f %OLp "$TMPDIR/readonly.txt" 2>/dev/null); then [ "$mode" = "000" ] || skip "Permission bits are not enforced on this filesystem" else skip "No compatible stat format for permission checks on this platform" fi 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:"* ]] }
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 ] }
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 }
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" ] }
bash#!/usr/bin/env bash # Source script under test export SCRIPT_DIR="${BATS_TEST_DIRNAME%/*}/bin" # Common test utilities assert_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 directory setup_test_dir() { export TEST_DIR=$(mktemp -d) } cleanup_test_dir() { rm -rf "$TEST_DIR" }
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.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
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-04 | pass→pass | 9,560 | 4,531 | -53% | 1 | 1 | 0% | 1,558 | 4,893 | +214% | 0 | 0 | — |
case-05 | pass→pass | 3,589 | 3,474 | -3% | 1 | 1 | 0% | 564 | 4,710 | +735% | 0 | 0 | — |
case-01 | pass→pass | 10,245 | 7,847 | -23% | 1 | 1 | 0% | 1,599 | 5,411 | +238% | 0 | 0 | — |
case-02 | pass→pass | 13,703 | 13,036 | -5% | 1 | 1 | 0% | 2,223 | 6,273 | +182% | 0 | 0 | — |
case-03 | pass→pass | 7,109 | 4,699 | -34% | 1 | 1 | 0% | 1,096 | 4,908 | +348% | 0 | 0 | — |
case-06 | pass→pass | 11,401 | 5,528 | -52% | 1 | 1 | 0% | 1,946 | 5,107 | +162% | 0 | 0 | — |
case-07 | fail→pass | 10,402 | 4,034 | -61% | 1 | 1 | 0% | 1,744 | 4,863 | +179% | 0 | 0 | — |
case-08 | pass→pass | 8,451 | 7,537 | -11% | 1 | 1 | 0% | 1,390 | 5,518 | +297% | 0 | 0 | — |
case-09 | fail→pass | 15,346 | 7,044 | -54% | 1 | 1 | 0% | 2,511 | 5,387 | +115% | 0 | 0 | — |
case-10 | pass→pass | 12,667 | 6,889 | -46% | 1 | 1 | 0% | 2,301 | 5,347 | +132% | 0 | 0 | — |
case-11 | fail→fail | 6,804 | 3,728 | -45% | 1 | 1 | 0% | 1,187 | 4,799 | +304% | 0 | 0 | — |
case-12 | pass→pass | 7,510 | 3,068 | -59% | 1 | 1 | 0% | 1,154 | 4,606 | +299% | 0 | 0 | — |
case-13 | pass→pass | 3,898 | 3,823 | -2% | 1 | 1 | 0% | 679 | 4,851 | +614% | 0 | 0 | — |
case-14 | pass→pass | 10,577 | 6,455 | -39% | 1 | 1 | 0% | 1,789 | 5,340 | +198% | 0 | 0 | — |
case-15 | pass→pass | 13,294 | 7,127 | -46% | 1 | 1 | 0% | 2,251 | 5,355 | +138% | 0 | 0 | — |
case-16 | pass→pass | 10,605 | 11,776 | +11% | 1 | 1 | 0% | 1,663 | 6,124 | +268% | 0 | 0 | — |
case-17 | pass→pass | 8,060 | 5,054 | -37% | 1 | 1 | 0% | 1,315 | 4,891 | +272% | 0 | 0 | — |
case-18 | fail→pass | 10,510 | 5,648 | -46% | 1 | 1 | 0% | 1,698 | 5,015 | +195% | 0 | 0 | — |
case-19 | pass→pass | 4,447 | 2,344 | -47% | 1 | 1 | 0% | 702 | 4,563 | +550% | 0 | 0 | — |
case-20 | pass→pass | 3,217 | 1,722 | -46% | 1 | 1 | 0% | 476 | 4,404 | +825% | 0 | 0 | — |
case-21 | pass→pass | 5,772 | 3,739 | -35% | 1 | 1 | 0% | 877 | 4,749 | +442% | 0 | 0 | — |
case-22 | pass→pass | 8,322 | 3,233 | -61% | 1 | 1 | 0% | 1,272 | 4,625 | +264% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +14 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.