Linux 基础体系 · 第 45/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。
Shell 脚本测试与质量:ShellCheck、Bats、Fixture 和故障注入
Shell 脚本通常没有编译阶段,很多错误会在生产输入、特定目录、异常信号或外部命令失败时才暴露。仅仅“手工执行一次成功路径”不能证明脚本可靠。
一套较完整的 Shell 质量体系至少包含四层:
- ShellCheck:在执行前发现语法、引用、条件判断和 Shell 语义问题。
- Bats:以测试用例形式启动脚本,验证退出码、标准输出、标准错误和文件系统状态。
- Fixture:为测试准备可重复的输入、目录、配置、假命令和期望结果。
- 故障注入:主动让依赖命令、信号、输入或资源失败,验证脚本的错误路径和清理逻辑。
这四者解决的问题不同。ShellCheck 不能证明业务逻辑正确;Bats 不能自动发现所有 Shell 语义陷阱;Fixture 决定测试是否可重复;故障注入则决定测试是否真正覆盖了失败路径。
一、先明确被测试对象:退出码、输出和副作用
一个 Shell 程序的可观察行为通常包括:
- 进程退出码;
- 标准输出;
- 标准错误;
- 创建、修改或删除的文件;
- 对外部命令的调用;
- 收到信号时的行为;
- 失败后是否留下临时文件或部分结果。
可以把脚本看成一个函数:
其中:
- 是命令行参数或标准输入;
- 是环境变量和
PATH等执行环境; - 是初始文件系统状态;
- 是外部命令、信号和资源状态;
- 是退出码;
- 是标准输出;
- 是标准错误;
- 是脚本结束后的文件系统状态。
测试不是只断言“命令返回 0”,而是验证:
例如,一个“原子复制”脚本在复制失败时,合理的要求可能是:
- 返回非零退出码;
- 错误写入标准错误,而不是标准输出;
- 目标文件不应被替换;
- 临时文件应被删除;
- 原目标文件内容应保持不变。
如果测试只检查退出码,就无法发现临时文件泄漏或目标文件被破坏。
二、ShellCheck:静态分析不是格式化工具
2.1 ShellCheck 检查什么
ShellCheck 是 Shell 的静态分析器。它不运行脚本,而是解析脚本结构并报告常见问题,例如:
- 变量未加引号导致词分割或通配符展开;
[、[[和=的使用方式不正确;cd失败后脚本仍在错误目录继续运行;rm -rf $dir可能因空格或通配符造成错误路径;local x=$(cmd)丢失命令替换的退出码;echo处理转义和以-开头的内容不可靠;set -e、管道和条件语句的语义容易被误解;- 函数、变量或命令替换存在未引用风险。
安装方式取决于发行版。Debian、Ubuntu 等发行版通常提供 shellcheck 软件包:
sudo apt install shellcheck
也可以使用发行版提供的其他包管理器安装。安装后检查版本:
shellcheck --version
对 Bash 脚本执行检查:
shellcheck -s bash script.sh
如果脚本使用了 source 引入的本地文件,可以尝试:
shellcheck -x -s bash script.sh
-x 允许 ShellCheck 跟随部分可解析的 source 文件。它不是任意动态路径的运行时追踪器,因此不能保证所有被加载文件都能被分析。
2.2 一个典型的引用错误
下面的脚本看起来简单,但存在路径问题:
#!/usr/bin/env bash
dir="$1"
rm -rf $dir
当参数为:
/tmp/a b
Shell 会把 $dir 展开成两个参数:
rm -rf /tmp/a b
当目录中有文件名匹配 * 时,还会发生额外的通配符展开。正确写法通常是:
#!/usr/bin/env bash
dir="$1"
rm -rf -- "$dir"
这里有两个独立的保护:
"$dir"防止空格、换行和通配符产生额外解析;--告诉支持该约定的命令,后续参数不是选项。
但这仍然不能证明删除操作安全。若 dir 为空、指向根目录或指向错误位置,逻辑层面的风险依旧存在。因此静态检查只能消除一类语义风险,不能替代参数校验和测试。
2.3 ShellCheck 的报告应如何处理
ShellCheck 的诊断编号是可引用的,例如:
# shellcheck disable=SC2155
local value=$(command)
不应为了让 CI 变绿而大面积禁用检查。应该先理解诊断对应的语义。
例如:
local value=$(false)
printf '%s\n' "$?"
很多人预期这里打印 1,但 local 是一个命令,函数或当前命令的退出状态可能由 local 决定,而不是由命令替换中的 false 决定。更清晰的写法是:
local value
value=$(false)
printf '%s\n' "$?"
如果需要保留错误并终止:
local value
if ! value=$(some_command); then
printf '%s\n' 'some_command failed' >&2
exit 1
fi
这里的关键不是“符合某个风格”,而是显式区分:
- 声明变量;
- 执行命令替换;
- 检查命令替换的退出码。
2.4 ShellCheck 不能发现什么
下面的脚本在语法上可能完全没有问题:
#!/usr/bin/env bash
if [[ -f "$1" ]]; then
cp -- "$1" "$2"
fi
但它可能存在业务错误:
- 输入文件不存在时仍然返回 0;
cp失败时没有报告;- 目标文件可能已被部分覆盖;
- 没有验证目标内容;
- 没有处理收到
TERM时的临时资源。
静态分析无法知道“文件不存在时应该返回 2 还是 1”,也无法知道目标文件内容是否符合业务要求。这些必须通过测试和契约定义。
三、Bats:以 Shell 进程边界验证行为
Bats,即 Bash Automated Testing System,是用 Bash 编写测试用例的测试框架。Bats 的价值在于:测试脚本通常以独立进程运行,这更接近真实调用方式。
一个 Bats 测试通常包含:
@test "描述":定义测试;run command args...:执行命令并捕获结果;$status:被测命令的退出码;$output:标准输出和标准错误的合并文本;setup:每个测试前执行;teardown:每个测试后执行。
run 不会因为被测命令返回非零而直接终止测试,因此可以检查失败场景。
3.1 被测脚本:安全地生成目标文件
创建项目目录:
mkdir -p shell-quality-demo/{bin,test}
保存为 shell-quality-demo/bin/atomic-copy:
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ $# -ne 2 ]]; then
printf 'usage: %s SOURCE DEST\n' "$0" >&2
exit 64
fi
src=$1
dst=$2
tmp="${dst}.tmp.$$"
cleanup() {
rm -f -- "$tmp"
}
on_signal() {
cleanup
exit 143
}
trap cleanup EXIT
trap on_signal INT TERM
if [[ ! -f "$src" ]]; then
printf 'source does not exist: %s\n' "$src" >&2
exit 2
fi
if cp -- "$src" "$tmp"; then
:
else
rc=$?
printf 'copy failed: %s\n' "$src" >&2
exit "$((rc == 0 ? 3 : 3))"
fi
if mv -f -- "$tmp" "$dst"; then
:
else
printf 'replace failed: %s\n' "$dst" >&2
exit 4
fi
trap - EXIT INT TERM
exit 0
赋予执行权限:
chmod +x shell-quality-demo/bin/atomic-copy
这个示例有几个需要明确的机制:
- 临时文件位于目标文件同一目录,避免跨文件系统移动。
cp先写临时文件,成功后再由mv替换目标。mv在同一文件系统内通常对应rename语义,替换动作不会暴露一个“只写了一半”的目标文件。EXIT陷阱负责普通退出时清理临时文件。INT和TERM陷阱先清理,再以143退出。143通常表示128 + 15,其中 15 是SIGTERM;这是一种约定,不是所有程序都必须采用的唯一编码。SIGKILL无法被捕获,进程被kill -9杀死时清理函数不会运行,临时文件仍可能残留。
示例中 cp 失败时统一返回 3。else 分支中的 rc 只是展示如何读取失败状态;如果业务需要透传原始错误码,可以直接 exit "$rc"。这里没有写成 if ! cp ...,因为在 then 或 else 中使用 ! 后,直接读取的状态是取反后的结果,容易误把原始退出码丢掉。
此外,exit "$((rc == 0 ? 3 : 3))" 在逻辑上等价于 exit 3,只是为了强调该脚本选择了“统一映射为 3”。更简洁、可读性更高的生产写法是:
if cp -- "$src" "$tmp"; then
:
else
printf 'copy failed: %s\n' "$src" >&2
exit 3
fi
3.2 Bats 测试:成功、输入错误和副作用
保存为 shell-quality-demo/test/atomic-copy.bats:
#!/usr/bin/env bats
setup() {
root="$BATS_TEST_DIRNAME/.."
tmpdir="$(mktemp -d)"
}
teardown() {
rm -rf -- "$tmpdir"
}
@test "成功复制并替换目标文件" {
printf 'new content\n' >"$tmpdir/source"
printf 'old content\n' >"$tmpdir/dest"
run "$root/bin/atomic-copy" "$tmpdir/source" "$tmpdir/dest"
[ "$status" -eq 0 ]
[ "$output" = "" ]
[ "$(cat "$tmpdir/dest")" = "new content" ]
[ ! -e "$tmpdir/dest.tmp.$$" ]
}
@test "源文件不存在时返回 2,并且不创建目标文件" {
run "$root/bin/atomic-copy" \
"$tmpdir/missing" \
"$tmpdir/dest"
[ "$status" -eq 2 ]
[[ "$output" == *"source does not exist"* ]]
[ ! -e "$tmpdir/dest" ]
}
@test "参数数量错误时返回 64" {
run "$root/bin/atomic-copy"
[ "$status" -eq 64 ]
[[ "$output" == *"usage:"* ]]
}
运行测试:
cd shell-quality-demo
bats test
预期结果类似:
1..3
ok 1 成功复制并替换目标文件
ok 2 源文件不存在时返回 2,并且不创建目标文件
ok 3 参数数量错误时返回 64
这里使用 mktemp -d 创建每个测试独立的临时目录,而不是固定使用 /tmp/test。固定目录会导致:
- 测试之间互相污染;
- 并行测试互相覆盖;
- 上一次异常退出留下的数据影响下一次结果;
- 普通用户和 CI 用户权限不同。
teardown 必须尽量执行清理,但它也不是绝对保证。测试进程被 SIGKILL 杀死、机器断电或文件系统不可用时,清理仍可能失败。因此临时目录应使用难以碰撞的名称,并避免在测试中操作真实生产路径。
四、Fixture:让测试输入和环境可重复
Fixture 不是某个特定命令,而是“测试所依赖的固定或可控状态”。
在 Shell 测试中,常见 Fixture 包括:
- 固定文本文件;
- 空文件、超大文件和包含特殊字符的文件名;
- 临时目录;
- 配置文件;
- 预置的目标文件;
- 假的外部命令;
- 特定环境变量;
- 固定的
PATH、语言和时区; - 模拟服务响应的文件或 Unix socket。
4.1 为什么 Fixture 不能只使用“当前目录”
下面这种测试容易产生隐藏依赖:
run ./script.sh input.txt output.txt
它依赖:
- 测试从哪个工作目录启动;
- 当前目录是否存在
input.txt; - 当前用户是否有权限;
- 当前环境中的
PATH和 locale; - 之前的测试是否创建过
output.txt。
更稳定的做法是使用绝对路径和测试专属目录:
input="$tmpdir/input.txt"
output="$tmpdir/output.txt"
printf 'fixture\n' >"$input"
run "$root/bin/atomic-copy" "$input" "$output"
4.2 文件名本身也应作为 Fixture
Shell 的错误经常由特殊文件名触发。可以测试:
special="$tmpdir/file with spaces
and-newline"
printf 'data\n' >"$special"
run "$root/bin/atomic-copy" "$special" "$tmpdir/result"
[ "$status" -eq 0 ]
[ "$(cat "$tmpdir/result")" = "data" ]
这类测试可以暴露未加引号的变量、错误的 for file in $(...) 和依赖换行分隔的解析逻辑。
如果需要遍历文件,通常应优先使用 Shell 能正确保留路径边界的方式,例如:
while IFS= read -r -d '' file; do
printf '%s\n' "$file"
done < <(find "$dir" -type f -print0)
但这段代码的前提是 find 支持 -print0,并且消费端确实使用 NUL 分隔。跨平台脚本不能把 GNU 扩展当成 POSIX 保证,发行版和工具实现差异需要在支持范围中明确。
五、故障注入:主动验证失败路径
故障注入是人为制造异常,使测试进入平时不容易到达的分支。常见注入点包括:
- 外部命令返回非零;
- 外部命令输出格式错误;
- 文件权限不足;
- 目录不存在或不可写;
- 命令执行超时;
- 进程收到
INT、TERM; - 依赖命令不存在;
- 磁盘空间或 inode 不足;
- 目标文件在执行过程中被并发修改。
最容易、最安全、最具确定性的方式是注入一个同名假命令。
5.1 注入 cp 失败
在测试文件中增加:
@test "cp 失败时返回 3,且清理临时文件" {
fakebin="$tmpdir/fakebin"
mkdir -p "$fakebin"
cat >"$fakebin/cp" <<'EOF'
#!/usr/bin/env bash
printf 'injected cp failure\n' >&2
exit 17
EOF
chmod +x "$fakebin/cp"
printf 'source\n' >"$tmpdir/source"
run env PATH="$fakebin:$PATH" \
"$root/bin/atomic-copy" \
"$tmpdir/source" \
"$tmpdir/dest"
[ "$status" -eq 3 ]
[[ "$output" == *"copy failed"* ]]
[ ! -e "$tmpdir/dest" ]
[ ! -e "$tmpdir/dest.tmp.$$" ]
}
执行过程如下:
env PATH=...只修改这次子进程的环境;- 脚本执行
cp时,Shell 先在fakebin中找到假命令; - 假命令返回 17;
- 脚本将该失败映射为 3;
exit 3触发EXITtrap;cleanup删除临时文件;- Bats 检查退出码、错误文本和文件系统状态。
这比修改系统中的 /bin/cp 安全得多,也比依赖真实磁盘故障更可重复。
5.2 注入 mv 失败
@test "mv 失败时返回 4,且原目标内容不变" {
fakebin="$tmpdir/fakebin"
mkdir -p "$fakebin"
cat >"$fakebin/mv" <<'EOF'
#!/usr/bin/env bash
printf 'injected mv failure\n' >&2
exit 18
EOF
chmod +x "$fakebin/mv"
printf 'new\n' >"$tmpdir/source"
printf 'old\n' >"$tmpdir/dest"
run env PATH="$fakebin:$PATH" \
"$root/bin/atomic-copy" \
"$tmpdir/source" \
"$tmpdir/dest"
[ "$status" -eq 4 ]
[ "$(cat "$tmpdir/dest")" = "old" ]
[ ! -e "$tmpdir/dest.tmp.$$" ]
}
这个用例验证了一个重要的不变量:
如果脚本先直接 cp source dest,该性质通常无法保证,因为复制过程中可能先截断目标文件,随后才写入新内容。
5.3 信号故障注入和 Trap
测试信号时,不能只启动命令并立即发送信号,因为进程可能还没有进入预期阶段。一个简单但存在时序不确定性的测试如下:
@test "收到 TERM 时删除临时文件" {
fakebin="$tmpdir/fakebin"
mkdir -p "$fakebin"
cat >"$fakebin/cp" <<'EOF'
#!/usr/bin/env bash
sleep 30
EOF
chmod +x "$fakebin/cp"
printf 'source\n' >"$tmpdir/source"
"$root/bin/atomic-copy" \
"$tmpdir/source" \
"$tmpdir/dest" \
>"$tmpdir/stdout" \
2>"$tmpdir/stderr" &
pid=$!
for _ in {1..50}; do
if [[ -e "$tmpdir/dest.tmp.$pid" ]]; then
break
fi
sleep 0.02
done
kill -TERM "$pid"
wait "$pid" || status=$?
[ "${status:-0}" -eq 143 ]
[ ! -e "$tmpdir/dest.tmp.$pid" ]
}
这里的 tmp 名称使用 $$,它是脚本进程的 PID;后台启动后,测试中的 $pid 应与脚本进程 PID 对应。这个测试仍然可能受调度影响,因此通过轮询等待临时文件出现,避免固定 sleep 1 带来的不稳定。
但还要注意两个边界:
- 若信号发送给的是外层 Shell,而外层 Shell又启动了子进程,信号是否传递给子进程取决于进程关系和信号发送方式;
SIGKILL不可捕获,不能期望trap在kill -9后运行。
如果脚本需要处理长时间运行的子进程,还应测试父进程退出后子进程是否残留。单纯设置 trap 并不能自动解决进程组管理问题。
六、set -euo pipefail 不是错误处理框架
很多脚本以如下选项开始:
set -Eeuo pipefail
各选项的含义不同:
-e:某些未被条件位置吸收的简单命令失败时退出;-u:读取未设置变量时通常报错;pipefail:管道退出码取最后一个失败命令,而不是只取最后一条命令;-E:让ERRtrap 在函数、命令替换等上下文中继承,具体行为仍受 Bash 语法上下文影响。
例如:
set -e
false
printf 'unreachable\n'
通常不会执行第二行。但下面的失败不会以同样方式立即终止:
if false; then
printf 'then\n'
fi
false || printf 'handled\n'
这是因为命令位于条件或逻辑列表中。set -e 的规则依赖语法位置,不是“任意非零都立即退出”。
管道也有明显差异:
false | true
printf '%s\n' "$?"
没有 pipefail 时,管道状态通常是最后一个命令 true 的状态,即 0。启用后:
set -o pipefail
false | true
printf '%s\n' "$?"
管道状态会反映失败的 false。
因此,关键失败必须显式检查:
if ! output=$(some_command); then
printf 'some_command failed\n' >&2
exit 1
fi
测试也应覆盖“命令失败发生在条件、管道、命令替换和函数调用中”的不同位置,而不能因为脚本启用了 set -e 就认为所有错误都会传播。
七、退出码设计和错误传播
Shell 退出码是 0 到 255 的无符号字节范围。约定上:
0表示成功;1常用于一般错误;2常用于命令行用法或参数错误,但具体程序可自定义;64常见于 BSDsysexits约定中的参数用法错误;128 + N常用于表示被信号N终止。
这些是常见约定,不是所有脚本都必须遵循的统一规范。重要的是同一个脚本要保持一致,并让调用方能够区分错误类别。
例如:
if [[ $# -ne 2 ]]; then
exit 64
fi
if [[ ! -f "$src" ]]; then
exit 2
fi
if ! cp -- "$src" "$tmp"; then
exit 3
fi
测试应针对每个错误类别断言,而不是只断言“非零”:
[ "$status" -eq 64 ]
如果调用方只关心成功或失败,使用:
if "$script" ...; then
...
else
...
fi
如果调用方需要诊断原因,则必须定义退出码契约,并在文档、测试和监控中保持一致。
八、Fixture 和故障注入的生产边界
测试权限错误时,不要直接对真实系统目录执行操作。可以在临时目录中构造不可写目录:
readonly_dir="$tmpdir/readonly"
mkdir "$readonly_dir"
chmod 500 "$readonly_dir"
但这种测试在 root 用户下可能失效,因为 root 通常绕过普通权限限制。CI 不应假设自己总是普通用户,也不应假设一定是 root。更可靠的方式是:
- 使用专门的非特权测试用户;
- 将“权限错误”作为环境前置条件记录;
- 对 root 环境跳过不适用的测试,而不是错误地宣称测试通过。
磁盘空间和 inode 耗尽也不适合随意在宿主机上注入。可使用受控的容器、虚拟磁盘或专用文件系统,并在测试结束后卸载和清理。否则可能影响同一主机上的其他服务。
网络故障、服务超时可以使用本地 mock 服务或受控防火墙规则模拟,但必须限制作用范围。生产环境故障演练应有变更审批、监控、回滚和恢复验证,不能把测试脚本直接指向真实数据库、块设备或生产挂载点。
九、测试矩阵:从路径覆盖转向性质覆盖
一个“复制文件”的测试矩阵可以按输入、依赖和信号组合设计:
| 场景 | 预期退出码 | 目标文件 | 临时文件 |
|---|---|---|---|
| 参数不足 | 64 | 不创建 | 不存在 |
| 源文件不存在 | 2 | 不创建 | 不存在 |
cp 失败 |
3 | 不创建或保持原值 | 删除 |
mv 失败 |
4 | 保持原值 | 删除 |
| 正常执行 | 0 | 等于源文件 | 不存在 |
收到 TERM |
143 | 不应产生新结果 | 删除 |
SIGKILL |
非脚本可控 | 可能保持原值 | 可能残留 |
最后一行尤其重要:SIGKILL 后临时文件残留不是 trap 能解决的问题。若生产要求处理这种情况,应增加启动时的过期临时文件清理、使用唯一命名、记录所有者和时间戳,或者改用更适合的事务性存储机制。不能把“有 EXIT trap”误认为“任何异常都能清理”。
测试的核心不是把每一行执行一次,而是验证性质:
- 成功时目标内容正确;
- 失败时错误不会伪装成成功;
- 替换失败时原目标不被破坏;
- 可捕获信号不会留下临时文件;
- 特殊路径名不会改变参数边界;
- 外部命令失败后错误码按契约传播。
十、把 ShellCheck 和 Bats 接入持续集成
最小 CI 步骤可以是:
shellcheck -s bash bin/atomic-copy
bats test
如果项目中有多个目录:
find . -type f -name '*.sh' -print0 |
xargs -0 shellcheck -s bash
bats test
使用 find 和 xargs -0 是为了避免文件名包含空格时被错误拆分。若项目包含没有 .sh 后缀但实际是 Shell 的可执行文件,也应通过目录约定或 shebang 扫描纳入检查。
CI 环境还应固定或记录:
- Bash 版本;
- Bats 版本;
- ShellCheck 版本;
- 运行用户;
- locale;
- 时区;
- 是否启用并行测试;
- 是否存在 GNU 与 BusyBox 工具差异。
例如,脚本使用 sed -i、date -d、find -printf 等能力时,不能仅凭“现代 Linux”就认为所有发行版实现完全一致。Debian、Ubuntu、Fedora、Alpine 的用户空间工具来源和默认行为可能不同;应明确支持的 Shell 和工具实现,或提供兼容代码。
十一、诊断失败测试的顺序
当 Bats 测试失败时,先区分四类问题:
- 脚本退出码错误:检查错误传播、
set -e上下文和trap。 - 输出错误:区分标准输出与标准错误;
run捕获的$output通常是合并结果。 - Fixture 错误:确认路径、权限、环境变量和初始文件内容。
- 副作用错误:用
find,stat,ls -la检查临时文件、目标文件和文件权限。
手工复现时可以打开 Bash 跟踪:
PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
bash -x ./bin/atomic-copy source dest
但不要把包含密码、令牌或敏感路径的 set -x 输出写入生产日志。对于需要长期保留的诊断,更适合在脚本中显式输出稳定、无敏感信息的错误消息。
ShellCheck、Bats、Fixture 和故障注入分别覆盖静态语义、进程行为、可重复状态和失败路径。只有把四者连接起来,测试才会从“命令运行过”提升为“成功、失败、清理和副作用都符合契约”。
系列导航与关联阅读
- 系列入口:Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:Bash 函数与 Trap:退出码、错误传播、临时资源和信号清理
- 下一篇:命令行结构化数据:jq、yq、JSON、YAML、流式处理和安全更新
- 延伸:Linux 生产运行手册:容量、变更、监控、应急和复盘
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论