Mocking and Stubbing External Commands in Bash Tests
AI generated
$_
#!/
Bash · Mocking · Testing · bats-mock
Mocking External Commands in Bash Tests
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.

18 min read PATH override · function mocks · bats-mock Bash 4.x · 5.x · Linux · macOS

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.

11. FAQ: Mocking External Commands in Bash Tests

1Why mock external commands?
Real calls make tests slow, unreliable and infrastructure dependent. Mocking solves that.
2How does PATH override work?
A stub directory is placed ahead of the real system paths in PATH, Bash finds the stub first.
3Function or PATH override?
Function for fast unit tests in the same process, PATH override for subshells and child processes.
4Why export -f?
Makes the mock function visible in subshells and Bash child processes too.
5Check call arguments?
Mock logs calls to a log file, test evaluates it with grep.
6Simulate different failure scenarios?
Via an environment variable controlling behavior inside the mock.
7Advantage of bats-mock?
Generated mocks and consistent assertions instead of hand written stub logic.
8Make time and randomness deterministic?
Override date and similar functions, return fixed values.
9What is mock drift?
Mock becomes outdated versus real behavior, test stays green, real script breaks.
10Replace mocking entirely?
No, use a few real integration tests as a complement against mock drift.