Bash Testing¶
Testing shell scripts with Bats (Bash Automated Testing System).
Bats Installation¶
# npm
npm install -g bats
# Homebrew
brew install bats-core
# From source
git clone https://github.com/bats-core/bats-core.git
cd bats-core
./install.sh /usr/local
Helper Libraries¶
# Install helpers
git clone https://github.com/bats-core/bats-support test/test_helper/bats-support
git clone https://github.com/bats-core/bats-assert test/test_helper/bats-assert
git clone https://github.com/bats-core/bats-file test/test_helper/bats-file
Project Structure¶
project/
├── scripts/
│ └── my-script.sh
└── test/
├── test_helper/
│ ├── bats-support/
│ ├── bats-assert/
│ └── bats-file/
└── my-script.bats
Basic Tests¶
Simple Test¶
#!/usr/bin/env bats
@test "echo outputs text" {
result="$(echo hello)"
[ "$result" = "hello" ]
}
@test "exit code is 0 on success" {
run echo "test"
[ "$status" -eq 0 ]
}
@test "exit code is non-zero on failure" {
run false
[ "$status" -ne 0 ]
}
Run Command¶
@test "my script runs successfully" {
run ./scripts/my-script.sh
[ "$status" -eq 0 ]
}
@test "my script outputs expected text" {
run ./scripts/my-script.sh
[ "$output" = "Expected output" ]
}
@test "my script handles arguments" {
run ./scripts/my-script.sh --name "test"
[ "$status" -eq 0 ]
[[ "$output" =~ "test" ]]
}
Using Bats-Assert¶
#!/usr/bin/env bats
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
@test "assert success" {
run echo "hello"
assert_success
}
@test "assert failure" {
run false
assert_failure
}
@test "assert output equals" {
run echo "hello world"
assert_output "hello world"
}
@test "assert output contains" {
run echo "hello world"
assert_output --partial "hello"
}
@test "assert output matches regex" {
run echo "hello world"
assert_output --regexp "^hello.*$"
}
@test "assert line" {
run printf "line1\nline2\nline3"
assert_line --index 0 "line1"
assert_line --index 1 "line2"
}
@test "refute output" {
run echo "hello"
refute_output --partial "goodbye"
}
Using Bats-File¶
#!/usr/bin/env bats
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
load 'test_helper/bats-file/load'
@test "file exists" {
touch /tmp/testfile
assert_file_exists /tmp/testfile
rm /tmp/testfile
}
@test "directory exists" {
mkdir -p /tmp/testdir
assert_dir_exists /tmp/testdir
rmdir /tmp/testdir
}
@test "file contains text" {
echo "hello world" > /tmp/testfile
assert_file_contains /tmp/testfile "hello"
rm /tmp/testfile
}
@test "file is executable" {
touch /tmp/testscript
chmod +x /tmp/testscript
assert_file_executable /tmp/testscript
rm /tmp/testscript
}
Setup and Teardown¶
#!/usr/bin/env bats
# Run once before all tests
setup_file() {
export TEST_DIR="$(mktemp -d)"
export PATH="$BATS_TEST_DIRNAME/../scripts:$PATH"
}
# Run once after all tests
teardown_file() {
rm -rf "$TEST_DIR"
}
# Run before each test
setup() {
cd "$TEST_DIR"
}
# Run after each test
teardown() {
rm -f "$TEST_DIR"/*
}
@test "test in temp directory" {
touch testfile
[ -f testfile ]
}
Testing Functions¶
Source and Test¶
#!/usr/bin/env bats
# Source the script to test
setup() {
source ./scripts/functions.sh
}
@test "greet function outputs greeting" {
result="$(greet "World")"
[ "$result" = "Hello, World!" ]
}
@test "validate_email returns 0 for valid email" {
run validate_email "test@example.com"
[ "$status" -eq 0 ]
}
@test "validate_email returns 1 for invalid email" {
run validate_email "invalid"
[ "$status" -eq 1 ]
}
Mocking Commands¶
@test "script calls curl" {
# Create mock curl
function curl() {
echo '{"status": "ok"}'
}
export -f curl
run ./scripts/fetch-data.sh
assert_success
assert_output --partial "ok"
}
@test "script handles curl failure" {
function curl() {
return 1
}
export -f curl
run ./scripts/fetch-data.sh
assert_failure
}
Environment Variables¶
@test "script uses environment variable" {
export MY_VAR="test_value"
run ./scripts/my-script.sh
assert_output --partial "test_value"
}
@test "script has default when var not set" {
unset MY_VAR
run ./scripts/my-script.sh
assert_output --partial "default"
}
Testing Exit Codes¶
@test "script exits 0 on success" {
run ./scripts/my-script.sh valid-input
assert_success
}
@test "script exits 1 on invalid input" {
run ./scripts/my-script.sh invalid-input
assert_failure
[ "$status" -eq 1 ]
}
@test "script exits 2 on missing argument" {
run ./scripts/my-script.sh
[ "$status" -eq 2 ]
}
Testing Standard Error¶
@test "script outputs error to stderr" {
run ./scripts/my-script.sh invalid
assert_failure
# Check stderr (requires bats 1.5+)
assert_output --partial "Error:"
}
# Alternative: capture stderr separately
@test "check stderr" {
run bash -c './scripts/my-script.sh invalid 2>&1'
[[ "$output" =~ "Error:" ]]
}
Skipping Tests¶
@test "skip this test" {
skip "Not implemented yet"
# This code won't run
run false
assert_success
}
@test "skip on condition" {
if [[ "$(uname)" != "Linux" ]]; then
skip "Linux only"
fi
# Linux-specific test
}
@test "skip if command missing" {
if ! command -v docker &> /dev/null; then
skip "Docker not installed"
fi
run docker --version
assert_success
}
Running Tests¶
# Run all tests
bats test/
# Run specific file
bats test/my-script.bats
# Verbose output
bats --verbose-run test/
# TAP output
bats --tap test/
# Pretty output
bats --pretty test/
# Show timing
bats --timing test/
# Run in parallel
bats --jobs 4 test/
CI Integration¶
GitHub Actions¶
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Bats
run: |
git clone https://github.com/bats-core/bats-core.git
cd bats-core && sudo ./install.sh /usr/local
- name: Install helpers
run: |
git clone https://github.com/bats-core/bats-support test/test_helper/bats-support
git clone https://github.com/bats-core/bats-assert test/test_helper/bats-assert
- name: Run tests
run: bats test/
Best Practices¶
Use Temporary Directories¶
Make Tests Independent¶
# Good - each test sets up its own state
@test "test 1" {
echo "data" > "$TEST_TEMP_DIR/file1"
run cat "$TEST_TEMP_DIR/file1"
assert_success
}
@test "test 2" {
echo "other" > "$TEST_TEMP_DIR/file2"
run cat "$TEST_TEMP_DIR/file2"
assert_success
}
Descriptive Test Names¶
# Good
@test "my-script exits with error when config file is missing" {
}
@test "my-script creates output directory if it does not exist" {
}
# Bad
@test "test 1" {
}
@test "test error" {
}