bats-testing-patterns
upstream

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.

ID: bats-testing-patterns
Preview
Codigo

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


`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

Informacion

Estado
Activo
Origen upstream
Usos 3
Ultimo uso 23/01 16:57
Actualizado 09/01/2026 02:14

Archivo origen

Z:\Repositorios\core\upstream\plugins\shell-scripting\skills\bats-testing-patterns\SKILL.md