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
1# macOS with Homebrew
2brew install bats-core
3
4# Ubuntu/Debian
5git clone https://github.com/bats-core/bats-core.git
6cd bats-core
7./install.sh /usr/local
8
9# From npm (Node.js)
10npm install --global bats
11
12# Verify installation
13bats --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
1#!/usr/bin/env bats
2
3# Load test helper if present
4load test_helper
5
6# Setup runs before each test
7setup() {
8 export TMPDIR=$(mktemp -d)
9}
10
11# Teardown runs after each test
12teardown() {
13 rm -rf "$TMPDIR"
14}
15
16# Test: simple assertion
17@test "Function returns 0 on success" {
18 run my_function "input"
19 [ "$status" -eq 0 ]
20}
21
22# Test: output verification
23@test "Function outputs correct result" {
24 run my_function "test"
25 [ "$output" = "expected output" ]
26}
27
28# Test: error handling
29@test "Function returns 1 on missing argument" {
30 run my_function
31 [ "$status" -eq 1 ]
32}
Assertion Patterns
Exit Code Assertions
bash
1#!/usr/bin/env bats
2
3@test "Command succeeds" {
4 run true
5 [ "$status" -eq 0 ]
6}
7
8@test "Command fails as expected" {
9 run false
10 [ "$status" -ne 0 ]
11}
12
13@test "Command returns specific exit code" {
14 run my_function --invalid
15 [ "$status" -eq 127 ]
16}
17
18@test "Can capture command result" {
19 run echo "hello"
20 [ $status -eq 0 ]
21 [ "$output" = "hello" ]
22}
Output Assertions
bash
1#!/usr/bin/env bats
2
3@test "Output matches string" {
4 result=$(echo "hello world")
5 [ "$result" = "hello world" ]
6}
7
8@test "Output contains substring" {
9 result=$(echo "hello world")
10 [[ "$result" == *"world"* ]]
11}
12
13@test "Output matches pattern" {
14 result=$(date +%Y)
15 [[ "$result" =~ ^[0-9]{4}$ ]]
16}
17
18@test "Multi-line output" {
19 run printf "line1\nline2\nline3"
20 [ "$output" = "line1
21line2
22line3" ]
23}
24
25@test "Lines variable contains output" {
26 run printf "line1\nline2\nline3"
27 [ "${lines[0]}" = "line1" ]
28 [ "${lines[1]}" = "line2" ]
29 [ "${lines[2]}" = "line3" ]
30}
File Assertions
bash
1#!/usr/bin/env bats
2
3@test "File is created" {
4 [ ! -f "$TMPDIR/output.txt" ]
5 my_function > "$TMPDIR/output.txt"
6 [ -f "$TMPDIR/output.txt" ]
7}
8
9@test "File contents match expected" {
10 my_function > "$TMPDIR/output.txt"
11 [ "$(cat "$TMPDIR/output.txt")" = "expected content" ]
12}
13
14@test "File is readable" {
15 touch "$TMPDIR/test.txt"
16 [ -r "$TMPDIR/test.txt" ]
17}
18
19@test "File has correct permissions" {
20 touch "$TMPDIR/test.txt"
21 chmod 644 "$TMPDIR/test.txt"
22 [ "$(stat -f %OLp "$TMPDIR/test.txt")" = "644" ]
23}
24
25@test "File size is correct" {
26 echo -n "12345" > "$TMPDIR/test.txt"
27 [ "$(wc -c < "$TMPDIR/test.txt")" -eq 5 ]
28}
Setup and Teardown Patterns
Basic Setup and Teardown
bash
1#!/usr/bin/env bats
2
3setup() {
4 # Create test directory
5 TEST_DIR=$(mktemp -d)
6 export TEST_DIR
7
8 # Source script under test
9 source "${BATS_TEST_DIRNAME}/../bin/script.sh"
10}
11
12teardown() {
13 # Clean up temporary directory
14 rm -rf "$TEST_DIR"
15}
16
17@test "Test using TEST_DIR" {
18 touch "$TEST_DIR/file.txt"
19 [ -f "$TEST_DIR/file.txt" ]
20}
Setup with Resources
bash
1#!/usr/bin/env bats
2
3setup() {
4 # Create directory structure
5 mkdir -p "$TMPDIR/data/input"
6 mkdir -p "$TMPDIR/data/output"
7
8 # Create test fixtures
9 echo "line1" > "$TMPDIR/data/input/file1.txt"
10 echo "line2" > "$TMPDIR/data/input/file2.txt"
11
12 # Initialize environment
13 export DATA_DIR="$TMPDIR/data"
14 export INPUT_DIR="$DATA_DIR/input"
15 export OUTPUT_DIR="$DATA_DIR/output"
16}
17
18teardown() {
19 rm -rf "$TMPDIR/data"
20}
21
22@test "Processes input files" {
23 run my_process_script "$INPUT_DIR" "$OUTPUT_DIR"
24 [ "$status" -eq 0 ]
25 [ -f "$OUTPUT_DIR/file1.txt" ]
26}
Global Setup/Teardown
bash
1#!/usr/bin/env bats
2
3# Load shared setup from test_helper.sh
4load test_helper
5
6# setup_file runs once before all tests
7setup_file() {
8 export SHARED_RESOURCE=$(mktemp -d)
9 echo "Expensive setup" > "$SHARED_RESOURCE/data.txt"
10}
11
12# teardown_file runs once after all tests
13teardown_file() {
14 rm -rf "$SHARED_RESOURCE"
15}
16
17@test "First test uses shared resource" {
18 [ -f "$SHARED_RESOURCE/data.txt" ]
19}
20
21@test "Second test uses shared resource" {
22 [ -d "$SHARED_RESOURCE" ]
23}
Mocking and Stubbing Patterns
Function Mocking
bash
1#!/usr/bin/env bats
2
3# Mock external command
4my_external_tool() {
5 echo "mocked output"
6 return 0
7}
8
9@test "Function uses mocked tool" {
10 export -f my_external_tool
11 run my_function
12 [[ "$output" == *"mocked output"* ]]
13}
Command Stubbing
bash
1#!/usr/bin/env bats
2
3setup() {
4 # Create stub directory
5 STUBS_DIR="$TMPDIR/stubs"
6 mkdir -p "$STUBS_DIR"
7
8 # Add to PATH
9 export PATH="$STUBS_DIR:$PATH"
10}
11
12create_stub() {
13 local cmd="$1"
14 local output="$2"
15 local code="${3:-0}"
16
17 cat > "$STUBS_DIR/$cmd" <<EOF
18#!/bin/bash
19echo "$output"
20exit $code
21EOF
22 chmod +x "$STUBS_DIR/$cmd"
23}
24
25@test "Function works with stubbed curl" {
26 create_stub curl "{ \"status\": \"ok\" }" 0
27 run my_api_function
28 [ "$status" -eq 0 ]
29}
Variable Stubbing
bash
1#!/usr/bin/env bats
2
3@test "Function handles environment override" {
4 export MY_SETTING="override_value"
5 run my_function
6 [ "$status" -eq 0 ]
7 [[ "$output" == *"override_value"* ]]
8}
9
10@test "Function uses default when var unset" {
11 unset MY_SETTING
12 run my_function
13 [ "$status" -eq 0 ]
14 [[ "$output" == *"default"* ]]
15}
Fixture Management
Using Fixture Files
bash
1#!/usr/bin/env bats
2
3# Fixture directory: tests/fixtures/
4
5setup() {
6 FIXTURES_DIR="${BATS_TEST_DIRNAME}/fixtures"
7 WORK_DIR=$(mktemp -d)
8 export WORK_DIR
9}
10
11teardown() {
12 rm -rf "$WORK_DIR"
13}
14
15@test "Process fixture file" {
16 # Copy fixture to work directory
17 cp "$FIXTURES_DIR/input.txt" "$WORK_DIR/input.txt"
18
19 # Run function
20 run my_process_function "$WORK_DIR/input.txt"
21
22 # Compare output
23 diff "$WORK_DIR/output.txt" "$FIXTURES_DIR/expected_output.txt"
24}
Dynamic Fixture Generation
bash
1#!/usr/bin/env bats
2
3generate_fixture() {
4 local lines="$1"
5 local file="$2"
6
7 for i in $(seq 1 "$lines"); do
8 echo "Line $i content" >> "$file"
9 done
10}
11
12@test "Handle large input file" {
13 generate_fixture 1000 "$TMPDIR/large.txt"
14 run my_function "$TMPDIR/large.txt"
15 [ "$status" -eq 0 ]
16 [ "$(wc -l < "$TMPDIR/large.txt")" -eq 1000 ]
17}
Advanced Patterns
Testing Error Conditions
bash
1#!/usr/bin/env bats
2
3@test "Function fails with missing file" {
4 run my_function "/nonexistent/file.txt"
5 [ "$status" -ne 0 ]
6 [[ "$output" == *"not found"* ]]
7}
8
9@test "Function fails with invalid input" {
10 run my_function ""
11 [ "$status" -ne 0 ]
12}
13
14@test "Function fails with permission denied" {
15 touch "$TMPDIR/readonly.txt"
16 chmod 000 "$TMPDIR/readonly.txt"
17 run my_function "$TMPDIR/readonly.txt"
18 [ "$status" -ne 0 ]
19 chmod 644 "$TMPDIR/readonly.txt" # Cleanup
20}
21
22@test "Function provides helpful error message" {
23 run my_function --invalid-option
24 [ "$status" -ne 0 ]
25 [[ "$output" == *"Usage:"* ]]
26}
Testing with Dependencies
bash
1#!/usr/bin/env bats
2
3setup() {
4 # Check for required tools
5 if ! command -v jq &>/dev/null; then
6 skip "jq is not installed"
7 fi
8
9 export SCRIPT="${BATS_TEST_DIRNAME}/../bin/script.sh"
10}
11
12@test "JSON parsing works" {
13 skip_if ! command -v jq &>/dev/null
14 run my_json_parser '{"key": "value"}'
15 [ "$status" -eq 0 ]
16}
Testing Shell Compatibility
bash
1#!/usr/bin/env bats
2
3@test "Script works in bash" {
4 bash "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1
5}
6
7@test "Script works in sh (POSIX)" {
8 sh "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1
9}
10
11@test "Script works in dash" {
12 if command -v dash &>/dev/null; then
13 dash "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1
14 else
15 skip "dash not installed"
16 fi
17}
Parallel Execution
bash
1#!/usr/bin/env bats
2
3@test "Multiple independent operations" {
4 run bash -c 'for i in {1..10}; do
5 my_operation "$i" &
6 done
7 wait'
8 [ "$status" -eq 0 ]
9}
10
11@test "Concurrent file operations" {
12 for i in {1..5}; do
13 my_function "$TMPDIR/file$i" &
14 done
15 wait
16 [ -f "$TMPDIR/file1" ]
17 [ -f "$TMPDIR/file5" ]
18}
Test Helper Pattern
test_helper.sh
bash
1#!/usr/bin/env bash
2
3# Source script under test
4export SCRIPT_DIR="${BATS_TEST_DIRNAME%/*}/bin"
5
6# Common test utilities
7assert_file_exists() {
8 if [ ! -f "$1" ]; then
9 echo "Expected file to exist: $1"
10 return 1
11 fi
12}
13
14assert_file_equals() {
15 local file="$1"
16 local expected="$2"
17
18 if [ ! -f "$file" ]; then
19 echo "File does not exist: $file"
20 return 1
21 fi
22
23 local actual=$(cat "$file")
24 if [ "$actual" != "$expected" ]; then
25 echo "File contents do not match"
26 echo "Expected: $expected"
27 echo "Actual: $actual"
28 return 1
29 fi
30}
31
32# Create temporary test directory
33setup_test_dir() {
34 export TEST_DIR=$(mktemp -d)
35}
36
37cleanup_test_dir() {
38 rm -rf "$TEST_DIR"
39}
Integration with CI/CD
GitHub Actions Workflow
yaml
1name: Tests
2
3on: [push, pull_request]
4
5jobs:
6 test:
7 runs-on: ubuntu-latest
8
9 steps:
10 - uses: actions/checkout@v3
11
12 - name: Install Bats
13 run: |
14 npm install --global bats
15
16 - name: Run Tests
17 run: |
18 bats tests/*.bats
19
20 - name: Run Tests with Tap Reporter
21 run: |
22 bats tests/*.bats --tap | tee test_output.tap
Makefile Integration
makefile
1.PHONY: test test-verbose test-tap
2
3test:
4 bats tests/*.bats
5
6test-verbose:
7 bats tests/*.bats --verbose
8
9test-tap:
10 bats tests/*.bats --tap
11
12test-parallel:
13 bats tests/*.bats --parallel 4
14
15coverage: test
16 # Optional: Generate coverage reports
Best Practices
- Test one thing per test - Single responsibility principle
- Use descriptive test names - Clearly states what is being tested
- Clean up after tests - Always remove temporary files in teardown
- Test both success and failure paths - Don't just test happy path
- Mock external dependencies - Isolate unit under test
- Use fixtures for complex data - Makes tests more readable
- Run tests in CI/CD - Catch regressions early
- Test across shell dialects - Ensure portability
- Keep tests fast - Run in parallel when possible
- Document complex test setup - Explain unusual patterns
Resources