Unit and Integration Tests with Bats
Shell behavior becomes safer when exit status, output, and filesystem effects are executable contracts. Bats provides a test harness that stays close to Bash syntax while making those contracts repeatable.
Learning objectives
By the end of this lesson
- Write Bats test cases.
- Assert status and output.
- Isolate test state.
- Separate unit from integration coverage.
- Propagate required failures to CI.
1. Bats turns shell behavior into test cases
Bats organizes tests around commands, exit status, output, and setup/teardown. It is particularly effective for command-line contracts and Bash helper libraries.
flowchart LR T["Bats test"] --> R["run command"] R --> C["capture status/output"] C --> A["assert"] A --> P["pass/fail"]
2. A minimal test checks observable behavior
@test "missing argument prints usage" {
run ./tool.sh
[ "$status" -eq 64 ]
[[ "$output" == *"usage:"* ]]
}3. setup and teardown isolate state
setup() {
TEST_TMPDIR=$(mktemp -d)
}
teardown() {
rm -rf -- "$TEST_TMPDIR"
}Each test should start from a known state and clean up after itself.
4. Create fixtures inside the test workspace
@test "reads config" {
cat > "$TEST_TMPDIR/config.env" <<'EOF'
MODE=staging
EOF
run ./read-config.sh "$TEST_TMPDIR/config.env"
[ "$status" -eq 0 ]
[ "$output" = "staging" ]
}5. Exit status is part of the interface
run ./validate.sh invalid-name
[ "$status" -eq 65 ]
[[ "$output" == *"invalid"* ]]Do not test only text when callers also depend on status codes.
6. Source library functions without side effects
source ./lib/validate.sh
@test "valid service name" {
run validate_service "api-v2"
[ "$status" -eq 0 ]
}Files intended for sourcing should not unexpectedly perform deployment work at import time.
7. Integration tests exercise real boundaries
Integration tests can use a temporary Git repository, directory tree, local service, container, or other real dependency. Keep them separate from fast unit tests so environment requirements are visible.
8. Skip only for genuine capability gaps
@test "docker integration" {
command -v docker >/dev/null 2>&1 || skip "docker unavailable"
run docker version
[ "$status" -eq 0 ]
}9. Avoid shared mutable global state
Independent tests are easier to parallelize and far less likely to become ordering-dependent or flaky.
10. Let Bats failure reach CI
bats test/Do not add || true around a required test suite.
11. Hands-on lab: test a tiny CLI
mkdir -p "$HOME/devops-academy/bash/chapter17/lesson04/test"
cd "$HOME/devops-academy/bash/chapter17/lesson04"
cat > greet.sh <<'EOF'
#!/usr/bin/env bash
set -u
name=${1:-}
[[ -n $name ]] || {
printf 'usage: %s NAME\n' "$0" >&2
exit 64
}
printf 'Hello, %s\n' "$name"
EOF
chmod u+x greet.sh
cat > test/greet.bats <<'EOF'
#!/usr/bin/env bats
@test "greets supplied name" {
run ./greet.sh Bash
[ "$status" -eq 0 ]
[ "$output" = "Hello, Bash" ]
}
@test "missing name fails" {
run ./greet.sh
[ "$status" -eq 64 ]
[[ "$output" == *"usage:"* ]]
}
EOF
if command -v bats >/dev/null 2>&1; then
bats test/greet.bats
else
printf 'Bats not installed; test files created for inspection.\n' >&2
fiVerification checklist
12. Knowledge check
Question 1. What does Bats run capture?
Question 2. Why use setup/teardown?
Question 3. What distinguishes an integration test?
Question 4. Should a required Bats suite be made non-failing in CI?
13. Summary
Bats makes shell contracts executable. Assert status and output, isolate state, separate unit and integration coverage, skip only for real capability gaps, and let required failures propagate to CI.
14. Further reading
- Bats-core documentation.
- Bats helper-library documentation where used.
- GNU Bash Reference Manual — functions and sourcing.
- General testing guidance on isolation and integration boundaries.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.