Chapter 17Lesson 04~105 minutes

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.

IntermediateTesting & debuggingHands-on lab

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.

Bats execution cycle
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
fi

Verification 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.
Next lesson

Test Fixtures, Golden Files, and Failure Injection

Continue Chapter 17 by turning Bash quality and debugging practices into a repeatable engineering workflow.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.