🎯

bats-testing-patterns

🎯Skill

from ovachiever/droid-tings

VibeIndex|
What it does

Enables comprehensive shell script testing using Bats, covering unit tests, TDD, and CI/CD pipeline validation for robust shell utility development.

πŸ“¦

Part of

ovachiever/droid-tings(370 items)

bats-testing-patterns

Installation

git cloneClone repository
git clone https://github.com/bats-core/bats-core.git
Shell ScriptRun shell script
./install.sh /usr/local
πŸ“– Extracted from docs: ovachiever/droid-tings
13Installs
20
-
AddedFeb 4, 2026

Skill Details

SKILL.md

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.

Overview

# 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 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

# Verify installation

bats --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 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 ]

}

```

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" = "line1

line2

line3" ]

}

@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.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" ]

}

```

Mocking and Stubbing Patterns

Function Mocking

```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" ]]

}

```

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" <

#!/bin/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 ]

}

```

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 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"

}

```

Integration with CI/CD

GitHub Actions Workflow

```yaml

name: 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 principle
  2. Use descriptive test names - Clearly states what is being tested
  3. Clean up after tests - Always remove temporary files in teardown
  4. Test both success and failure paths - Don't just test happy path
  5. Mock external dependencies - Isolate unit under test
  6. Use fixtures for complex data - Makes tests more readable
  7. Run tests in CI/CD - Catch regressions early
  8. Test across shell dialects - Ensure portability
  9. Keep tests fast - Run in parallel when possible
  10. Document complex test setup - Explain unusual patterns

Resources

  • Bats GitHub: https://github.com/bats-core/bats-core
  • Bats Documentation: https://bats-core.readthedocs.io/
  • TAP Protocol: https://testanything.org/
  • Test-Driven Development: https://en.wikipedia.org/wiki/Test-driven_development