Course outline

Bash / Intermediate: reliable data and failure paths / Lesson 12

Make scripts reviewable and testable

Build a small command-line boundary, separate pure transformations from effects, and verify success and failure with an isolated harness.

About 4 min readContent reviewed
On this page

Lesson goals

  • Define input, output, error, and dependency contracts.
  • Distinguish syntax checks from behavioral tests.
  • Test exact outputs and statuses without executing real operations.

A maintainable script has a narrow public interface

State accepted arguments, standard input format, output format, status meanings, and required interpreter/tools. Keep human diagnostics off machine-readable output. Validate near entry and pass normalized values to small helpers. A function that transforms synthetic text can be tested without creating external state, while effects belong behind an explicit boundary. Avoid reading an undocumented personal configuration or current-directory file. The caller should know what controls behavior. A reliable test harness starts from a declared environment so inherited aliases, shell options, locale, and startup hooks do not silently change the test.

Syntax validity is necessary but insufficient

bash -n parses without executing normal script commands and catches many grammatical errors. It does not prove that a variable is defined, a pipeline is checked, or a branch preserves data. Static analysis can add useful warnings when a trusted supported tool is available, but its findings still need interpretation and version context. Do not install arbitrary tools merely to run a course example. Behavioral tests should exercise the script’s contract using harmless synthetic input and controlled dependencies, including malformed input and operations that deliberately return a documented failure.

Assert outputs and status independently

A test should save the operation’s status before running its assertion messages, then compare the intended output. Expected failure is a passing test when the failure contract is satisfied. Command substitution removes trailing newlines, so an exact byte comparison may need files rather than a captured scalar. Keep standard error separate when its content matters. A harness must itself return nonzero on a failed assertion; printing FAIL while exiting zero misleads automation. Use bounded examples and never replace a real administrative command with a test that accidentally invokes the original path.

bash Test a small transformation and rejection contract
normalize() {
  case $1 in
    draft) printf 'DRAFT\n' ;;
    ready) printf 'READY\n' ;;
    *) return 2 ;;
  esac
}
actual=$(normalize draft)
status=$?
if [[ $status == 0 && $actual == DRAFT ]]; then
  printf 'PASS valid mode\n'
else
  exit 1
fi
if normalize unknown; then
  exit 1
else
  status=$?
  [[ $status == 2 ]] || exit 1
  printf 'PASS rejected mode\n'
fi
Expected output
PASS valid mode
PASS rejected mode

The test passes only when the valid output and the invalid status are both correct. The helper performs no filesystem or service operation.

Private fixtures keep tests reproducible

Use a newly created temporary directory for fixture files and never overwrite a fixed host path. Keep fixture content synthetic and small. Record which files belong to the test and clean them through the harness’s trusted lifecycle, not an unconstrained path assembled from unvalidated input. Tests can stub a dependency through an explicit function or configured command boundary, but the stub must preserve the relevant status and output contract. A test double that always succeeds cannot validate rollback or failure handling. Compare the original behavior and the intended new behavior at the interface.

Choose cases that protect real boundaries

Useful regression cases include empty input, spaces, literal wildcard characters, a failed producer with partial output, an unterminated record, and a function used as a condition. Test the interpreter versions actually supported instead of assuming a recent manual means deployment compatibility. Do not freeze incidental formatting unless it is part of the public output contract. Keep tests focused on decisions, preserved values, and meaningful statuses. The second example demonstrates a deterministic argument-boundary assertion; it protects a real bug class rather than mirroring the implementation’s internal variable names.

bash Protect an exact argument-list contract
accept_two() {
  [[ $# == 2 && $1 == 'red apple' && -z $2 ]]
}
args=('red apple' '')
if accept_two "${args[@]}"; then
  printf 'PASS argument boundaries\n'
else
  printf 'FAIL argument boundaries\n'
  exit 1
fi
Expected output
PASS argument boundaries

The receiving function asserts two arguments, including the empty one. Joining or leaving the array expansion unquoted would break the tested interface.

Common errors and fixes

bash -n is called a complete test

Why it happens
Parsing was confused with runtime behavior.
How to fix it
Add contract-based success and failure cases.
Prevent it next time
Report syntax and behavior checks separately.

FAIL output still returns zero

Why it happens
The harness never propagated assertion failure.
How to fix it
Return a nonzero status on every failed assertion.
Prevent it next time
Test the harness with one deliberate failing assertion.

A stub bypasses the failure contract

Why it happens
The dependency double always succeeds.
How to fix it
Provide controlled failure and partial-output cases.
Prevent it next time
Model only the relevant dependency interface accurately.

Check your understanding

1. Why can captured text be insufficient for an exact trailing-newline test?

Show worked solution

Ordinary command substitution removes trailing newlines. Use controlled fixture files or another byte-preserving interface when those bytes are part of the contract, and compare the operation’s status separately.

2. A regression concerns unquoted array forwarding. What observable assertion is useful?

Show worked solution

Have the receiving helper verify argument count and exact values for a spaced item and an empty item. This fails when boundaries are lost, regardless of how the caller’s variables are named or arranged internally.

Key takeaways

  • Test behavior and statuses with isolated synthetic fixtures.
  • Protect real contracts and supported interpreter versions.

Official references