测试固件、临时环境与覆盖率
构建隔离的测试固件,并衡量测试实际执行了哪些脚本分支。
测试固件、临时环境与覆盖率 是 CoddyKit 上的免费 DevOps Bootcamp 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 DevOps Bootcamp 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 DevOps Bootcamp 课程共包含 4 节课。
为什么测试夹具和隔离很重要
测试 Bash 脚本时,最大的风险是副作用:测试意外修改真实文件、真实数据库或真实系统状态。在您的机器上通过、却破坏生产数据的测试,还不如没有测试。
解决方案是测试夹具——模拟真实条件、却不会触碰任何真实资源的受控且可丢弃环境。优秀的测试夹具可以提供:
- 可复现性——每次运行测试都会产生相同结果
- 隔离性——测试之间以及测试与主机之间互不干扰
- 安全性——破坏性操作只会接触可随时丢弃的数据
- 速度——不进行网络调用,除非绝对必要,否则不执行繁重的输入输出操作
在 Bash 测试中,测试夹具通常是包含已知文件的临时目录、放置在 $PATH 前部的模拟可执行文件,以及作用域限定在测试进程内的环境变量。
创建和清理临时目录
为每个测试创建临时目录的标准模式是使用 mktemp -d。该命令会在 /tmp 中创建唯一目录并输出其路径。您可以保存该路径,并注册一个 trap,在 Shell 退出时(即使发生失败)自动删除该目录。
凡是会操作文件系统的每个测试文件,都应包含这个两行惯用写法:
#!/usr/bin/env bash
set -euo pipefail
# Create an isolated temp directory
TMPDIR=$(mktemp -d)
# Always clean up, even if the script exits early or errors out
trap 'rm -rf "$TMPDIR"' EXIT
echo "Working in: $TMPDIR"
# Simulate creating fixture files
mkdir -p "$TMPDIR/project/{src,tests,logs}"
echo 'version=1.2.3' > "$TMPDIR/project/.env"
echo 'Hello fixture' > "$TMPDIR/project/src/main.sh"
ls -R "$TMPDIR/project"
echo 'Temp dir will be removed automatically on exit'组织测试夹具目录树
结构良好的测试夹具会仿照被测脚本实际需要的目录布局。您可以把它看作一个微型伪项目根目录。测试夹具设置函数会在每次测试前创建该布局,而清理函数会将其删除。
关键实践:
- 使用测试框架在每次测试前调用的
setup()函数 - 使用在每次测试后运行的
teardown()或cleanup(),即使测试失败也要运行 - 让测试夹具文件保持精简——只保留脚本实际读取的内容
- 使用描述性名称命名测试夹具文件,以便轻松诊断失败原因
#!/usr/bin/env bash
# fixture_helpers.bash — source this from your test files
FIXTURE_ROOT=''
setup_fixture() {
FIXTURE_ROOT=$(mktemp -d)
# Build the directory tree the deploy script expects
mkdir -p "$FIXTURE_ROOT"/{dist,config,logs}
echo '{"version":"2.0"}' > "$FIXTURE_ROOT/config/app.json"
echo 'console.log("app")' > "$FIXTURE_ROOT/dist/index.js"
touch "$FIXTURE_ROOT/logs/.gitkeep"
export FIXTURE_ROOT
echo "[setup] Fixture ready at $FIXTURE_ROOT"
}
teardown_fixture() {
if [[ -n "$FIXTURE_ROOT" && -d "$FIXTURE_ROOT" ]]; then
rm -rf "$FIXTURE_ROOT"
echo '[teardown] Fixture removed'
fi
}
# Self-test
setup_fixture
ls "$FIXTURE_ROOT"
teardown_fixture使用伪造的 PATH 模拟可执行文件
许多 Bash 脚本会调用 curl、aws、docker 或 git 等外部工具。在测试中,您不希望访问真实服务,因此需要用伪造的可执行文件替代这些工具。
具体方法很简单:
- 在测试夹具中创建临时的
bin/目录 - 在其中写入与真实工具同名的小型 Shell 脚本
- 调用被测脚本前,将该目录置于
$PATH的最前面
由于 $PATH 会从左到右进行搜索,因此伪造工具会被优先找到,真实二进制文件不会被调用。
#!/usr/bin/env bash
set -euo pipefail
FIXTURE=$(mktemp -d)
trap 'rm -rf "$FIXTURE"' EXIT
# Create a fake 'curl' that records calls and returns canned data
FAKE_BIN="$FIXTURE/bin"
mkdir -p "$FAKE_BIN"
cat > "$FAKE_BIN/curl" << 'SCRIPT'
#!/usr/bin/env bash
# Log every argument for later inspection
echo "curl $*" >> "$FIXTURE_BIN_LOG"
# Return a canned HTTP 200 response body
echo '{"status":"ok","id":42}'
SCRIPT
chmod +x "$FAKE_BIN/curl"
# Export the log path so the fake can find it
export FIXTURE_BIN_LOG="$FIXTURE/curl_calls.log"
# Prepend fake bin directory to PATH
export PATH="$FAKE_BIN:$PATH"
# Now any call to 'curl' hits our fake
curl -s https://api.example.com/health
curl -X POST https://api.example.com/deploy
echo '--- Recorded curl calls ---'
cat "$FIXTURE_BIN_LOG"捕获并断言命令输出
只有能够断言脚本执行了什么操作,测试夹具才有用。常见模式包括:
- 使用
$()或进程替换,将标准输出和标准错误捕获到变量中 - 检查伪造二进制文件写入的日志文件
- 使用
$?或条件逻辑,明确检查退出代码 - 确认特定文件已创建、已修改,或保持未被触碰
编写简短、专注的断言辅助函数,可以让测试更易读,并在出现问题时提供精确的失败消息。
#!/usr/bin/env bash
set -euo pipefail
# Minimal assertion helpers
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then
echo "PASS: $desc"
else
echo "FAIL: $desc"
echo " expected: $expected"
echo " actual: $actual"
return 1
fi
}
assert_file_exists() {
local desc="$1" file="$2"
if [[ -f "$file" ]]; then
echo "PASS: $desc"
else
echo "FAIL: $desc — file not found: $file"
return 1
fi
}
assert_contains() {
local desc="$1" needle="$2" haystack="$3"
if [[ "$haystack" == *"$needle"* ]]; then
echo "PASS: $desc"
else
echo "FAIL: $desc — '$needle' not found in output"
return 1
fi
}
# Demo usage
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
echo 'hello world' > "$TMPDIR/greeting.txt"
OUT=$(cat "$TMPDIR/greeting.txt")
assert_eq 'file content matches' 'hello world' "$OUT"
assert_file_exists 'greeting file created' "$TMPDIR/greeting.txt"
assert_contains 'output has hello' 'hello' "$OUT"测试中的环境变量作用域
脚本经常读取 $HOME、$CONFIG_PATH 或 $DATABASE_URL 等环境变量。在测试中,您必须覆盖这些变量,同时不污染真实环境。
最安全的方法是在子 Shell 中运行被测脚本,只设置您明确需要的变量。env 命令可以让您清除现有环境,然后只重新添加所需内容:
env -i VAR=val ./script.sh——完全干净的环境(export VAR=val; ./script.sh)——子 Shell 继承父环境,并叠加您的覆盖值
使用子 Shell 还意味着,如果脚本修改了 $IFS、$PWD 或其他全局状态,这些修改也不会泄漏回测试运行器。
#!/usr/bin/env bash
set -euo pipefail
FIXTURE=$(mktemp -d)
trap 'rm -rf "$FIXTURE"' EXIT
# Write a tiny script under test that reads env vars
cat > "$FIXTURE/deploy.sh" << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
ENV_NAME=${DEPLOY_ENV:-unknown}
CFG=${CONFIG_DIR:-/etc/app}
echo "Deploying to $ENV_NAME using config from $CFG"
SCRIPT
chmod +x "$FIXTURE/deploy.sh"
echo '--- Test 1: staging env ---'
# Run in a clean subshell with explicit vars
(
export DEPLOY_ENV=staging
export CONFIG_DIR="$FIXTURE/config"
"$FIXTURE/deploy.sh"
)
echo '--- Test 2: production env ---'
(
export DEPLOY_ENV=production
export CONFIG_DIR=/etc/prod-config
"$FIXTURE/deploy.sh"
)
echo '--- Test 3: defaults ---'
# No env vars set — script should use its own defaults
env -i PATH="$PATH" "$FIXTURE/deploy.sh"Bash 覆盖率简介:kcov
覆盖率回答了这样一个问题:测试实际执行了脚本中的哪些行(以及哪些分支)?较高的覆盖率数值并不能保证正确性,但较低的覆盖率会暴露未经测试、很可能藏有错误的路径。
用于 Bash 覆盖率分析的主要工具是 kcov。它在操作系统层面使用 PTRACE(Linux)或 dtrace(macOS)对脚本进行插桩,因此无需修改源代码。它会生成 HTML 报告,用红色表示未覆盖的行,用绿色表示已覆盖的行。
基本用法:
kcov --include-path=./src coverage-out/ ./src/myscript.sh- 在浏览器中打开
coverage-out/index.html,查看结果 - 在持续集成中,解析
coverage-out/myscript.sh/coverage.json,获取机器可读的百分比
注意:必须单独安装 kcov(在 macOS 上使用 brew install kcov,在 Ubuntu 20.04 及更高版本上使用 apt install kcov)。
对真实脚本运行 kcov
下面是一个端到端示例,展示可部署的脚本、用于执行该脚本的测试,以及用于测量覆盖率的 kcov 调用。请注意,输出目录按测试分别设置,这样稍后可以合并多次测试运行的结果。
#!/usr/bin/env bash
# This demo shows the *structure* of a kcov workflow.
# It will not run kcov itself (not guaranteed to be installed),
# but the script under test and test runner are fully runnable.
set -euo pipefail
FIXTURE=$(mktemp -d)
trap 'rm -rf "$FIXTURE"' EXIT
# 1. Script under test
cat > "$FIXTURE/process.sh" << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
INPUT="$1"
if [[ ! -f "$INPUT" ]]; then
echo "ERROR: file not found" >&2
exit 1
fi
LINE_COUNT=$(wc -l < "$INPUT")
if (( LINE_COUNT == 0 )); then
echo "WARNING: file is empty"
else
echo "Processed $LINE_COUNT lines"
fi
SCRIPT
chmod +x "$FIXTURE/process.sh"
# 2. Happy path test (exercises line 6 and 11)
echo -e 'one\ntwo\nthree' > "$FIXTURE/data.txt"
OUT=$("$FIXTURE/process.sh" "$FIXTURE/data.txt")
echo "Happy path: $OUT"
# 3. Empty file test (exercises line 9)
touch "$FIXTURE/empty.txt"
OUT=$("$FIXTURE/process.sh" "$FIXTURE/empty.txt")
echo "Empty file: $OUT"
# 4. Missing file test (exercises line 5-6)
if ! OUT=$("$FIXTURE/process.sh" "$FIXTURE/missing.txt" 2>&1); then
echo "Missing file (expected error): $OUT"
fi
# To measure coverage, wrap each call with kcov:
# kcov --include-path="$FIXTURE" "$FIXTURE/cov/happy" "$FIXTURE/process.sh" ...
# kcov --merge "$FIXTURE/cov/all" "$FIXTURE/cov/happy" "$FIXTURE/cov/empty"合并多次测试运行的覆盖率
单个测试很少能覆盖所有分支。您需要运行多个测试——每个测试使用各自的覆盖率输出目录——然后将它们合并。kcov 的 --merge 选项会将多次运行合并为一份统一报告。
持续集成流水线中的典型模式:
- 运行测试 A → 输出到
cov/test_a/ - 运行测试 B → 输出到
cov/test_b/ - 合并 →
kcov --merge cov/all/ cov/test_a/ cov/test_b/ - 解析
cov/all/<script>/coverage.json,获取最终百分比
您还可以强制设置最低阈值;如果覆盖率低于该阈值,就让持续集成构建失败:
#!/usr/bin/env bash
# extract_coverage.sh — parse kcov JSON and fail below threshold
set -euo pipefail
MIN_COVERAGE=80 # percent
COV_JSON="${1:-coverage-out/myscript.sh/coverage.json}"
if [[ ! -f "$COV_JSON" ]]; then
echo "ERROR: coverage JSON not found at $COV_JSON" >&2
exit 1
fi
# kcov JSON contains a key like: "percent_covered": "87.50"
PERCENT=$(grep -oP '"percent_covered":\s*"\K[0-9.]+' "$COV_JSON")
PERCENT_INT=${PERCENT%%.*} # truncate decimal
echo "Coverage: ${PERCENT}% (minimum: ${MIN_COVERAGE}%)"
if (( PERCENT_INT < MIN_COVERAGE )); then
echo "FAIL: coverage ${PERCENT}% is below threshold ${MIN_COVERAGE}%" >&2
exit 1
fi
echo "PASS: coverage threshold met"分支覆盖率与行覆盖率
您会遇到两种主要的覆盖率指标:
- 行覆盖率——这一行是否曾被执行?这种指标很容易被“刷高”:单个测试可以触及许多行,却遗漏重要的条件路径。
- 分支覆盖率——每个
if、case以及&&/||的每个分支是否都被执行?这是更有力的指标,要求针对每个判断的真和假两种情况编写测试。
kcov 会同时报告这两种指标。关键认识是:100% 的行覆盖率并不意味着 100% 的分支覆盖率。请看下面这个脚本——使用非空文件进行一次测试可以覆盖每一行,但空文件分支(下面的第 9 行)始终不会被执行:
#!/usr/bin/env bash
# Illustrates line vs branch coverage gap
set -euo pipefail
check_file() {
local f="$1"
if [[ -f "$f" ]]; then # branch A (true) OR branch B (false)
local lines
lines=$(wc -l < "$f")
if (( lines > 0 )); then # branch C (true) OR branch D (false)
echo "File has $lines lines"
else
echo "File is empty" # branch D — unreached if only tested with non-empty file
fi
else
echo "File missing" # branch B — unreached if only tested with existing file
fi
}
# Only one test: covers lines 5-10 (4 of 6 branches)
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
echo 'data' > "$TMPDIR/sample.txt"
check_file "$TMPDIR/sample.txt"
# To reach 100% branch coverage you also need:
# check_file "/nonexistent/path"
# check_file "$TMPDIR/empty.txt" (after: touch "$TMPDIR/empty.txt")在持续集成中整合固定装置与覆盖率
将所有内容整合起来:适用于 Bash 项目的稳健持续集成流程会在一个脚本中完成固定装置设置、在 kcov 下执行测试、合并结果以及阈值检查。这个脚本会成为持续集成的入口——只需一条命令即可运行全部流程。
持续集成测试运行器的设计原则:
- 每个测试用例都会调用
setup_fixture,并通过trap注册teardown_fixture - 使用伪造的
$PATH和限定作用域的环境变量调用待测脚本 - kcov 会包装每次调用,并将结果写入按编号命名的子目录
- 所有测试完成后,kcov 会合并结果,阈值脚本则负责决定构建是否通过
- 如果任何测试或覆盖率检查失败,持续集成运行器就会以非零状态退出
#!/usr/bin/env bash
# ci_runner.sh — full fixture + coverage pipeline entry point
set -euo pipefail
SRC="./src/deploy.sh"
COV_ROOT="$(mktemp -d)/coverage"
trap 'rm -rf "$COV_ROOT"' EXIT
mkdir -p "$COV_ROOT"
PASS=0
FAIL=0
RUN_NUM=0
run_test() {
local name="$1" test_fn="$2"
local fixture
fixture=$(mktemp -d)
local cov_out="$COV_ROOT/run_$((++RUN_NUM))"
if (
trap 'rm -rf "$fixture"' EXIT
export FIXTURE="$fixture"
# Fake bin directory shadowing real tools
mkdir -p "$fixture/bin"
export PATH="$fixture/bin:$PATH"
"$test_fn" "$fixture"
); then
echo "PASS: $name"
(( PASS++ )) || true
else
echo "FAIL: $name"
(( FAIL++ )) || true
fi
}
# Example test function
test_happy_path() {
local fx="$1"
mkdir -p "$fx/dist"
echo 'app.js' > "$fx/dist/index.js"
# Would normally run: kcov "$cov_out" "$SRC" --env=staging "$fx"
echo "[test] happy path executed in $fx"
}
run_test 'happy_path' test_happy_path
echo "Results: $PASS passed, $FAIL failed"
(( FAIL == 0 ))知识检查:伪造 PATH 技巧
测试您对 Bash 测试固定装置中所用伪造 PATH 技巧的理解。
回顾:固定装置、临时环境与覆盖率
本课介绍了构建可靠、隔离的 Bash 测试所需的完整工具集:
- 临时目录——
mktemp -d加上trap ... EXIT,无论测试以何种方式结束,都能保证自动清理。 - 固定装置结构——
setup_fixture/teardown_fixture配对使用,创建并销毁一个模拟真实脚本输入的最小目录树。 - 伪造 PATH——将桩可执行文件放入
$FIXTURE/bin/,并将其置于$PATH前面,就可以拦截对curl、aws、docker或任何外部工具的调用,而不会接触系统二进制文件。 - 环境作用域——在子 Shell(
()或env -i)中运行待测脚本,这样修改过的变量就不会泄漏回测试运行器。 - 断言——小型辅助函数(
assert_eq、assert_file_exists、assert_contains)会生成清晰的通过/失败输出和有意义的错误消息。 - kcov 覆盖率——无需修改源代码即可包装脚本执行,并生成 HTML 和 JSON 格式的行覆盖率与分支覆盖率报告。
- 合并与阈值——使用
--merge合并多次 kcov 运行结果,解析 JSON,并在覆盖率低于最低要求时使持续集成失败。 - 分支覆盖率与行覆盖率——应始终以分支覆盖率为目标;仅依靠行覆盖率可能遗漏完整的条件路径,从而造成虚假的信心。
采用这些技巧后,您的 Bash 测试会像任何编译型语言的测试一样严谨。
常见问题解答
「测试固件、临时环境与覆盖率」课时是免费的吗?
是的 — 「测试固件、临时环境与覆盖率」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 DevOps Bootcamp 课程的其余内容,请升级到 CoddyKit PRO。 DevOps Bootcamp 课程共包含 4 节课。
「测试固件、临时环境与覆盖率」这节课中我会学到什么?
构建隔离的测试固件,并衡量测试实际执行了哪些脚本分支。 你通过在浏览器中直接运行的动手代码来练习 DevOps Bootcamp,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 DevOps Bootcamp 需要有经验吗?
无需任何先前经验。CoddyKit 上的 DevOps Bootcamp 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。
「测试固件、临时环境与覆盖率」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 DevOps Bootcamp 课中编写并运行代码吗?
能。每节 DevOps Bootcamp 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- 使用 Bats-core 对函数进行单元测试
- 模拟命令与替代外部工具
- 测试固件、临时环境与覆盖率
- 在 CI 管道中运行 Shell 测试