模拟命令与替代外部工具
覆盖 PATH 并定义虚假二进制文件,在不接触真实系统的情况下测试脚本。
模拟命令与替代外部工具 是 CoddyKit 上的免费 Linux Command Line & Bash Scripting Mastery 课时。 这是第 2 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Linux Command Line & Bash Scripting Mastery 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Linux Command Line & Bash Scripting Mastery 课程共包含 4 节课。
为什么要在 Bash 测试中模拟命令?
当您测试会调用 curl、aws、git 或其他外部工具的 Bash 脚本时,会遇到一个问题:真实调用会访问网络、修改状态、产生费用,或者在未安装这些工具的 CI 环境中直接失败。
模拟是指用一个由您控制的伪命令替代真实命令。您的伪命令(即存根)会返回可预测的输出和退出码,因此测试速度快、彼此隔离且可重复。
- 不需要网络或云访问权限
- 测试在几毫秒内完成,而不是几秒
- 可以模拟在真实系统中难以触发的错误
- CI 流水线保持整洁,无需额外依赖
Bash 提供了一种出人意料地简单的实现方式:只需将伪二进制文件放在 $PATH 中,并确保其位置早于真实命令所在目录。
PATH 查找的工作原理
当 shell 执行 curl 这样的命令时,会从左到右搜索 $PATH 中的每个目录,并运行找到的第一个匹配项。
这意味着,如果您将一个包含自定义 curl 脚本的目录添加到 PATH 开头,shell 就不会继续查找 /usr/bin/curl。
覆盖模式:
- 创建一个临时目录(作为您的存根目录)
- 创建一个与真实命令同名的伪可执行文件
- 将该目录添加到
PATH开头 - 运行待测脚本——它会调用您的存根,而不是真实二进制文件
- 测试结束后清理临时目录
这种方法不需要 root 权限,不需要修改系统文件,也不需要任何特殊框架。
创建存根目录
标准模式使用 mktemp -d 创建一个彼此隔离的临时目录来存放存根。每个测试或测试套件都有自己的目录,从而避免测试之间相互污染。
测试完成后,使用 rm -rf 删除该目录。使用 trap 可以确保即使测试因错误提前退出,也能完成清理。
#!/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."编写您的第一个存根
存根就是一个与要替代的命令同名的可执行文件。它会输出待测脚本所需的内容,并以您指定的代码退出。
编写存根时需要遵循以下规则:
- 文件必须是可执行的(
chmod +x) - 必须包含 shebang 行(
#!/usr/bin/env bash) - 输出真实脚本会解析的内容
- 成功时使用
exit 0,模拟失败时使用非零退出码
#!/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测试调用 curl 的脚本
现在让我们把这些内容组合起来。假设您有一个部署脚本,它会调用 curl 检查健康检查端点;如果服务不健康,则以错误退出。您希望在不使用真实服务器的情况下,同时测试正常路径和失败路径。
#!/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"模拟命令失败
存根最有价值的用途之一,是模拟使用真实工具难以复现的失败——网络超时、权限错误、磁盘空间已满,或远程 API 返回 500。
要模拟失败,只需让存根以非零代码退出即可。您还可以完全按照真实命令的方式写入 stderr,从而充分测试脚本的错误处理逻辑。
#!/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记录存根调用以进行验证
有时您不仅需要断言脚本输出了什么,还需要断言它如何调用外部工具——传递了哪些参数、调用了多少次,或调用顺序是什么。间谍存根会将调用记录到文件中。
测试结束后,测试工具会读取记录文件,并根据其内容进行断言。这样无需任何特殊框架,就能验证调用参数的具体内容。
#!/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"一次为多个命令创建存根
真实脚本通常会调用多个外部工具。您可以将它们全部存根化,并放在同一个 STUB_BIN 目录中。每个存根文件彼此独立,可以返回不同的输出和退出代码。
请让存根保持精简:只返回被测脚本实际解析的内容。不要尝试模拟每个选项,只需模拟脚本使用的那一部分即可。
#!/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"使用函数作为存根(无需文件)
对于简单情况,您完全不需要编写文件。您可以定义一个与命令同名的Shell 函数。由于函数的解析优先于外部 PATH 查找,因此它会自动获得优先级。
这是对通过源代码加载的脚本进行单元测试的最快方法。不过,函数存根只能在同一个 Shell 进程中工作——使用显式 bash -c 启动的子 Shell 或后台进程无法看到它们。对于这些情况,请使用基于文件的方法。
#!/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"将函数导出到子 Shell
当被测脚本启动子 Shell(例如 bash script.sh 或流水线)时,默认情况下,父 Shell 中定义的 Shell 函数存根不会被继承。您有两个选择:
- 使用
export -f function_name导出函数——它会在子bash进程中可用 - 或者改用基于文件的存根,将其放在
STUB_BIN目录中;这种方式始终可以跨进程边界工作
export -f 很简洁,但只能与 bash 配合使用(不能用于 sh 或其他 Shell)。在包含多种语言和工具的持续集成环境中,建议优先使用基于文件的存根。
#!/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)将存根集成到测试框架(BATS)中
使用 BATS(Bash 自动化测试系统)时,应在 setup() 钩子中完成存根设置,并在 teardown() 中执行清理。BATS 会在各次测试之间重置环境,因此每个测试都会获得全新的存根目录。
像 $BATS_TEST_TMPDIR 这样的 BATS 变量会自动为每个测试提供临时目录——请使用它代替 mktemp -d,这样代码更简洁。
#!/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 ]
}知识检查:在 Bash 中模拟命令
请检验您对 Bash 中命令模拟和存根模式的理解。
某位开发人员编写了一个测试,定义了名为 aws 的 Shell 函数,用来替代真实的 AWS CLI。测试直接在终端中运行正常,但当持续集成流水线以 bash deploy.sh 的方式运行被测脚本时,存根会被忽略,实际的 aws 命令反而被调用。
正确的修复方法是什么?
回顾:模拟命令与为外部工具创建存根
您已经学习了在 Bash 脚本测试期间,用受控的伪实现替代真实命令的完整工具集。
涵盖的核心技术:
- 将目录置于 PATH 前端——使用
mktemp -d创建STUB_BIN目录,在其中写入可执行的存根文件,然后将该目录置于PATH的最前面 - 存根退出代码——成功时返回
0,使用非零值模拟特定失败(网络错误、权限被拒绝等) - 间谍存根——将参数追加到存根内的日志文件中,以断言脚本如何调用外部工具
- 多个存根——将多个存根文件放在同一个
STUB_BIN中,一次模拟完整依赖生态中的所有依赖项 - 函数存根——定义与命令同名的 Shell 函数,以进行同进程模拟;使用
export -f访问子 bash 进程 - BATS 集成——使用
setup()/teardown()钩子和$BATS_TEST_TMPDIR,确保每个测试相互隔离
始终使用 trap '...' EXIT,无论测试结果如何,都能确保清理存根。请让存根保持精简——只返回脚本实际解析的内容。对于持续集成流水线,基于文件的存根是最具可移植性的选择。
常见问题解答
「模拟命令与替代外部工具」课时是免费的吗?
是的 — 「模拟命令与替代外部工具」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Linux Command Line & Bash Scripting Mastery 课程的其余内容,请升级到 CoddyKit PRO。 Linux Command Line & Bash Scripting Mastery 课程共包含 4 节课。
「模拟命令与替代外部工具」这节课中我会学到什么?
覆盖 PATH 并定义虚假二进制文件,在不接触真实系统的情况下测试脚本。 你通过在浏览器中直接运行的动手代码来练习 Linux Command Line & Bash Scripting Mastery,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 Linux Command Line & Bash Scripting Mastery 需要有经验吗?
无需任何先前经验。CoddyKit 上的 Linux Command Line & Bash Scripting Mastery 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 2 节课,共 4 节。
「模拟命令与替代外部工具」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 Linux Command Line & Bash Scripting Mastery 课中编写并运行代码吗?
能。每节 Linux Command Line & Bash Scripting Mastery 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。