Linux & Bash · Part I · Module 21 · From Script to Reusable Tool
Testing a Shell Script
Chapter 21.7's checker reads the source and cannot see values. This chapter covers the layer that can: running the script and checking what it did. The part that is usually missing is not the happy path — it is proving the script fails when it should, and then proving the suite itself is capable of noticing.
Advanced22 min readLinuxPart IBashTestingScripting
Part I · Module 21 · Chapter 21.8 · Testing a Shell Script
Nobody tests shell scripts. The reasons given are that there is no framework, that the script is only forty lines, and that you will notice if it breaks.
The first is nearly true and does not matter. The second is how forty lines become four hundred. The third is Chapter 20.1's entire subject.
1. What There Is to Test
A script has a small, finite interface — which is what makes it testable at all. By Chapter 21.1 through 21.5 it has exactly five observable behaviours:
| Observable | Checked by |
|---|---|
| what it writes to stdout | comparing text |
| what it writes to stderr | comparing text, loosely |
| its exit status | comparing a number |
| what it changed on disk | inspecting before and after |
| that it changed nothing else | inspecting before and after |
And two of those are where the value is. Testing that a passing log is reported as PASS is worth something; testing that a missing log produces an exit 2 and no stdout is worth more, because that is the path nobody exercises by hand.
2. The Script Under Test
Small enough to show in full, with a real contract — Chapter 21.4's kind:
#!/usr/bin/env bash
# verdict.sh — classify one log. usage: verdict.sh <logfile>
# stdout: one verdict word exit: 0 PASS · 1 not PASS · 2 unusable
set -o nounset
f=${1:-}
[ -n "$f" ] || { printf '%s: usage: %s <logfile>\n' "${0##*/}" "${0##*/}" >&2; exit 2; }
[ -f "$f" ] || { printf '%s: no such file: %s\n' "${0##*/}" "$f" >&2; exit 2; }
[ -r "$f" ] || { printf '%s: not readable: %s\n' "${0##*/}" "$f" >&2; exit 2; }
if ! [ -s "$f" ]; then printf 'EMPTY\n'; exit 1
elif grep -q UVM_FATAL "$f"; then printf 'FATAL\n'; exit 1
elif grep -q UVM_ERROR "$f"; then printf 'ERRORS\n'; exit 1
elif ! grep -q 'TEST PASSED' "$f"; then printf 'NO-VERDICT\n'; exit 1
else printf 'PASS\n'; exit 0
fiSix outputs, three statuses, five distinguishable failure modes. That is a lot of behaviour for fourteen lines, and all of it is reachable from the command line.
3. A Harness in Twenty Lines
There is no framework here — Chapter 21.7 verified that bats and shunit2 are not installed — and for a handful of scripts that is not a loss. A check function is enough:
#!/usr/bin/env bash
# t.sh — tests for verdict.sh
set -o nounset
SUT=./verdict.sh
pass=0; fail=0
# check <name> <expected-stdout> <expected-status> -- <args...>
check() {
local name=$1 want_out=$2 want_rc=$3; shift 4
local got_out got_rc
got_out=$("$SUT" "$@" 2>/dev/null) || true
got_rc=$("$SUT" "$@" >/dev/null 2>&1; echo $?)
if [ "$got_out" = "$want_out" ] && [ "$got_rc" = "$want_rc" ]; then
printf 'ok %s\n' "$name"; pass=$(( pass + 1 ))
else
printf 'FAIL %s\n out: got [%s] want [%s]\n rc: got [%s] want [%s]\n' \
"$name" "$got_out" "$want_out" "$got_rc" "$want_rc"; fail=$(( fail + 1 ))
fi
}
tmp=$(mktemp -d); trap 'rm -rf -- "$tmp"' EXIT
printf 'running\nTEST PASSED\n' > "$tmp/pass.log"
printf 'UVM_ERROR : x\n' > "$tmp/err.log"
printf 'UVM_FATAL : x\nUVM_ERROR : y\n' > "$tmp/fatal.log"
printf 'running\n' > "$tmp/noverdict.log"
: > "$tmp/empty.log"
printf 'TEST PASSED\n' > "$tmp/locked.log"; chmod 000 "$tmp/locked.log"
printf 'TEST PASSED\n' > "$tmp/my log.log"
# --- positive cases
check 'a passing log' PASS 0 -- "$tmp/pass.log"
check 'an error log' ERRORS 1 -- "$tmp/err.log"
check 'fatal beats error' FATAL 1 -- "$tmp/fatal.log"
check 'no verdict' NO-VERDICT 1 -- "$tmp/noverdict.log"
check 'an empty log' EMPTY 1 -- "$tmp/empty.log"
check 'a name with a space' PASS 0 -- "$tmp/my log.log"
# --- NEGATIVE cases: it must fail when it should
check 'no argument' '' 2 --
check 'missing file' '' 2 -- "$tmp/nope.log"
check 'unreadable file' '' 2 -- "$tmp/locked.log"
chmod 644 "$tmp/locked.log"
printf -- '-- %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]$ ./t.sh
ok a passing log
ok an error log
ok fatal beats error
ok no verdict
ok an empty log
ok a name with a space
ok no argument
ok missing file
ok unreadable file
-- 9 passed, 0 failed
$ echo $?
0Six things in that harness are deliberate, and most of them are chapters from this course:
It checks stdout and status, in one check. A test that only looks at the output would pass a script whose exit codes were all 0 — which §4 measures.
mktemp -d plus a trap, Chapters 20.6 and 20.5. The fixtures are built fresh on every run, so the suite is idempotent (21.5) and leaves nothing behind.
The fixtures include the awkward cases: an unreadable file and a name containing a space. Those are Chapters 16.4 and 19.1, and they are the two that catch real defects.
Three negative cases, each asserting an empty stdout as well as a status. A script that printed EMPTY to stdout on a missing file would fail these even though its status was right.
|| true on the capture, because the SUT is expected to fail and set -e would otherwise kill the harness on the first negative case. (The harness deliberately does not use set -e for this reason; set -o nounset is kept.)
And the run ends with [ "$fail" -eq 0 ], which is Chapter 16.7's bare-test ending — the suite's own exit status is the thing CI reads.
4. A Green Suite Proves Nothing Yet
Why a passing suite is not evidence on its own
This is the part that is almost always skipped, and it is the only thing that makes the rest worth having. Break the script on purpose, one fault at a time, and check that the right test fails.
Breakage one — swap the FATAL and ERRORS arms, so a log containing both is reported as ERRORS:
$ ./t.sh | grep -E '^(FAIL|--)'
FAIL fatal beats error
-- 8 passed, 1 failedOne test, and it is the one named for exactly that ordering. Chapter 16.1's point about elif order being a policy decision, now with a test defending it.
Breakage two — break the empty-file check so ! [ -s "$f" ] never fires:
$ ./t.sh | grep -E '^(FAIL|--)'
FAIL an empty log
-- 8 passed, 1 failedBreakage three — change ERRORS's exit 1 to exit 0, leaving the output identical:
$ ./t.sh | grep -E '^(FAIL|--)'
FAIL an error log
-- 8 passed, 1 failedThat third one is the argument for checking the status. The stdout was still ERRORS — a suite comparing only output would have been perfectly green while the script told every caller that a failing run had passed.
And restored:
$ ./t.sh | tail -1
-- 9 passed, 0 failedThree faults, three single failures, each in the test written for it, and a clean run either side. That sequence is what converts "the tests pass" into "the tests work".
5. What to Test, and What Not To
Nine cases for a fourteen-line script is a reasonable ratio, and the selection matters more than the count.
Test every documented exit status. If the help text promises 0, 1 and 2, there is a test per status. Chapter 21.4's contract is only a contract if something checks it.
Test one case per branch, which for verdict.sh means the five elif arms plus the three guards. Any arm with no test is an arm you can reorder without noticing.
Test the awkward inputs you know about. A filename with a space, an unreadable file, an empty file, a file with no trailing newline (Chapter 17.5 §4). Those four catch most of what this course measured.
And test that it does not do what it should not. The negative cases are the half with the value, because the positive path gets exercised every time anyone runs the tool.
What not to test:
Not the implementation. A test that greps verdict.sh for the string UVM_FATAL breaks when you refactor and tells you nothing about behaviour. Test through the interface.
Not the shell. [ -s "$f" ] works; you do not need a test for it.
And not every combination. Six fixtures cover the branches; thirty-six pairwise combinations cover nothing extra and nobody maintains them.
6. Testing the Filesystem Effects
The fifth observable from §1, and the one a check on stdout cannot reach. Chapter 21.5's dry-run test is the pattern:
# before and after, compared
check_no_change() {
local name=$1; shift
local before after
before=$(cd "$tmp" && find . | sort)
"$@" > /dev/null 2>&1 || true
after=$(cd "$tmp" && find . | sort)
if [ "$before" = "$after" ]; then
printf 'ok %s (changed nothing)\n' "$name"; pass=$(( pass + 1 ))
else
printf 'FAIL %s changed the filesystem:\n' "$name"
diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") | sed 's/^/ /'
fail=$(( fail + 1 ))
fi
}
check_no_change 'dry run is dry' ./rotate-results -n "$tmp/res"
check_no_change 'a rejected call' ./rotate-results -k 0 "$tmp/res"Two properties worth testing this way, and both are things Module 20 and 21 argued for:
That a dry run changes nothing — Chapter 21.5 §4's stated test, now automated.
That a rejected invocation changes nothing — Chapter 20.3 §3's ordering rule. A script that validates after creating its output directory fails this test, and the failure is the diff showing the directory it left behind.
And the positive version, for a script that is supposed to change something:
check_file_contains() {
local name=$1 file=$2 want=$3
if [ -f "$file" ] && grep -qF -- "$want" "$file"; then
printf 'ok %s\n' "$name"; pass=$(( pass + 1 ))
else
printf 'FAIL %s: %s does not contain [%s]\n' "$name" "$file" "$want"; fail=$(( fail + 1 ))
fi
}grep -qF -- because the wanted text is literal, not a pattern, and may begin with a dash — Chapter 14.1 and Chapter 1.5.
7. Running the Tests
Three things that make a suite get run rather than written once.
One command, from anywhere:
#!/usr/bin/env bash
# run-tests — run every t_*.sh beside this script
set -o nounset
here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
shopt -s nullglob
suites=("$here"/t_*.sh)
[ "${#suites[@]}" -gt 0 ] || { printf 'no test suites found\n' >&2; exit 2; }
failed=0
for s in "${suites[@]}"; do
printf '== %s\n' "${s##*/}"
"$s" | sed 's/^/ /'
[ "${PIPESTATUS[0]}" -eq 0 ] || failed=$(( failed + 1 ))
done
printf -- '-- %d suite(s), %d failed\n' "${#suites[@]}" "$failed"
[ "$failed" -eq 0 ]${PIPESTATUS[0]} rather than testing the pipeline, for the reason Chapter 21.7 §7 measured the hard way. Here it is again, with two suites of which the second fails:
$ ./run-tests
== t_a.sh
ok one
== t_b.sh
FAIL something
-- 2 suite(s), 1 failed
$ echo $?
1And the same runner with ${PIPESTATUS[0]} changed to a plain $? — which then reads sed's status instead of the suite's:
$ ./run-bad | tail -1
-- 2 suite(s), 0 failed
$ echo $?
0Zero failed, exit 0, on exactly the same red suite. A CI step wired to that runner is permanently green: Chapter 20.1's worst outcome, reached through the test runner itself — which is the one place nobody thinks to test.
The empty-glob guard is load-bearing for the same reason. With shopt -s nullglob and no t_*.sh present:
$ ./run-tests
no test suites found
$ echo $?
2Without nullglob, suites would hold the literal string t_*.sh, the count would be 1, and the runner would try to execute a file that does not exist — Chapter 9.6's unmatched glob, which is why [ "${#suites[@]}" -gt 0 ] is written out rather than assumed. (Chapter 10.6 is shopt itself.)
In CI, next to the checker. Chapter 21.7's check-scripts and this are the two halves of a gate: static first because it is free, then the tests.
And as a pre-commit hook, if the suite is fast. A suite that takes two seconds gets run; one that takes two minutes gets skipped, which argues for keeping the fixtures small and the SUT's slow paths stubbed.
8. A Complete Suite
Everything above, for verdict.sh, including the filesystem check and a self-check that the harness can fail:
#!/usr/bin/env bash
# t_verdict.sh — tests for verdict.sh
#
# usage: t_verdict.sh [-v]
# exit: 0 all passed · 1 a test failed · 2 the SUT is missing
set -o nounset
SUT=${SUT:-./verdict.sh}
verbose=0
while getopts ':v' o; do case $o in v) verbose=1 ;; esac; done
[ -x "$SUT" ] || { printf 't_verdict.sh: not executable: %s\n' "$SUT" >&2; exit 2; }
pass=0; fail=0
tmp=$(mktemp -d); trap 'chmod -R u+rwX -- "$tmp" 2>/dev/null; rm -rf -- "$tmp"' EXIT
say() { [ "$verbose" -eq 0 ] || printf ' %s\n' "$*"; }
check() {
local name=$1 want_out=$2 want_rc=$3; shift 4
local got_out got_rc
got_out=$("$SUT" "$@" 2>/dev/null) || true
got_rc=$("$SUT" "$@" >/dev/null 2>&1; echo $?)
say "args: $*"
if [ "$got_out" = "$want_out" ] && [ "$got_rc" = "$want_rc" ]; then
printf 'ok %s\n' "$name"; pass=$(( pass + 1 ))
else
printf 'FAIL %s\n out: got [%s] want [%s]\n rc: got [%s] want [%s]\n' \
"$name" "$got_out" "$want_out" "$got_rc" "$want_rc"; fail=$(( fail + 1 ))
fi
}
check_stderr_mentions() {
local name=$1 want=$2; shift 2
local got
got=$("$SUT" "$@" 2>&1 >/dev/null) || true
case $got in
*"$want"*) printf 'ok %s\n' "$name"; pass=$(( pass + 1 )) ;;
*) printf 'FAIL %s: stderr [%s] does not mention [%s]\n' "$name" "$got" "$want"
fail=$(( fail + 1 )) ;;
esac
}
# ---- fixtures ------------------------------------------------------------
printf 'running\nTEST PASSED\n' > "$tmp/pass.log"
printf 'UVM_ERROR : x\n' > "$tmp/err.log"
printf 'UVM_FATAL : x\nUVM_ERROR : y\n' > "$tmp/fatal.log"
printf 'running\n' > "$tmp/noverdict.log"
: > "$tmp/empty.log"
printf 'TEST PASSED\n' > "$tmp/my log.log"
printf 'TEST PASSED' > "$tmp/no-newline.log" # 17.5 §4
printf 'TEST PASSED\n' > "$tmp/locked.log"; chmod 000 "$tmp/locked.log"
# ---- the verdicts: one per branch ---------------------------------------
check 'a passing log' PASS 0 -- "$tmp/pass.log"
check 'an error log' ERRORS 1 -- "$tmp/err.log"
check 'fatal beats error' FATAL 1 -- "$tmp/fatal.log"
check 'no verdict' NO-VERDICT 1 -- "$tmp/noverdict.log"
check 'an empty log' EMPTY 1 -- "$tmp/empty.log"
# ---- the awkward inputs -------------------------------------------------
check 'a name with a space' PASS 0 -- "$tmp/my log.log"
check 'no trailing newline' PASS 0 -- "$tmp/no-newline.log"
# ---- the negative cases: it must fail when it should --------------------
check 'no argument' '' 2 --
check 'missing file' '' 2 -- "$tmp/nope.log"
check 'unreadable file' '' 2 -- "$tmp/locked.log"
# ---- and the messages name the problem ---------------------------------
check_stderr_mentions 'usage names the script' 'usage'
check_stderr_mentions 'missing file is named' 'nope.log' "$tmp/nope.log"
check_stderr_mentions 'unreadable file is named' 'not readable' "$tmp/locked.log"
# ---- the filesystem is untouched ---------------------------------------
before=$(cd "$tmp" && find . | sort)
"$SUT" "$tmp/pass.log" > /dev/null 2>&1 || true
after=$(cd "$tmp" && find . | sort)
if [ "$before" = "$after" ]; then
printf 'ok %s\n' 'changes nothing on disk'; pass=$(( pass + 1 ))
else
printf 'FAIL %s\n' 'changed the filesystem'; fail=$(( fail + 1 ))
fi
printf -- '-- %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]$ ./t_verdict.sh
ok a passing log
ok an error log
ok fatal beats error
ok no verdict
ok an empty log
ok a name with a space
ok no trailing newline
ok no argument
ok missing file
ok unreadable file
ok usage names the script
ok missing file is named
ok unreadable file is named
ok changes nothing on disk
-- 14 passed, 0 failed
$ echo $?
0And the proof, three times:
$ cp verdict.sh v.bak
$ # swap the FATAL and ERRORS arms
$ ./t_verdict.sh | grep -E '^(FAIL|--)'
FAIL fatal beats error
-- 13 passed, 1 failed
$ cp v.bak verdict.sh
$ # make the empty check never fire
$ ./t_verdict.sh | grep -E '^(FAIL|--)'
FAIL an empty log
-- 13 passed, 1 failed
$ cp v.bak verdict.sh
$ # change ERRORS' exit 1 to exit 0
$ ./t_verdict.sh | grep -E '^(FAIL|--)'
FAIL an error log
-- 13 passed, 1 failed
$ cp v.bak verdict.sh
$ ./t_verdict.sh | tail -1
-- 14 passed, 0 failedSix decisions:
SUT=${SUT:-./verdict.sh} so the suite can be pointed at a copy — which is how you test a candidate version without moving files around, and how the three breakages above were run.
[ -x "$SUT" ] || exit 2 as the first check. A suite that cannot find the script must not report "0 failed"; Chapter 21.7 §4's lesson about a checker that did not check.
The trap does chmod -R u+rwX before rm -rf, because one fixture is mode 000 and rm -rf inside a 0700 directory copes but the intent is clearer stated. Chapter 20.5's handler, with the fixture's quirk handled.
check_stderr_mentions is loose on purpose. Asserting stderr exactly makes every reworded message a test failure, which trains people to stop running the suite. Asserting that it mentions the filename and the word usage pins the useful part — Chapter 21.1 §6's "quote the rejected value back" — without freezing the prose.
The no-trailing-newline fixture is Chapter 17.5 §4, and it is the kind of case that only gets tested if someone wrote it down once.
And the suite's own exit status is [ "$fail" -eq 0 ], so run-tests and CI read one number.
Practice
- Type out
verdict.shandt_verdict.sh. Confirm 14 passed, 0 failed. - Apply each of the three breakages from §4, one at a time, restoring from a copy between them. Confirm exactly one test fails each time, and that it is the one named for that behaviour.
- Invent a fourth breakage — change the
NO-VERDICTtext, say — and predict which test catches it before you run the suite. - Find a breakage that no test catches. Then write the test, confirm it fails, and fix the script.
- Delete the
rchalf ofcheckso it compares only stdout. Re-apply breakage three (exit 1→exit 0) and confirm the suite is now green on a broken script. - Make the suite red before mutating — break the SUT and leave it broken — then apply a second breakage. Explain what you can and cannot conclude.
- Restore a breakage by editing it back rather than from a copy. Then
diffagainst the original and describe what you might have introduced. - Add a fixture with a filename containing a newline (
touch "$(printf 'a\nb.log')"). Doesverdict.shhandle it? Does the harness? - Write
check_no_changefrom §6 and point it at a script that creates its output directory before validating its arguments. Read thediffit prints. - Write
run-testsand put two suites beside it. Then break one and confirmrun-testsexits 1 — and check it is reading${PIPESTATUS[0]}and notsed. - Time your suite. If it is over a few seconds, find the slowest fixture and stub it.
Check Your Understanding
- What are the five observable behaviours of a script, and which two are usually untested?
- Why does
checkcompare the exit status as well as stdout? - What does a green suite prove on its own?
- What is the procedure that turns a green suite into evidence?
- Why must the baseline be green before you mutate, and why restore from a copy?
- Why is
check_stderr_mentionsloose rather than exact? - Name two things not worth testing.
9. Remember
- A script has five observable behaviours: stdout, stderr, exit status, what it changed, and that it changed nothing else.
- Check the status as well as the output. A script can print the right word and return the wrong number.
- A twenty-line
checkfunction is enough for a handful of scripts;batsandshunit2pay off at several scripts and several people. - Build fixtures with
mktemp -dand atrap, so the suite is idempotent and leaves nothing behind. - Include the awkward inputs: a name with a space, an unreadable file, an empty file, no trailing newline.
- The negative cases carry the value. The happy path gets exercised every time anyone uses the tool.
- A green suite proves nothing until you have made it go red on purpose.
- Break one thing at a time, confirm exactly the expected test fails, restore from a copy, confirm green.
- A red baseline makes every mutation meaningless. Start clean.
- Assert stderr loosely — that it mentions the filename and the key word — or reworded messages become failures.
- A suite that cannot find the script must not report success.
- Read
${PIPESTATUS[0]}, not the pipeline, when a runner pipes a suite throughsed. - Test the interface, not the implementation, and not the shell.
10. Part I Is Complete
Twenty-one modules and a hundred and forty chapters, and the shell is no longer a collection of commands you half-remember.
You can navigate and inspect a filesystem, read and change permissions, follow a process and signal it. You know what the shell does to a line before running it — expansion order, word splitting, quoting — which is the single piece of knowledge that separates shell you can reason about from shell that works by luck. You can redirect and pipe deliberately, and you know which stream carries what. You can search and transform text with the tools designed for it rather than with a loop.
And you can write a script that decides, repeats, is decomposed into named pieces with their own arguments and scope, holds real lists, builds a correct command line out of values it did not choose, fails loudly, cleans up after itself, tells the truth in its exit status — and that another person can pick up: a usage message, conventional options, a stated configuration precedence, a documented exit contract, safety on a second run, a dry run, a known portability target, a static check, and a test suite you have watched fail.
That is a complete working knowledge of the language, and it is enough to write automation a team can depend on.
11. What Part II Is
Part II is the same language applied to the job. Nothing in it re-teaches Part I — it assumes every chapter above — and it is explicitly the Linux and Bash a semiconductor engineer needs rather than a general continuation. Fourteen modules, in six movements:
- The environment, first (Modules 22–23). Where semiconductor work actually happens: a workstation you log into, compute servers your jobs run on, a shared filesystem both can see, and which directory is backed up versus purged nightly. Then the shape of a real design tree — RTL, testbench, scripts, generated output — and how to find your way around a repository you did not write.
- Finding things (Module 24). Searching design, verification and log text: locating a signal's declaration across a hundred files, and the difference between searching code and searching forty thousand lines of tool output.
- Getting the tools to run (Modules 25–27). Module systems and environment files, licence servers, and what
source setup.shactually did to your shell; file lists, and how they go stale; then compile and elaborate as separate steps, where the output goes, and the arguments that differ between vendors. - The output (Modules 28–29). Run directories, generated files and workspace hygiene — what is safe to delete and what is irreplaceable; then what a simulation log actually contains, finding the first real error in a long one, and the difference between a tool that failed and a test that failed.
- Doing it at scale (Modules 30–32). Sharing a machine without degrading it for everyone, schedulers, test lists and seeds, and then regression automation end to end: running hundreds of tests, collecting results, triaging a failure list and reporting a number someone can act on.
- And making it survive contact (Modules 33–35). Debugging failed jobs and broken flows, defensive automation around the specific ways EDA tools misbehave — truncated logs, hangs, lost licences, and the ones that print a fatal error and exit zero — and finally reusable flow tooling you can hand to someone else.
Every one of those is a Part I technique applied to a tool that does not behave well. The last of them is this module's lesson arriving in its natural habitat: a simulator that exits 0 after a fatal error is Chapter 20.1's seventh shape, and the only defence is the exit-code discipline of Chapter 21.4 wrapped around a tool that does not have any.
The language is finished. The next half is about the environment, and it begins with Chapter 22.1 — four assumptions every EDA tool makes about the operating system beneath it, and what each one costs when it turns out to be false.
Where this fits
Part of the Linux & Bash curriculum.
