Skip to content
shellhacks.dev
Testing

What Makes a Shell Script Testable With Real Output

What makes a shell script testable: functions, a main guard, injected paths and commands, exit codes, no hidden globals, and a small example refactored.

6 min read
Summarize in AI
Illustration for "What Makes a Shell Script Testable With Real Output": a terminal session, test results, an exit status badge.

Most shell scripts are untestable by construction: they run everything at the top level, touch absolute paths and call real services. Testing then means running the whole script and looking. A few design habits make the logic testable in isolation, and they happen to improve readability. This is the design side of testing Bash functions and making a script safe to source. The examples assume Bash 5.2 and Bats 1.11.

Before: untestable

Shell
#!/usr/bin/env bash
cd /srv/uploads
for f in *.jpg; do
  size=$(stat -c %s "$f")
  if [ "$size" -gt 5000000 ]; then
    convert "$f" -resize 50% "small-$f"
    curl -s -X POST -d "resized $f" https://hooks.example.test/notify
  fi
done

The script runs when you load it, uses a fixed directory, calls convert and curl for real, and mixes the decision (is the file too big?) with the effects (resize and notify). To test it you need images, ImageMagick and a web server.

After: testable

Shell
#!/usr/bin/env bash
# resize-large.sh
set -euo pipefail

: "${UPLOAD_DIR:=/srv/uploads}"
: "${MAX_BYTES:=5000000}"
: "${CONVERT:=convert}"
: "${NOTIFY:=notify_webhook}"

is_too_large() {                       # pure decision: file size vs limit
  local size=$1 limit=$2
  (( size > limit ))
}

file_size() { stat -c %s -- "$1"; }

notify_webhook() { curl -fsS -X POST -d "$1" https://hooks.example.test/notify >/dev/null; }

resize_one() {
  local f=$1
  "$CONVERT" "$f" -resize 50% "$(dirname "$f")/small-$(basename "$f")"
  "$NOTIFY" "resized $f"
}

main() {
  local f
  for f in "$UPLOAD_DIR"/*.jpg; do
    [[ -e $f ]] || continue
    if is_too_large "$(file_size "$f")" "$MAX_BYTES"; then
      resize_one "$f"
    fi
  done
}

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

Now the script can be sourced without running anything. is_too_large is a pure function that tests can call with numbers. The directory, limit and the two external commands are variables with defaults, so a test can point UPLOAD_DIR at a temporary folder and replace CONVERT and NOTIFY with fakes that record their arguments.

The test

Shell
#!/usr/bin/env bats
setup() {
  source "$BATS_TEST_DIRNAME/../resize-large.sh"
  UPLOAD_DIR=$(mktemp -d)
}

teardown() { rm -rf "$UPLOAD_DIR"; }

@test "is_too_large compares size to the limit" {
  run is_too_large 6000000 5000000
  [ "$status" -eq 0 ]
  run is_too_large 100 5000000
  [ "$status" -eq 1 ]
}

@test "main resizes only the large file" {
  head -c 6000000 /dev/zero > "$UPLOAD_DIR/big.jpg"
  head -c 100 /dev/zero > "$UPLOAD_DIR/tiny.jpg"
  CONVERT=fake_convert NOTIFY=fake_notify
  fake_convert() { echo "$1" >> "$UPLOAD_DIR/calls"; }
  fake_notify()  { echo "$1" >> "$UPLOAD_DIR/notes"; }
  run main
  [ "$status" -eq 0 ]
  grep -q big.jpg "$UPLOAD_DIR/calls"
  ! grep -q tiny.jpg "$UPLOAD_DIR/calls"
}

The fakes are shell functions, and the script calls them through the variable names, which is the replaceable-name seam. No ImageMagick, no network and no fixed paths are required, and the test runs in milliseconds. The approach avoids heavy mocking tools; see why mocks are hard in shell for the alternatives.

The checklist

Define everything in functions and call only main. Guard with the sourcing check. Read configuration from variables with defaults using : "${VAR:=default}". Return results with printf and statuses, not by setting globals. Keep decisions separate from effects. Call external tools through variables or small wrapper functions when you need to replace them. Avoid cd in the middle of functions and prefer arguments over implicit directories. Run ShellCheck to catch the quoting issues that make functions behave differently on odd input.

Why most scripts resist testing

A script that does everything at the top level, reading arguments, calling commands and exiting from the middle, can only be tested by running it whole, with real side effects. Three habits of such scripts block testing: logic is mixed with action, so a decision cannot be examined without performing what follows; functions reach for globals and the environment instead of taking arguments; and the final line is a call to exit, which would stop the test runner if the file were sourced. The goal of restructuring is to separate deciding from doing, so the deciding parts become functions that take input and produce output, and the doing parts are thin wrappers around system calls.

Start with the parts that contain bugs. Parsing arguments, building paths and choosing between branches are more error-prone than calling cp, and they are easy to isolate once pulled into functions.

Making the file sourceable

Guard the main entry point so that sourcing the file defines functions without running anything. The standard idiom compares the file name with the invocation: if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi. Put all the work in main, which calls the functions. A test then sources the script, calls a function with chosen arguments and checks the output and exit status. Move the strict-mode options into the guarded section if they would interfere with the test runner, since set -e inside sourced code changes the runner's behaviour. Keep configuration in variables with defaults that tests can override, so the script can be pointed at a temporary directory.

Avoid using exit in functions. Return a status and let main decide, so a failing function can be checked without ending the test process.

Isolating the outside world

Functions that call external programs are tested by replacing those programs. Put a directory of stubs first in PATH during the test; each stub records its arguments to a file and prints canned output. The test then asserts both the result and the calls made. For commands that must be real, such as sort or grep, let them run, since they are deterministic. For time, network and randomness, wrap the access in a function that tests can override. Work in a temporary directory made per test, and set HOME and other variables to values inside it, so a test cannot touch the developer's files. The discipline pays off by letting the suite run in seconds, in CI, on any machine.

Resist mocking everything. A test that is all stubs proves the code calls what you expected, and not that the system works. Keep a few integration tests that run the real script in a container.

A checklist for the next script

Before writing a script that will live, decide its structure: a header with strict mode, small functions with one job, a main with the guard, configuration through variables, errors to standard error and a non-zero status on failure. Write the first test with the first function. Run ShellCheck in the editor and CI. Add a test for every bug fixed, named for the bug. Keep functions short enough to read at a glance, and push logic that grows complicated toward a language with better tools. Review the tests as seriously as the code: a test that cannot fail is worse than none, because it creates false confidence. Over time the suite documents the script's intended behaviour, and gives you the freedom to change it without fear, which is the real purpose of testing.

Finally, set expectations about coverage. Not every line of a deployment script needs a unit test, and some code is best verified by running it in a disposable environment. Aim to test the logic that decides: argument parsing, path construction, version comparison, retry policy and error handling. Leave simple pass-through calls to the integration test. A suite that covers decisions thoroughly and runs the real thing once catches most of the failures that matter, at a cost the team will keep paying.

Where this does not apply

For a ten-line script that wraps one command, a test costs more than the script. For code that is mostly calls to external services, an integration test against a real or containerised service is more informative than a faked one, see unit versus integration tests. When the logic grows beyond what is comfortable in Bash, a rewrite in Python or Go, as in Go versus Bash, makes testing easier still. See the Bats documentation.

For the testing side of the same design, the Bats guide to writing tests shows setup, teardown and run in detail, and the Bash manual on functions covers the scoping rules that make the pattern work.

Taggedscripting
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.