Stage 2 · Tools
Scripting Patterns
Testing Shell Scripts
bats-core: test cases, assertions, setup/teardown, and mocking for shell scripts.
Why Test Shell Scripts?
Shell scripts are often mission-critical — deployments, backups, monitoring. Untested scripts fail silently in production. Testing catches bugs before they cause outages.
bats-core (Bash Automated Testing System) is the standard framework for testing shell scripts. It provides assertions, test isolation, and reporting.
bats-core
# Install bats-core
brew install bats-core # macOS
sudo apt install bats # Ubuntu
# Or install from source
git clone https://github.com/bats-core/bats-core.git
cd bats-core
sudo ./install.sh /usr/localbats-core runs each test in a subshell for isolation. It captures stdout, checks exit codes, and reports pass/fail with line numbers.
# tests/test_script.bats
#!/usr/bin/env bats
@test "script returns 0 on success" {
run ./myscript.sh --help
[ "$status" -eq 0 ]
}
@test "script outputs help text" {
run ./myscript.sh --help
[[ "$output" == *"Usage:"* ]]
}
@test "script fails with no arguments" {
run ./myscript.sh
[ "$status" -eq 1 ]
[[ "$output" == *"Missing"* ]]
}
@test "script processes file correctly" {
run ./myscript.sh test_input.txt
[ "$status" -eq 0 ]
[[ "$output" == *"Processed"* ]]
}Each @test defines a test case. run executes the command and captures status and output. [ ] is an assertion. bats reports pass/fail for each test.
Assertions
# Exit code assertions
[ "$status" -eq 0 ] # Success
[ "$status" -eq 1 ] # Specific error
[ "$status" -ne 0 ] # Any failure
# String assertions
[ "$output" = "expected" ] # Exact match
[[ "$output" == *"pattern"* ]] # Contains pattern
[[ "$output" =~ ^regex$ ]] # Regex match
[ -z "$output" ] # Empty output
[ -n "$output" ] # Non-empty output
# File assertions
[ -f "$output_file" ] # File exists
[ -d "/tmp/testdir" ] # Directory exists
[ -s "$output_file" ] # File is not empty
# Using bats-assert library (recommended)
@test "better assertions" {
run ./script.sh
[ "$status" -eq 0 ]
assert_output --partial "success"
assert_success
}Basic assertions use [ ] or [[ ]]. For richer assertions, install bats-assert: load 'test_helper/bats-assert/load'. It provides assert_output, assert_success, assert_failure, and more.
Setup and Teardown
#!/usr/bin/env bats
setup() {
# Create temp directory for each test
TEST_DIR=$(mktemp -d)
cp ./myscript.sh "$TEST_DIR/"
cd "$TEST_DIR"
}
teardown() {
# Clean up after each test
rm -rf "$TEST_DIR"
}
@test "creates output file" {
echo "input data" > input.txt
run ./myscript.sh input.txt
[ -f "output.txt" ]
[[ "$(cat output.txt)" == *"processed"* ]]
}
@test "handles missing input" {
run ./myscript.sh nonexistent.txt
[ "$status" -eq 1 ]
}setup() runs before each test. teardown() runs after each test, even on failure. This ensures test isolation — tests do not interfere with each other.
Mocking External Commands
#!/usr/bin/env bats
setup() {
# Create mock directory
export MOCK_DIR=$(mktemp -d)
export PATH="$MOCK_DIR:$PATH"
}
teardown() {
rm -rf "$MOCK_DIR"
}
# Mock curl
mock_curl() {
cat > "$MOCK_DIR/curl" << 'MOCK'
#!/bin/bash
echo '{"status":"ok","version":"1.0"}'
MOCK
chmod +x "$MOCK_DIR/curl"
}
@test "health check works with mock" {
mock_curl
run ./health_check.sh
[ "$status" -eq 0 ]
[[ "$output" == *"ok"* ]]
}
# Mock with different responses
mock_curl_failure() {
cat > "$MOCK_DIR/curl" << 'MOCK'
#!/bin/bash
echo '{"status":"error"}'
exit 1
MOCK
chmod +x "$MOCK_DIR/curl"
}
@test "health check handles failure" {
mock_curl_failure
run ./health_check.sh
[ "$status" -eq 1 ]
}Mocking replaces real commands with controlled substitutes. Create a script in a temporary directory, prepend it to PATH, and the function under test uses the mock instead of the real command.
Organizing Tests
# Project structure
project/
lib/
utils.sh
deploy.sh
tests/
test_helper/
bats-support/
bats-assert/
utils.bats
deploy.bats
integration.bats
# tests/utils.bats
#!/usr/bin/env bats
load '../lib/utils'
@test "parse_url extracts host" {
run parse_url "https://example.com/path"
[ "$status" -eq 0 ]
[ "$output" = "example.com" ]
}
@test "validate_email accepts valid email" {
run validate_email "user@example.com"
[ "$status" -eq 0 ]
}
@test "validate_email rejects invalid email" {
run validate_email "not-an-email"
[ "$status" -eq 1 ]
}Organize tests to mirror your source structure. Use load to import functions from your scripts. Keep unit tests separate from integration tests. Use bats-support and bats-assert for rich assertions.
Add bats tests/ to your CI pipeline. bats produces TAP output which most CI systems understand. This catches regressions before they reach production.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.