0Pricing
DevOps Bootcamp · 课时

测试固件、临时环境与覆盖率

构建隔离的测试固件,并衡量测试实际执行了哪些脚本分支。

测试固件、临时环境与覆盖率 是 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 等外部工具。在测试中,您不希望访问真实服务,因此需要用伪造的可执行文件替代这些工具。

具体方法很简单:

  1. 在测试夹具中创建临时的 bin/ 目录
  2. 在其中写入与真实工具同名的小型 Shell 脚本
  3. 调用被测脚本前,将该目录置于 $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 反馈 — 无需本地设置。

此课程中的所有课时

  1. 使用 Bats-core 对函数进行单元测试
  2. 模拟命令与替代外部工具
  3. 测试固件、临时环境与覆盖率
  4. 在 CI 管道中运行 Shell 测试
← 返回 DevOps Bootcamp