Why Mocks Are Hard in Shell and What to Do Instead
Why mocking is hard in shell and what to do instead: PATH stubs, function overrides, injected command variables, containers, and when to test the real tool.

In most languages a mock replaces an object behind an interface. Shell has no interfaces: a script calls curl, the shell finds curl on the PATH, and a new process runs. So the replacement happens by changing how the name resolves, or by not calling by that name. Knowing the three or four ways to do it, and their limits, keeps tests simple. It is the companion to what makes scripts testable and mocking commands in tests. The examples assume Bash 5.2.
Fake executable on PATH
setup() {
FAKE_BIN=$(mktemp -d)
export CALLS="$FAKE_BIN/calls.log"
cat > "$FAKE_BIN/curl" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$CALLS"
echo '{"status":"ok"}'
EOF
chmod +x "$FAKE_BIN/curl"
PATH="$FAKE_BIN:$PATH"
}
@test "calls the API once" {
run ./fetch-status.sh
[ "$status" -eq 0 ]
[ "$(wc -l < "$CALLS")" -eq 1 ]
grep -q 'https://api.example.test/status' "$CALLS"
}This works for any external program, from any script, with no changes to the script. The fake records its arguments and returns canned output. It covers a script that calls curl, docker, git or ssh, and it also works across subprocesses because the PATH is inherited. Limits: scripts that call absolute paths (/usr/bin/curl) bypass it, and every fake is another script to maintain.
Function overrides
curl() { echo '{"status":"ok"}'; }
export -f curl # Bash only: children started by this shell see it
source ./fetch-lib.sh
run fetch_statusA function with the same name takes precedence over the command in the current shell, so overriding works when the code under test runs in the same process. A subprocess (a script launched by path, xargs, find -exec) does not see it unless exported with export -f, which is Bash-specific and fails in sh -c. Functions are lighter than fake files, and they leak: remember to unset -f in teardown or run each test in a subshell. Inside a function, command curl bypasses the override.
Inject the command name
: "${CURL:=curl}"
fetch_status() { "$CURL" -fsS https://api.example.test/status; }The script reads the name from a variable that defaults to the real command, so a test sets CURL=fake_curl. It is the cleanest approach when you control the script and want to avoid PATH tricks; it makes the dependency visible. Keep the number of injected commands small: wrap the few calls that matter, not every echo.
Why it is still hard
A fake that returns canned output tests your code against your belief about the tool, not the tool. If git changes the format of git status --porcelain, your fake keeps passing while production breaks. Fakes also grow: handling subcommands and flags turns a three-line stub into a poor reimplementation. Order and timing are hard to simulate, and failures (exit codes, partial output, slow responses) need extra fakes. The result can be tests that pass with a broken script.
Better substitutes where possible
tmp=$(mktemp -d); git -C "$tmp" init -q; git -C "$tmp" commit -q --allow-empty -m init
docker run -d --name testdb -e POSTGRES_PASSWORD=change-me -p 55432:5432 postgres:17
python3 -m http.server 8099 --directory fixtures &Use real, cheap resources: a temporary Git repository, a throwaway database in a container, a local HTTP server serving fixture files, a temp directory with sample data. These test the real interaction, with no fake to keep in sync. Reserve fakes for things that are dangerous, slow or costly: sending email, charging a card, calling a paid API, deleting production data. The split is the unit versus integration question of unit versus integration tests.
How command lookup gives you the seams
Every test double in shell exploits the order in which Bash resolves a command name: aliases, then functions, then builtins, then the directories on PATH. A test can interpose at any of these levels. A function shadows an external command in the same shell. A fake executable early on the path shadows the real one for the whole process tree. A variable that holds the command name moves the seam into the script's own code. Choosing the level is choosing how much of the script you want the fake to reach and how invisible the substitution should be.
Builtins deserve a note: a function can override cd or echo, and command or builtin bypass the override. That is rarely wanted in tests, and it can hide real behaviour. Keep fakes for external programs, where the boundary between your script and the outside world actually lies.
Verifying how the fake was used
A fake that only returns canned output tests the script's reaction. A fake that also records its invocations lets the test check the script's action: that it called curl with the right URL, header and method, the right number of times and in the right order. Append each call as a line in a file, and assert on the file's content. Resist asserting on every argument of every call; pin down the arguments that carry the behaviour, such as the endpoint, and leave incidental flags alone, so refactoring does not break tests that still describe correct behaviour.
Make the fake configurable per test. A variable can select the response, for example success, a 500 or a timeout, so one fake serves many scenarios. The scenarios for failure matter most, since they cover the code that is hardest to exercise for real. Include one test where the fake exits non-zero without printing anything, because a script that does not check the status of its tools is a common source of silent errors.
Keeping fakes honest
The risk of a fake is that it diverges from reality. Counter it with contract tests: a small test that runs the real tool against a local resource and compares the shape of its output with what the fake returns. When a tool upgrade changes its output, the contract test fails first and tells you the fake needs updating. Run those tests less often, in CI nightly, and keep them separate from the fast suite so that developers get immediate feedback while the checks against reality still run regularly.
Store canned responses as files taken from the real tool, not typed by hand. A captured output carries the details a person forgets: trailing spaces, extra fields, odd quoting. Refresh them when the tool changes, and note the version they came from in a file name or comment.
Recognising when to stop mocking
If the fake is longer than the code it replaces, or encodes the tool's logic, stop. Use the real tool in a sandbox, or move the logic so that it can run without the tool, passing parsed values to a pure function. The cheapest tests are those with nothing to fake, and designing scripts toward that shape, with small pure functions at the core and thin wrappers at the edges, reduces the need for mocks across the whole codebase.
Where this does not apply
If you find yourself writing a large fake, step back: restructure the script so that the logic does not depend on the tool's output format, or test it as an integration test. Bats helper libraries offer stubbing (bats-mock), which wraps the PATH technique with call counting. See bats-assert and bats-support for assertions, and the Bats guide to writing tests.
The bats-mock helper packages the PATH-stub technique with call counting if you prefer a library over hand-written fakes.


