Chapter 15Lesson 05~85 minutes

Testing Shell Scripts and Packaging Reusable Libraries

Turn one-off Bash into maintainable software with test seams, deterministic fixtures, Bats test suites, namespaced libraries, executable entry points, static analysis, and release metadata.

Bats testingShell librariesRelease discipline

Learning objectives

By the end of this lesson

  • Separate pure decision logic from commands that touch the operating system.
  • Test exit status, standard output, standard error, and filesystem effects.
  • Write a small Bats test suite with isolated setup and teardown.
  • Package reusable functions without executing a CLI during source.
  • Build a release gate using syntax checks, ShellCheck, tests, and version metadata.

1. Testability begins before the test framework

Shell scripts become difficult to test when parsing, network calls, filesystem mutation, logging, and formatting are intertwined at top level. Create seams: functions receive explicit arguments, pure functions return status or data, command paths can be injected, and the CLI entry point runs only when the file is executed directly.

Testable Bash package architecture
flowchart TD
  CLI["bin/acme-report"] --> PARSE["Parse and validate CLI"]
  PARSE --> LIB["Namespaced library functions"]
  LIB --> PURE["Pure validation and formatting"]
  LIB --> ADAPTERS["Filesystem and external-command adapters"]
  TEST["Bats test suite"] --> PURE
  TEST --> ADAPTERS
  FIX["Temporary fixtures and fake commands"] --> TEST
  GATE["bash -n + ShellCheck + Bats"] --> CLI
  GATE --> LIB
# lib/acme.sh
acme::valid_environment() {
  [[ ${1-} =~ ^(dev|staging|prod)$ ]]
}

acme::render_target() {
  local service=$1 environment=$2
  acme::valid_environment "$environment" || return 2
  printf '%s@%s\n' "$service" "$environment"
}

# bin/acme-target
main() {
  (( $# == 2 )) || { printf 'Usage: %s SERVICE ENV\n' "${0##*/}" >&2; return 2; }
  acme::render_target "$1" "$2"
}

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  main "$@"
fi

The guard compares the sourced file path with the executing script path. It allows tests and other scripts to load functions without triggering the CLI. Namespaces such as acme:: reduce collisions because Bash has one global function namespace per shell.

2. Test observable contracts, not implementation trivia

A useful shell test can assert status, stdout, stderr, created files, permissions, and repeated-run behavior. Avoid tests that merely duplicate the implementation’s exact sequence of internal commands. They become brittle without protecting behavior.

# Minimal framework-free smoke test.
output_file=$(mktemp)
error_file=$(mktemp)
trap 'rm -f -- "$output_file" "$error_file"' EXIT

if ./bin/acme-target payments prod >"$output_file" 2>"$error_file"; then
  status=0
else
  status=$?
fi

(( status == 0 )) || { printf 'unexpected status=%d\n' "$status" >&2; exit 1; }
[[ $(<"$output_file") == 'payments@prod' ]] || exit 1
[[ ! -s $error_file ]] || exit 1

if ./bin/acme-target payments invalid >/dev/null 2>&1; then
  printf 'invalid environment unexpectedly succeeded\n' >&2
  exit 1
fi

Capture a command inside an if when a nonzero status is part of the test. Otherwise a global errexit policy may abort the test harness before the assertion can examine the result.

3. Bats provides TAP-compatible shell tests

Bats executes each @test in an isolated context and provides run, $status, $output, and line arrays. setup and teardown create and remove per-test fixtures. Bats is especially useful for command-line behavior and shell libraries.

#!/usr/bin/env bats

setup() {
  TEST_ROOT=$(mktemp -d)
  export TEST_ROOT
}

teardown() {
  rm -rf -- "$TEST_ROOT"
}

@test "renders a valid target" {
  run ./bin/acme-target payments prod
  [ "$status" -eq 0 ]
  [ "$output" = "payments@prod" ]
}

@test "rejects an invalid environment" {
  run ./bin/acme-target payments qa
  [ "$status" -eq 2 ]
  [[ "$output" == *"invalid environment"* ]]
}

@test "rejects missing arguments as usage error" {
  run ./bin/acme-target payments
  [ "$status" -eq 2 ]
  [[ "$output" == Usage:* ]]
}

Depending on Bats version and options, run may combine captured streams for ordinary assertions. When stdout and stderr separation is central to the interface, use supported separate-stream facilities or invoke a small wrapper that redirects them to fixture files.

4. Replace external dependencies at process boundaries

Shell scripts resolve external commands through PATH. Tests can prepend a directory containing deterministic fake executables. This tests the real command invocation and argument vector without contacting a network or changing the host.

setup_fake_curl() {
  mkdir -p "$TEST_ROOT/bin"
  cat > "$TEST_ROOT/bin/curl" <<'FAKE'
#!/usr/bin/env bash
printf '%s\n' "$@" > "${CURL_ARGS_FILE:?}"
printf '{"status":"ok"}\n'
FAKE
  chmod u+x "$TEST_ROOT/bin/curl"
  export PATH="$TEST_ROOT/bin:$PATH"
  export CURL_ARGS_FILE="$TEST_ROOT/curl.args"
}

# Production code should invoke `curl`, not hard-code an unreplaceable path.
fetch_health() {
  curl --fail --silent --show-error "$1"
}

Fakes should record arguments and emit controlled outputs and statuses. Do not fake shell builtins or core syntax. Integration tests against the real dependency still belong in a separate, explicitly provisioned test layer.

5. Package libraries, executables, tests, and documentation separately

A small Bash project benefits from a predictable layout. Executables belong in bin/, sourced functions in lib/, tests in test/, and fixtures beneath the test tree. The entry point resolves its own directory rather than assuming the current working directory.

project/
├── bin/
│   └── acme-target
├── lib/
│   └── acme.sh
├── test/
│   └── acme-target.bats
├── VERSION
├── README.md
└── LICENSE

# Reliable library resolution from bin/acme-target:
script_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
project_root=$(CDPATH= cd -- "$script_dir/.." && pwd -P)
# shellcheck source=../lib/acme.sh
source "$project_root/lib/acme.sh"

Do not source files from the caller’s current directory. Resolve from BASH_SOURCE, pin released versions, and document the supported Bash version and external dependencies. Libraries should avoid changing global options, traps, working directory, positional parameters, or environment without explicit documentation.

6. A release gate combines complementary checks

No single tool proves correctness. Syntax parsing catches grammar defects. ShellCheck identifies suspicious constructs. Unit tests verify contracts. Integration tests exercise real dependencies. A release record connects those results to an immutable version.

#!/usr/bin/env bash
set -Eeuo pipefail

mapfile -d '' scripts < <(find bin lib test -type f \
  \( -name '*.sh' -o -name '*.bats' -o -perm -u+x \) -print0)

for script in "${scripts[@]}"; do
  bash -n "$script"
done

shellcheck bin/* lib/*.sh test/*.bats
bats test

version=$(<VERSION)
[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
  printf 'Invalid VERSION: %s\n' "$version" >&2
  exit 1
}

printf 'release_gate=pass version=%s files=%d\n' "$version" "${#scripts[@]}"

A test suite should be deterministic, isolated, and able to run from a clean checkout. Tests that depend on the developer’s home directory, active cloud credentials, locale, or installed aliases are hidden environment contracts.

7. Hands-on lab: package and test a reusable Bash library

The lab builds a namespaced library, executable wrapper, Bats tests, and a release-check script. It uses only a temporary project directory beneath your course lab.

lab="$HOME/devops-academy/linux/chapter15/lesson05"
rm -rf "$lab"
mkdir -p "$lab/project"/{bin,lib,test}
cd "$lab/project"

cat > lib/acme.sh <<'LIB'
#!/usr/bin/env bash

acme::valid_environment() {
  [[ ${1-} =~ ^(dev|staging|prod)$ ]]
}

acme::render_target() {
  (( $# == 2 )) || return 2
  local service=$1 environment=$2
  [[ $service =~ ^[a-z][a-z0-9-]{2,31}$ ]] || return 2
  acme::valid_environment "$environment" || return 2
  printf '%s@%s\n' "$service" "$environment"
}
LIB

cat > bin/acme-target <<'BIN'
#!/usr/bin/env bash
set -u
script_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
project_root=$(CDPATH= cd -- "$script_dir/.." && pwd -P)
# shellcheck source=../lib/acme.sh
source "$project_root/lib/acme.sh"

main() {
  if (( $# != 2 )); then
    printf 'Usage: %s SERVICE ENV\n' "${0##*/}" >&2
    return 2
  fi
  if ! acme::render_target "$1" "$2"; then
    printf 'invalid service or environment\n' >&2
    return 2
  fi
}

main "$@"
BIN
chmod u+x bin/acme-target

cat > test/acme-target.bats <<'BATS'
#!/usr/bin/env bats

@test "valid target" {
  run ./bin/acme-target payments-api prod
  [ "$status" -eq 0 ]
  [ "$output" = "payments-api@prod" ]
}

@test "invalid environment" {
  run ./bin/acme-target payments-api qa
  [ "$status" -eq 2 ]
  [[ "$output" == *"invalid"* ]]
}

@test "missing operand" {
  run ./bin/acme-target payments-api
  [ "$status" -eq 2 ]
  [[ "$output" == Usage:* ]]
}
BATS

printf '1.0.0\n' > VERSION
bash -n bin/acme-target lib/acme.sh test/acme-target.bats

if command -v shellcheck >/dev/null; then
  shellcheck bin/acme-target lib/acme.sh test/acme-target.bats
fi
if command -v bats >/dev/null; then
  bats test
else
  [[ $(./bin/acme-target payments-api prod) == 'payments-api@prod' ]]
  ! ./bin/acme-target payments-api qa >/dev/null 2>&1
  printf 'Bats not installed; smoke tests passed\n'
fi
printf 'PASS version=%s\n' "$(<VERSION)" 

Verification checklist

8. Common testing and packaging mistakes

“Testing means running the script once.”

A useful suite includes invalid input, dependency failure, repeated execution, unusual paths, and cleanup behavior.

“Mock every function.”

Over-mocking tests implementation details. Prefer public behavior and fakes at external command boundaries.

“A sourced library may set strict mode globally.”

That changes the caller’s shell semantics. Libraries should minimize global side effects or document them explicitly.

“Passing ShellCheck means releasable.”

Static analysis cannot verify business logic, runtime dependencies, permissions, concurrency, or remote behavior.

9. Knowledge check

Question 1. Why use [[ ${BASH_SOURCE[0]} == "$0" ]] around main?

Question 2. What should a shell test normally assert?

Question 3. Why prepend fake executables to PATH?

10. Summary

Maintainable shell automation is designed for testing and reuse. Separate parsing, pure logic, adapters, and presentation; assert observable contracts; isolate fixtures; fake external commands through process boundaries; namespace library functions; resolve paths from the executing file; and gate releases with syntax parsing, static analysis, executable tests, and version metadata.

11. Further reading

Next chapter

CPU, Memory, Load Average, and Pressure

Chapter 16 turns Linux telemetry into a structured performance workflow across CPU scheduling, memory pressure, storage latency, network behavior, diagnostic tools, baselines, and incident evidence.

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.