Back to Testing & Quality Assurance

bats-testing-patterns

batsshell scriptingbashtestingunit testingtddci/cddevops
⭐ 36.8kπŸ“„ MITπŸ•’ 2026-06-16Source β†—

Install this skill

npx skills add wshobson/agents

Works across Claude Code, Cursor, Codex, Copilot & Antigravity

The Bats-testing-patterns skill provides a structured methodology for validating shell scripts using the Bash Automated Testing System. By utilizing the Test Anything Protocol, it enables developers to isolate script functions, monitor exit statuses, and verify stream outputs within isolated environments. This skill focuses on the implementation of lifecycle hooks like setup and teardown to ensure a clean state between individual test cases, preventing side effects from leaking across the suite. It emphasizes practical verification techniques for file system operations, command exit codes, and standard output parsing. By adopting these patterns, developers transform ad-hoc script maintenance into a repeatable quality assurance process, identifying regressions in CLI logic before deployment. This approach minimizes environment-specific bugs and ensures that complex shell pipelines behave predictably across different local or CI-based shell versions.

When to Use This Skill

  • β€’Creating unit tests for standalone CLI utilities and automation scripts
  • β€’Developing mock environments to test file processing scripts
  • β€’Defining regression suites for shell scripts used in CI/CD pipelines
  • β€’Testing error handling and edge-case exit triggers in bash functions

How to Invoke This Skill

Example prompts that trigger this skill in Claude Code, Cursor, or Antigravity:

  • β€œcreate a unit test for this bash script using bats
  • β€œhow do I mock a file operation in bats-core
  • β€œwrite a bats test case that validates exit code 1
  • β€œsetup and teardown pattern for temporary directories in bats
  • β€œhow to verify multiline output in a bats test

Pro Tips

  • πŸ’‘Always use `setup` and `teardown` functions in Bats to create a clean environment for each test, ensuring test isolation and preventing side effects.
  • πŸ’‘Structure your Bats test files to mirror your script's modularity; one test file per script or logical component simplifies maintenance and debugging.
  • πŸ’‘Leverage Bats' `run` command to capture stdout, stderr, and exit codes, then use `assert_success`, `assert_failure`, `assert_output`, and `assert_line` for precise assertions.

What this skill does

  • β€’Capturing and asserting shell command exit codes
  • β€’Validating standard output line-by-line using the lines array
  • β€’Isolating test environments with setup and teardown hooks
  • β€’Performing filesystem assertions including file existence, content, and permissions
  • β€’Handling string pattern matching and substring identification in script output

When not to use it

  • βœ•Testing high-level GUI applications or browser-based user interfaces
  • βœ•Running heavy-duty integration tests that require complex remote infrastructure setup
  • βœ•Executing long-running asynchronous processes where shell exit codes are not the primary metric

Example workflow

  1. Install bats-core using a package manager or source installation
  2. Initialize a test directory structure relative to your project bin folder
  3. Write test files using the @test syntax and load common test helpers
  4. Define setup functions to generate temporary test data or mock files
  5. Execute the test suite and analyze output for failed assertions
  6. Adjust script logic based on test failures and re-run the test suite

Prerequisites

  • –Bash shell environment
  • –bats-core installed on the development machine

Pitfalls & limitations

  • !Forgetting to clean up temporary directories in teardown, which pollutes the host system
  • !Assuming environment variables persist between tests when they are actually reset
  • !Over-reliance on exact string matching for output, which breaks when minor formatting changes occur

FAQ

How does Bats handle script sourcing?
You should use the source command within your setup function or at the top of your test file, pointing to the script relative to BATS_TEST_DIRNAME.
Can I test multiple shell dialects with Bats?
Yes, you can specify the shebang line in your script to use different shells, and Bats will capture the exit status regardless of the underlying shell language.
What is the difference between status and output in a Bats test?
The status variable contains the integer exit code of the last command run, while the output variable holds the full standard output captured during that execution.

How it compares

Unlike manual bash testing which involves repetitive ad-hoc execution, Bats provides a formal framework that yields machine-readable TAP output and enforces consistent assertion logic.

Source & trust

⭐ 37k starsπŸ“„ MITπŸ•’ Updated 2026-06-16
πŸ“„ Full skill instructions β€” original source: wshobson/agents
# 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

# 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

.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

How to Use This Skill Unit

Option A: Project-Specific (Recommended)

  1. Click "Download" above
  2. In your project, create the directory: .agent/skills/bats-testing-patterns/
  3. Save the file as SKILL.md
  4. The agent will automatically discover the skill based on its description.

Option B: Global Installation (All Agents)

Save the file to these locations to make it available across all projects:

  • Claude Code: ~/.claude/skills/wshobson/agents/bats-testing-patterns/SKILL.md
  • Cursor: ~/.cursor/skills/wshobson/agents/bats-testing-patterns/SKILL.md
  • Antigravity: ~/.gemini/antigravity/skills/wshobson/agents/bats-testing-patterns/SKILL.md

πŸš€ Install with CLI:
npx skills add wshobson/agents

Read the Master Guide: Mastering Agent Skills β†’

Recommended Rules

View more rules β†’

Recommended Workflows

View more workflows β†’

Recommended MCP Servers

View more MCP servers β†’

Take It Further

Maximize your productivity with these powerful resources

πŸ“‹

Define Your Standards

Set up coding standards to ensure this workflow produces consistent, high-quality results.

Browse Rules Library
πŸ“–

Master Workflows

Learn how to create custom workflows, use Turbo Mode, and build your automation library.

Complete Guide

How to use this Skill in Claude Code & Cursor

For Claude Code (CLI)

To use this skill in Claude Code, copy the rule content into your project's custom instructions or follow our Add-Skill CLI guide. This ensures Claude follows your standards during every code generation.

For Cursor & Windsurf

For Cursor or Windsurf, individual skills are best used in the "Rules for AI" section. This specific unit helps the agent avoid testing & quality assurance issues, leading to cleaner, more efficient code.

Why the skill format matters: the standardized Agent Skills format lets your AI agent load detailed instructions only when they are relevant, keeping your prompt clean while improving results.

Source & attribution

This skill is categorized under Testing & Quality Assurance and is published by W. Shobson, maintained in wshobson/agents.

← Browse All Agent Skills
Sponsored AI assistant. Recommendations may be paid.