Bash Automated Testing System (BATS) for TDD-style testing of shell scripts...
BATS (Bash Automated Testing System) is a TAP-compliant testing framework for Bash 3.2+. Think of it as JUnit for Bashβstructured, repeatable testing for shell scripts.
.bats extensionsetup()@test blocksrun + assert_outputrun + assert_success/assert_failurebats-file assertionsbash -c wrapper or bats_piperun ! (BATS 1.5+)run for assignmentsproject/
βββ src/
β βββ my_script.sh
βββ test/
β βββ bats/ # bats-core submodule
β βββ test_helper/
β β βββ bats-support/ # Output formatting
β β βββ bats-assert/ # Assertions
β β βββ bats-file/ # Filesystem assertions
β β βββ common-setup.bash # Shared setup logic
β βββ unit/
β β βββ parser.bats
β βββ integration/
β βββ api.bats
βββ .gitmodules
git submodule add https://github.com/bats-core/bats-core.git test/bats
git submodule add https://github.com/bats-core/bats-support.git test/test_helper/bats-support
git submodule add https://github.com/bats-core/bats-assert.git test/test_helper/bats-assert
git submodule add https://github.com/bats-core/bats-file.git test/test_helper/bats-file
Create test/test_helper/common-setup.bash:
_common_setup() {
load "$BATS_TEST_DIRNAME/test_helper/bats-support/load"
load "$BATS_TEST_DIRNAME/test_helper/bats-assert/load"
load "$BATS_TEST_DIRNAME/test_helper/bats-file/load"
PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
export PATH="$PROJECT_ROOT/src:$PATH"
}
#!/usr/bin/env bats
setup_file() {
# Runs ONCE before all tests in file (expensive setup)
export SHARED_RESOURCE="initialized"
}
setup() {
# Runs before EACH test
load 'test_helper/common-setup'
_common_setup
TEST_DIR="$BATS_TEST_TMPDIR"
}
teardown() {
# Runs after EACH test (cleanup)
rm -rf "$TEST_DIR" 2>/dev/null || true
}
teardown_file() {
# Runs ONCE after all tests (final cleanup)
unset SHARED_RESOURCE
}
@test "describe expected behavior" {
run my_command arg1 arg2
assert_success
assert_output --partial "expected substring"
}
run Helperrun captures exit status and output in a subshell:
run command arg1 arg2
# Available after run:
$status # Exit code
$output # Combined stdout+stderr
${lines[@]} # Array of output lines
${lines[0]} # First line
# Implicit status checks (BATS 1.5+)
run -1 failing_command # Expect exit code 1
run ! command # Expect non-zero exit
run --separate-stderr cmd # Separate $output and $stderr
Critical: run always returns 0 to BATS. Always check $status explicitly or use assertions.
# Exit status
assert_success # $status == 0
assert_failure # $status != 0
assert_failure 1 # $status == 1
# Output
assert_output "exact match"
assert_output --partial "substring"
assert_output --regexp "^[0-9]+$"
# Lines
assert_line "any line matches"
assert_line --index 0 "first line"
assert_line --partial "substring"
# Negations
refute_output "not this"
refute_line "not in output"
assert_file_exists "/path/to/file"
assert_dir_exists "/path/to/dir"
assert_file_executable "/path/to/script"
assert_file_not_empty "/path/to/file"
assert_file_contains "/path/to/file" "search text"
| Variable | Scope | Use Case |
|---|---|---|
$BATS_TEST_TMPDIR |
Per test | Always use for isolation |
$BATS_FILE_TMPDIR |
Per file | Shared fixtures in setup_file |
$BATS_RUN_TMPDIR |
Per run | Rarely needed |
@test "file operations" {
echo "data" > "$BATS_TEST_TMPDIR/file.txt"
run process_file "$BATS_TEST_TMPDIR/file.txt"
assert_success
# Automatically cleaned up
}
Mock via PATH manipulation:
@test "mock curl" {
mkdir -p "$BATS_TEST_TMPDIR/bin"
cat > "$BATS_TEST_TMPDIR/bin/curl" <<'EOF'
#!/bin/bash
echo '{"status":"ok"}'
EOF
chmod +x "$BATS_TEST_TMPDIR/bin/curl"
export PATH="$BATS_TEST_TMPDIR/bin:$PATH"
run script_using_curl
assert_output --partial "status"
}
# Basic execution
bats test/ # All tests
bats -r test/ # Recursive
bats --jobs 4 test/ # Parallel
# Filtering
bats --filter "login" test/ # By name regex
bats --filter-tags api,!slow test/ # By tags
bats --filter-status failed test/ # Re-run failures
# Output formats
bats --formatter junit --output ./reports test/ # JUnit for CI
bats --timing test/ # Show durations
# bats test_tags=api,smoke
@test "user login" { }
# Run tagged tests
bats --filter-tags api test/ # Has 'api'
bats --filter-tags api,!slow test/ # Has 'api' but not 'slow'
@test "not ready" {
skip "Feature not implemented"
}
@test "requires docker" {
command -v docker || skip "Docker not installed"
run docker ps
}
- name: Run tests
run: ./test/bats/bin/bats --formatter junit --output ./reports test/
- name: Publish results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: reports/report.xml
test:
script:
- bats --formatter junit --output reports/ test/
artifacts:
reports:
junit: reports/report.xml
| Problem | Solution |
|---|---|
| Test passes but should fail | Use assert_failure or check $status |
Pipes don't work with run |
Use run bash -c "cmd1 | cmd2" |
! true doesn't fail test |
Use run ! true (BATS 1.5+) |
Variables lost after run |
Don't use run for assignments |
| Test hangs indefinitely | Close FD 3 for background tasks: cmd 3>&- & |
| Output has ANSI colors | Use strip_colors helper or NO_COLOR=1 |
run for capturing output, direct execution for state changes$status or use assertions$BATS_TEST_TMPDIR over hardcoded paths