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.
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.
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
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.
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
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.