simulating curl, ssh and friends instead of touching real systems
A Bash script that calls curl, ssh or mysqldump cannot be meaningfully tested without touching real network connections or databases on every test run. Mocking external commands solves exactly this problem: the PATH override pattern and targeted function overriding make Bash tests deterministic, fast and independent of real infrastructure.
Table of Contents
- 1. Why external commands become a problem in tests
- 2. The PATH override pattern: putting stub scripts ahead of real commands
- 3. Overriding functions instead of manipulating PATH
- 4. Logging calls and checking arguments
- 5. Controlling return values and exit codes of mocks
- 6. Structuring mocking with bats-mock
- 7. Making time, randomness and network deterministic
- 8. Limits of mocking: when an integration test is needed
- 9. Mocking techniques compared
- 10. Summary
- 11. FAQ
1. Why external commands become a problem in tests
Bash scripts are rarely self contained. They call curl for HTTP requests, ssh for remote commands, mysqldump for database backups, or docker for container operations. These exact external dependencies make mocking commands in Bash necessary: without mocking, every test run would open a real HTTP connection, start a real SSH session, or touch a real database, with all the consequences for speed, reliability and side effects.
The problem has several dimensions. First, speed: a real curl call against a remote server takes seconds, a mocked command responds in milliseconds. Second, reliability: a test run should not fail just because an external service happens to be unreachable. Third, the determinism question: a test that depends on the current system time, a random number, or the actual state of a database delivers a different result on every run, which makes test automation practically impossible.
The solution to all these problems is the same: replace external commands with controlled stand ins that deliver predictable behavior without touching the real outside world. In Bash there are two established techniques for this, the PATH override pattern and directly overriding function names, both covered in detail in the following sections.
2. The PATH override pattern: putting stub scripts ahead of real commands
The fundamental technique for mocking commands in Bash uses the order in which the shell searches for commands in PATH. If a directory containing a stub script with the same name is placed ahead of the regular system paths, Bash finds the stub first and never calls the real command. That works because Bash searches PATH left to right and stops at the first match, regardless of whether the real command still exists further down the PATH.
For this technique, you create a test directory with executable files sharing the name of the commands to be mocked, for example test/mocks/curl. Before the actual test run, this directory is temporarily prepended with PATH="test/mocks:$PATH". The big advantage of this approach over function overriding: it also works when the script under test calls the command not as a Bash builtin, but via exec, in a subshell, or from another process, since PATH applies system wide to every new process.
#!/usr/bin/env bash
# test/mocks/curl — stub replacing the real curl binary
# Records every invocation and returns a canned response
echo "$*" >> "${MOCK_LOG:-/tmp/mock-curl.log}"
case "$1 $2" in
*"api.mironsoft.de/status"*)
echo '{"status":"ok"}'
exit 0
;;
*"api.mironsoft.de/deploy"*)
echo '{"error":"unauthorized"}'
exit 22
;;
*)
echo "mock curl: unhandled arguments: $*" >&2
exit 1
;;
esac
#!/usr/bin/env bash
# Running the test with the mock ahead of the real curl in PATH
set -euo pipefail
readonly TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PATH="${TEST_DIR}/mocks:${PATH}"
export MOCK_LOG="$(mktemp)"
# The script under test calls "curl", but resolves to our mock first
./check_deployment_status.sh
grep -q "api.mironsoft.de/status" "$MOCK_LOG" && echo "Mock was called correctly"
An important detail with PATH override: the stub must be executable (chmod +x) and must not carry a file extension, since the script under test calls the command as curl, not as curl.sh. In addition, PATH should be reliably reset after the test run, ideally via a subshell or a trap, so that subsequent tests do not accidentally use the same mock.
3. Overriding functions instead of manipulating PATH
A lighter weight alternative to the PATH override is directly overriding function names inside the test environment. If you define a Bash function with the same name as an external command, for example curl(), before sourcing the script under test, Bash calls this function instead of the real binary every time curl is invoked within the same shell process. Functions take precedence over commands in PATH in Bash, as long as no fully qualified path like /usr/bin/curl is used.
This approach to mocking commands is especially handy for fast, isolated unit tests of individual functions, since no separate script file and no PATH manipulation is needed, everything happens within the same test file. The downside: function mocks only work within the same Bash process. If the script under test calls an external command from a subshell via $(...) where the function was not exported, the mock may not take effect.
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
setup() {
load "../lib/deployment.sh"
# Override curl with a mock function for this test file
curl() {
echo "MOCK: curl called with args: $*" >&2
if [[ "$*" == *"api.mironsoft.de/status"* ]]; then
echo '{"status":"ok"}'
return 0
fi
return 1
}
export -f curl
}
@test "check_deployment_status parses mocked curl response correctly" {
run check_deployment_status
assert_success
assert_output --partial "Deployment is healthy"
}
@test "check_deployment_status handles curl failure gracefully" {
curl() { return 7; }
export -f curl
run check_deployment_status
assert_failure
assert_output --partial "Could not reach API"
}
The export -f command is crucial: without it, the function is only visible in the current shell context, not in subshells created by command substitution or background processes. When a function is exported with export -f, subshells and child processes started from the script also inherit the override, as long as they are Bash child processes, not entirely new programs that resolve PATH themselves.
4. Logging calls and checking arguments
A mock that only returns a fixed response only tests whether the script can handle a particular response. Equally important is often the reverse check: was the external command actually called with the right arguments? This check requires the mock to log every call, typically to a log file that the test then evaluates.
A solid logging pattern writes a line with all arguments to a file whose path is configurable via an environment variable on every mock call. The test can then use grep or direct line comparison to check whether the external command was called with the expected parameters, and even verify the order of multiple calls, for example that a backup was created before the database was cleared.
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
setup() {
load "../lib/backup.sh"
CALL_LOG="$(mktemp)"
mysqldump() {
echo "mysqldump $*" >> "$CALL_LOG"
echo "-- mock dump output"
}
export -f mysqldump
export CALL_LOG
}
teardown() {
rm -f "$CALL_LOG"
}
@test "backup_database calls mysqldump with correct database name" {
run backup_database "shop_production"
assert_success
run grep -c "mysqldump.*shop_production" "$CALL_LOG"
assert_output "1"
}
@test "backup_database calls mysqldump exactly once" {
run backup_database "shop_production"
run wc -l < "$CALL_LOG"
assert_output "1"
}
@test "backup_database passes the --single-transaction flag" {
run backup_database "shop_production"
run grep -c -- "--single-transaction" "$CALL_LOG"
assert_output "1"
}
This pattern turns a simple mock into what is called a spy: it does not just simulate external behavior, it also records how it was used. This recording is especially valuable during refactoring: if a test needs to verify that mysqldump is still called with the same critical flags, an accidental change to those flags immediately shows up, without having to touch the actual database function.
5. Controlling return values and exit codes of mocks
The real strength of mocks lies in simulating failure scenarios that would be nearly impossible to reproduce with a real command: a network timeout, an expired certificate, a full disk, or a temporary server error with HTTP status 503. A mock that returns a specific exit code or error output on demand makes such scenarios testable in seconds and reproducibly, without first having to artificially create the corresponding failure condition in a real environment.
To cover different scenarios within the same test suite, you configure the mock via an environment variable that controls the desired behavior. That way, the same mock can simulate success, a specific error code, or a delayed response depending on the test case, without maintaining a separate stub script for every scenario.
#!/usr/bin/env bash
# test/mocks/curl — mock with configurable failure modes via env var
echo "$*" >> "${MOCK_LOG:-/dev/null}"
case "${MOCK_CURL_BEHAVIOR:-success}" in
success)
echo '{"status":"ok"}'
exit 0
;;
timeout)
echo "curl: (28) Operation timed out" >&2
exit 28
;;
unauthorized)
echo '{"error":"invalid token"}'
exit 22
;;
server_error)
echo '{"error":"internal server error"}'
exit 22
;;
*)
echo "Unknown MOCK_CURL_BEHAVIOR: ${MOCK_CURL_BEHAVIOR}" >&2
exit 1
;;
esac
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
setup() {
export PATH="$(pwd)/test/mocks:${PATH}"
}
@test "deploy retries on timeout and eventually fails" {
export MOCK_CURL_BEHAVIOR="timeout"
run deploy_to_production
assert_failure
assert_output --partial "Deployment failed after 3 retries"
}
@test "deploy fails fast on unauthorized without retrying" {
export MOCK_CURL_BEHAVIOR="unauthorized"
run deploy_to_production
assert_failure
assert_output --partial "Authentication failed"
refute_output --partial "retry"
}
This pattern shows a central advantage of mocking over real integration tests: you can specifically check whether the retry logic actually retries multiple times on a timeout, while it aborts immediately on an authorization error without wasting unnecessary attempts. This distinction in error behavior would be nearly impossible to reliably force with a real server.
6. Structuring mocking with bats-mock
For larger test suites with many mocked commands, manually writing stub scripts quickly becomes repetitive. The bats-mock library builds on the PATH override pattern, but encapsulates mock creation, configuration and evaluation into reusable helper functions. Instead of manually creating every mock as a file, bats-mock generates stub scripts at runtime and offers assertions like assert_called_with to check calls directly.
The advantage of bats-mock over hand written stub scripts lies in consistency across a growing test suite: every mock follows the same structure, every assertion uses the same syntax, and new team members do not first have to understand a project specific mocking pattern, they can rely on a documented, established library.
#!/usr/bin/env bats
load "test_helper/bats-mock/load"
load "test_helper/bats-assert/load"
setup() {
load "../lib/backup.sh"
mock_aws="$(mock_create)"
# Redirect calls to "aws" toward our generated mock stub
ln -sf "$mock_aws" "$(dirname "$mock_aws")/aws"
export PATH="$(dirname "$mock_aws"):${PATH}"
}
@test "upload_backup calls aws s3 cp with the correct bucket path" {
mock_set_output "$mock_aws" "upload: ok" 0
run upload_backup "/tmp/backup.sql.gz" "production-backups"
assert_success
assert_equal "$(mock_get_call_num "$mock_aws")" 1
assert_equal "$(mock_get_call_args "$mock_aws" 1)" \
"s3 cp /tmp/backup.sql.gz s3://production-backups/"
}
@test "upload_backup surfaces an aws error correctly" {
mock_set_output "$mock_aws" "Access Denied" 1
run upload_backup "/tmp/backup.sql.gz" "production-backups"
assert_failure
assert_output --partial "Access Denied"
}
A detail that distinguishes bats-mock from hand written stub scripts: the generated mocks automatically record the number of calls and all arguments in structured form, so assertions like mock_get_call_args can access specific calls directly, without hand written log parsing logic. For projects with more than a handful of mocked commands, this initial learning effort pays off quickly.
7. Making time, randomness and network deterministic
Besides external commands, there are other sources of nondeterminism that make Bash tests unreliable: the current system time via date, random numbers via $RANDOM or /dev/urandom, and generated unique IDs via uuidgen or mktemp. For reliable tests, these sources need to be controlled too, otherwise every test run produces slightly different results, which makes assertions on exact values impossible.
The Bash pattern for this is identical to mocking external commands: override date() as a function that always returns the same fixed timestamp, or make $RANDOM reproducible via a fixed seed in test environments. For network operations that do not go through a single command like curl, but directly via TCP sockets, a local test server that delivers simulated responses on a fixed port helps, instead of testing against a real external service.
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
setup() {
load "../lib/release.sh"
# Freeze time for deterministic release tag generation
date() {
echo "2026-07-30T14:00:00Z"
}
export -f date
# Deterministic "random" ID for reproducible test assertions
generate_release_id() {
echo "test-fixed-id-0001"
}
export -f generate_release_id
}
@test "create_release_tag produces a deterministic, predictable tag" {
run create_release_tag
assert_success
assert_output "release-2026-07-30-test-fixed-id-0001"
}
This technique has an additional advantage: tests based on a fixed time also reliably cover edge cases around time zones and date boundaries, for example a script's behavior on December 31st at 23:59, a case that without controlled time would only be randomly tested once a year, while with a mocked date it stays reproducible at any time.
8. Limits of mocking: when an integration test is needed
As useful as mocks are, they never test the actual behavior of the real external command, only the test author's assumptions about it. If the output format of curl, the behavior of an API, or the argument structure of a tool like aws changes, a mock test stays green even though the real script breaks in production. This risk is called "mock drift": the stand in gradually drifts away from real behavior without anyone noticing.
That is why mocked commands should never remain the only test layer. A small number of integration tests that actually run against a staging environment, a real test server, or a local Docker instance covers exactly the cases a mock cannot check by definition: whether the assumptions about external behavior still actually hold. The rule of thumb: many fast mock tests for your own script's logic, a few, but regularly running integration tests for compatibility with the real outside world.
9. Mocking techniques compared
Depending on the use case, different mocking techniques are more or less suitable, depending on test speed, setup effort and the accuracy of the simulation.
| Technique | Setup effort | Scope of effect | Best suited for |
|---|---|---|---|
| Function override | Very low | Current shell process only | Fast unit tests of individual functions |
| PATH override | Medium | Also subshells and child processes | Scripts with exec or external processes |
| bats-mock | Medium (one time) | Also subshells and child processes | Large test suites with many mocks |
| Local test server | High | Realistic network behavior | HTTP APIs with complex behavior |
| Real integration test | High, slow | Full reality | Regular compatibility checking |
In practice, these techniques are combined depending on the test level: function mocks for fast, isolated unit tests, PATH override or bats-mock for more realistic simulation of whole script flows, and a small number of real integration tests that run regularly but less often, to catch mock drift in time before it becomes visible in production.
Mironsoft
Shell automation, testing and deployment infrastructure
Need to reliably test Bash scripts with external dependencies?
We build mocking strategies for your Bash scripts, from simple function mocks to structured bats-mock test suites, complemented by targeted integration tests against real systems.
Mocking setup
PATH override and function mocks for curl, ssh, aws and more
Test suite expansion
bats-mock integration for growing, maintainable Bash test suites
Mock drift checking
Complementary integration tests against real staging systems
10. Summary
Mocking external commands is the basic prerequisite for testing Bash scripts with dependencies on curl, ssh, aws or database tools quickly and deterministically. The PATH override pattern puts stub scripts ahead of the real commands, also works in subshells and child processes, and is therefore more robust than plain function overriding, which only works in the current shell process. Call logging turns simple mocks into spies that can verify whether and how an external command was called.
bats-mock structures mocking for growing test suites, while controlled simulation of time and randomness eliminates additional nondeterminism. It remains important that mocked commands never stay the only test layer: a small number of real integration tests against actual systems catches mock drift before outdated assumptions about external behavior turn into real bugs in production.
Mocking External Commands in Bash Tests — The Essentials at a Glance
PATH override
Put a stub script ahead of the real system paths, also works in subshells and child processes.
Function mocks
Fast and lightweight for isolated unit tests, with export -f for subshell visibility.
Call logging
Extend a mock into a spy that records arguments and call counts for assertions.
Know the limits
Catch mock drift with regular real integration tests against actual systems.