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.
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.
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 "$@"
fiThe 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
fiCapture 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
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.