0Pricing
DevOps Bootcamp · Lesson

Mocking Commands and Stubbing External Tools

Override PATH and define fake binaries to test scripts without touching real systems.

Mocking Commands and Stubbing External Tools is a free DevOps Bootcamp lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the DevOps Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Mock Commands in Bash Tests?

When you test a Bash script that calls curl, aws, git, or any external tool, you face a problem: real calls hit the network, modify state, cost money, or simply fail in a CI environment where those tools aren't installed.

Mocking means replacing the real command with a fake one that you control. Your fake (the stub) returns predictable output and exit codes, so your test is fast, isolated, and reproducible.

  • No network or cloud access needed
  • Tests run in milliseconds instead of seconds
  • You can simulate errors that are hard to trigger on real systems
  • CI pipelines stay clean and dependency-free

Bash gives you a surprisingly simple mechanism to do this: just put your fake binary somewhere earlier on $PATH than the real one.

How PATH Lookup Works

When the shell executes a command like curl, it searches each directory in $PATH from left to right and runs the first match it finds.

This means if you prepend a directory that contains your own curl script, the shell will never reach /usr/bin/curl.

The override pattern:

  1. Create a temporary directory (your stub bin)
  2. Write a fake executable with the same name as the real command
  3. Prepend that directory to PATH
  4. Run your script under test — it calls your stub, not the real binary
  5. Clean up the temp directory after the test

This works without root access, without modifying system files, and without any special framework.

Creating a Stub Directory

The standard pattern uses mktemp -d to create an isolated temporary directory for your stubs. Every test or test suite gets its own directory, preventing cross-test pollution.

After your test completes, remove the directory with rm -rf. Using a trap ensures cleanup even when the test exits early due to an error.

#!/usr/bin/env bash
# Setup a stub bin directory for testing

# Create the temp dir
STUB_BIN=$(mktemp -d)

# Always clean up on exit (success, error, or signal)
trap 'rm -rf "$STUB_BIN"' EXIT

# Prepend it to PATH so our stubs take priority
export PATH="$STUB_BIN:$PATH"

echo "Stub bin: $STUB_BIN"
echo "PATH starts with: ${PATH%%:*}"

# Your tests would go here...
echo "Tests complete."

Writing Your First Stub

A stub is just an executable file with the same name as the command you want to replace. It prints whatever output your script under test expects, and exits with the code you choose.

Key rules for stubs:

  • The file must be executable (chmod +x)
  • The shebang line (#!/usr/bin/env bash) is required
  • Echo the output your real script would parse
  • Use exit 0 for success, non-zero for simulated failures
#!/usr/bin/env bash
# Create a stub for 'curl' that returns a fake HTTP response

STUB_BIN=$(mktemp -d)
trap 'rm -rf "$STUB_BIN"' EXIT
export PATH="$STUB_BIN:$PATH"

# Write the stub
cat > "$STUB_BIN/curl" << 'EOF'
#!/usr/bin/env bash
# Fake curl: always returns a 200 OK with a JSON body
echo '{"status": "ok", "version": "1.2.3"}'
exit 0
EOF
chmod +x "$STUB_BIN/curl"

# Verify the stub is found before the real curl
which curl
curl https://example.com/api/version

Testing a Script That Calls curl

Now let's put it together. Suppose you have a deployment script that calls curl to check a health endpoint, then exits with an error if the service is not healthy. You want to test both the happy path and the failure path without a real server.

#!/usr/bin/env bash
# Script under test: check_health.sh
# It calls curl and checks the returned JSON

check_health() {
  local url="$1"
  local response
  response=$(curl -sf "$url")
  if [[ "$response" == *'"healthy":true'* ]]; then
    echo "Service is UP"
    return 0
  else
    echo "Service is DOWN" >&2
    return 1
  fi
}

# ---- Test harness ----
STUB_BIN=$(mktemp -d)
trap 'rm -rf "$STUB_BIN"' EXIT
export PATH="$STUB_BIN:$PATH"

# Happy path stub
cat > "$STUB_BIN/curl" << 'EOF'
#!/usr/bin/env bash
echo '{"healthy":true}'
EOF
chmod +x "$STUB_BIN/curl"

check_health "http://fake-host/health" && echo "PASS: healthy response"

Simulating Command Failures

One of the most valuable uses of stubs is simulating failures that are difficult to reproduce with real tools — network timeouts, permission errors, disk full, or a remote API returning a 500.

To simulate failure, simply make your stub exit with a non-zero code. You can also write to stderr exactly as the real command would, so your script's error handling is fully exercised.

#!/usr/bin/env bash
# Test that check_health handles a curl failure gracefully

check_health() {
  local url="$1"
  local response
  # -f makes curl exit non-zero on HTTP error; -s silences progress
  if ! response=$(curl -sf "$url" 2>/dev/null); then
    echo "ERROR: could not reach $url" >&2
    return 1
  fi
  echo "OK: $response"
}

STUB_BIN=$(mktemp -d)
trap 'rm -rf "$STUB_BIN"' EXIT
export PATH="$STUB_BIN:$PATH"

# Failure stub — simulates a network error (curl exit code 6 = could not resolve host)
cat > "$STUB_BIN/curl" << 'EOF'
#!/usr/bin/env bash
echo 'curl: (6) Could not resolve host: fake-host' >&2
exit 6
EOF
chmod +x "$STUB_BIN/curl"

if ! check_health "http://fake-host/health"; then
  echo "PASS: failure path handled correctly"
fi

Recording Stub Calls for Verification

Sometimes you need to assert not just what your script outputs, but how it called an external tool — the arguments it passed, how many times it was called, or in what order. A spy stub records its invocations to a file.

After the test, your harness reads the record file and asserts on its contents. This gives you argument-level verification without any special framework.

#!/usr/bin/env bash
# Spy stub: record every invocation of 'aws' to a log file

STUB_BIN=$(mktemp -d)
CALL_LOG=$(mktemp)
trap 'rm -rf "$STUB_BIN" "$CALL_LOG"' EXIT
export PATH="$STUB_BIN:$PATH"
export CALL_LOG   # make it available inside the stub

cat > "$STUB_BIN/aws" << 'EOF'
#!/usr/bin/env bash
# Append all arguments to the call log
echo "aws $*" >> "$CALL_LOG"
# Return fake S3 output
echo "upload: ./report.pdf to s3://my-bucket/report.pdf"
exit 0
EOF
chmod +x "$STUB_BIN/aws"

# Simulate the script under test calling aws s3 cp
aws s3 cp report.pdf s3://my-bucket/report.pdf
aws s3 cp logs.tar.gz s3://my-bucket/logs.tar.gz

# Verify calls were made with expected arguments
echo "--- Recorded calls ---"
cat "$CALL_LOG"
grep -q 's3://my-bucket/report.pdf' "$CALL_LOG" && echo "PASS: S3 upload verified"

Stubbing Multiple Commands at Once

A real script often calls several external tools. You can stub all of them in the same STUB_BIN directory. Each stub file is independent and can return different outputs and exit codes.

Keep stubs minimal: return only what the script under test actually parses. Don't try to simulate every flag — just the subset your script uses.

#!/usr/bin/env bash
# Stub both 'git' and 'docker' for a release script test

STUB_BIN=$(mktemp -d)
trap 'rm -rf "$STUB_BIN"' EXIT
export PATH="$STUB_BIN:$PATH"

# Stub git: pretend we are on tag v2.1.0
cat > "$STUB_BIN/git" << 'EOF'
#!/usr/bin/env bash
case "$*" in
  *"describe --tags"*) echo "v2.1.0" ;;
  *"rev-parse HEAD"*)  echo "abc1234" ;;
  *) echo "[git stub] unhandled: $*" >&2 ; exit 1 ;;
esac
EOF
chmod +x "$STUB_BIN/git"

# Stub docker: pretend build and push succeed
cat > "$STUB_BIN/docker" << 'EOF'
#!/usr/bin/env bash
echo "[docker stub] $*"
exit 0
EOF
chmod +x "$STUB_BIN/docker"

# Simulate the release logic
VERSION=$(git describe --tags)
SHA=$(git rev-parse HEAD)
echo "Building image for version=$VERSION sha=$SHA"
docker build -t "myapp:$VERSION" .
docker push "myapp:$VERSION"

Using Functions as Stubs (No Files Needed)

For simple cases, you don't need to write files at all. You can define a shell function with the same name as the command. Because functions are resolved before external PATH lookup, they take priority automatically.

This is the fastest approach for unit-testing sourced scripts. However, function stubs only work within the same shell process — they won't be visible to subshells spawned with explicit bash -c or backgrounded processes. For those, use the file-based approach.

#!/usr/bin/env bash
# Source the script under test (a small helper library)
source_under_test() {
  # Inline the logic we want to test
  get_instance_id() {
    # Would normally call: curl http://169.254.169.254/latest/meta-data/instance-id
    curl -sf http://169.254.169.254/latest/meta-data/instance-id
  }
}
source_under_test

# Override curl with a shell function stub
curl() {
  echo "i-0abc123def456"
  return 0
}
# Export is NOT needed — function is visible in same shell

# Run the function under test
result=$(get_instance_id)
[[ "$result" == "i-0abc123def456" ]] && echo "PASS: instance ID returned" || echo "FAIL"

Exporting Functions to Subshells

When your script under test launches a subshell (e.g. bash script.sh or a pipeline), shell function stubs defined in the parent are not inherited by default. You have two options:

  • Use export -f function_name to export the function — it becomes available to child bash processes
  • Or fall back to file-based stubs in a STUB_BIN directory, which always work across process boundaries

export -f is elegant but only works with bash (not sh or other shells). Prefer file-based stubs in polyglot CI environments.

#!/usr/bin/env bash
# Demonstrate export -f for subshell-visible function stubs

# Define the stub in the current shell
curl() {
  echo '{"status":"ok"}'
  return 0
}
# Export the function so child bash processes inherit it
export -f curl

# Verify the stub works in a subshell
bash -c '
  response=$(curl -sf http://api.example.com/status)
  echo "Subshell got: $response"
'

# Without export -f, the subshell would call the real curl
# (or fail if curl is not installed)

Integrating Stubs with a Test Framework (BATS)

When you use BATS (Bash Automated Testing System), stub setup belongs in the setup() hook and teardown in teardown(). BATS resets the environment between tests, so each test gets a fresh stub directory.

BATS variables like $BATS_TEST_TMPDIR give you a per-test temp directory automatically — use it instead of mktemp -d for cleaner code.

#!/usr/bin/env bats
# File: test_deploy.bats
# Run with: bats test_deploy.bats

setup() {
  # BATS provides a unique tmpdir per test
  export STUB_BIN="$BATS_TEST_TMPDIR/stub_bin"
  mkdir -p "$STUB_BIN"
  export PATH="$STUB_BIN:$PATH"

  # Default stub: healthy service
  cat > "$STUB_BIN/curl" << 'EOF'
#!/usr/bin/env bash
echo '{"healthy":true}'
EOF
  chmod +x "$STUB_BIN/curl"
}

teardown() {
  # BATS auto-removes BATS_TEST_TMPDIR, but explicit is safer
  rm -rf "$STUB_BIN"
}

@test "deploy succeeds when service is healthy" {
  run bash deploy.sh
  [ "$status" -eq 0 ]
  [[ "$output" == *"Deploy complete"* ]]
}

@test "deploy aborts when service is down" {
  # Override the stub for this specific test
  echo -e '#!/usr/bin/env bash\nexit 1' > "$STUB_BIN/curl"
  chmod +x "$STUB_BIN/curl"

  run bash deploy.sh
  [ "$status" -ne 0 ]
}

Knowledge Check: Mocking Commands in Bash

Test your understanding of command mocking and stub patterns in Bash.

A developer writes a test that defines a shell function named aws to stub the real AWS CLI. The test runs fine directly in the terminal, but when the CI pipeline runs the script under test as bash deploy.sh, the stub is ignored and the real aws command is called.

What is the correct fix?

Recap: Mocking Commands and Stubbing External Tools

You have learned the complete toolkit for replacing real commands with controlled fakes during Bash script testing.

Core techniques covered:

  • PATH prepending — create a STUB_BIN directory with mktemp -d, write executable stub files there, and prepend the directory to PATH
  • Stub exit codes — return 0 for success and non-zero values to simulate specific failures (network errors, permission denied, etc.)
  • Spy stubs — append arguments to a log file inside the stub to assert how your script called external tools
  • Multiple stubs — place several stub files in the same STUB_BIN to mock an entire ecosystem of dependencies at once
  • Function stubs — define a shell function with the same name as a command for same-process mocking; use export -f to reach child bash processes
  • BATS integration — use setup()/teardown() hooks and $BATS_TEST_TMPDIR for clean per-test isolation

Always use a trap '...' EXIT to guarantee stub cleanup regardless of test outcome. Keep stubs minimal — return only what your script actually parses. File-based stubs are the most portable choice for CI pipelines.

Frequently asked questions

Is the “Mocking Commands and Stubbing External Tools” lesson free?

Yes — the full text of “Mocking Commands and Stubbing External Tools” is free to read here on the web, and the DevOps Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DevOps Bootcamp course, upgrade to CoddyKit PRO.

What will I learn in “Mocking Commands and Stubbing External Tools”?

Override PATH and define fake binaries to test scripts without touching real systems. You practise DevOps Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start DevOps Bootcamp?

No prior experience is required. DevOps Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Mocking Commands and Stubbing External Tools” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this DevOps Bootcamp lesson?

Yes. Every DevOps Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Unit Testing Functions with Bats-core
  2. Mocking Commands and Stubbing External Tools
  3. Fixtures, Temp Environments, and Coverage
  4. Running Shell Tests in CI Pipelines
← Back to DevOps Bootcamp