Linux 基础体系 · 第 45/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Shell 脚本测试与质量:ShellCheck、Bats、Fixture 和故障注入

Shell 脚本通常没有编译阶段,很多错误会在生产输入、特定目录、异常信号或外部命令失败时才暴露。仅仅“手工执行一次成功路径”不能证明脚本可靠。

一套较完整的 Shell 质量体系至少包含四层:

  1. ShellCheck:在执行前发现语法、引用、条件判断和 Shell 语义问题。
  2. Bats:以测试用例形式启动脚本,验证退出码、标准输出、标准错误和文件系统状态。
  3. Fixture:为测试准备可重复的输入、目录、配置、假命令和期望结果。
  4. 故障注入:主动让依赖命令、信号、输入或资源失败,验证脚本的错误路径和清理逻辑。

这四者解决的问题不同。ShellCheck 不能证明业务逻辑正确;Bats 不能自动发现所有 Shell 语义陷阱;Fixture 决定测试是否可重复;故障注入则决定测试是否真正覆盖了失败路径。


一、先明确被测试对象:退出码、输出和副作用

一个 Shell 程序的可观察行为通常包括:

  • 进程退出码;
  • 标准输出;
  • 标准错误;
  • 创建、修改或删除的文件;
  • 对外部命令的调用;
  • 收到信号时的行为;
  • 失败后是否留下临时文件或部分结果。

可以把脚本看成一个函数:

F(I,E,D,S)(R,O,Er,D)F(I, E, D, S) \rightarrow (R, O, E_r, D')

其中:

  • II 是命令行参数或标准输入;
  • EE 是环境变量和 PATH 等执行环境;
  • DD 是初始文件系统状态;
  • SS 是外部命令、信号和资源状态;
  • RR 是退出码;
  • OO 是标准输出;
  • ErE_r 是标准错误;
  • DD' 是脚本结束后的文件系统状态。

测试不是只断言“命令返回 0”,而是验证:

F(I,E,D,S)=预期的 (R,O,Er,D)F(I, E, D, S) = \text{预期的 }(R, O, E_r, D')

例如,一个“原子复制”脚本在复制失败时,合理的要求可能是:

  • 返回非零退出码;
  • 错误写入标准错误,而不是标准输出;
  • 目标文件不应被替换;
  • 临时文件应被删除;
  • 原目标文件内容应保持不变。

如果测试只检查退出码,就无法发现临时文件泄漏或目标文件被破坏。


二、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

这里的关键不是“符合某个风格”,而是显式区分:

  1. 声明变量;
  2. 执行命令替换;
  3. 检查命令替换的退出码。

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

这个示例有几个需要明确的机制:

  1. 临时文件位于目标文件同一目录,避免跨文件系统移动。
  2. cp 先写临时文件,成功后再由 mv 替换目标。
  3. mv 在同一文件系统内通常对应 rename 语义,替换动作不会暴露一个“只写了一半”的目标文件。
  4. EXIT 陷阱负责普通退出时清理临时文件。
  5. INTTERM 陷阱先清理,再以 143 退出。143 通常表示 128 + 15,其中 15 是 SIGTERM;这是一种约定,不是所有程序都必须采用的唯一编码。
  6. SIGKILL 无法被捕获,进程被 kill -9 杀死时清理函数不会运行,临时文件仍可能残留。

示例中 cp 失败时统一返回 3。else 分支中的 rc 只是展示如何读取失败状态;如果业务需要透传原始错误码,可以直接 exit "$rc"。这里没有写成 if ! cp ...,因为在 thenelse 中使用 ! 后,直接读取的状态是取反后的结果,容易误把原始退出码丢掉。

此外,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 保证,发行版和工具实现差异需要在支持范围中明确。


五、故障注入:主动验证失败路径

故障注入是人为制造异常,使测试进入平时不容易到达的分支。常见注入点包括:

  1. 外部命令返回非零;
  2. 外部命令输出格式错误;
  3. 文件权限不足;
  4. 目录不存在或不可写;
  5. 命令执行超时;
  6. 进程收到 INTTERM
  7. 依赖命令不存在;
  8. 磁盘空间或 inode 不足;
  9. 目标文件在执行过程中被并发修改。

最容易、最安全、最具确定性的方式是注入一个同名假命令。

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.$$" ]
}

执行过程如下:

  1. env PATH=... 只修改这次子进程的环境;
  2. 脚本执行 cp 时,Shell 先在 fakebin 中找到假命令;
  3. 假命令返回 17;
  4. 脚本将该失败映射为 3;
  5. exit 3 触发 EXIT trap;
  6. cleanup 删除临时文件;
  7. 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.$$" ]
}

这个用例验证了一个重要的不变量:

mv 失败目标文件内容保持原值\text{mv 失败} \Rightarrow \text{目标文件内容保持原值}

如果脚本先直接 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 不可捕获,不能期望 trapkill -9 后运行。

如果脚本需要处理长时间运行的子进程,还应测试父进程退出后子进程是否残留。单纯设置 trap 并不能自动解决进程组管理问题。


六、set -euo pipefail 不是错误处理框架

很多脚本以如下选项开始:

set -Eeuo pipefail

各选项的含义不同:

  • -e:某些未被条件位置吸收的简单命令失败时退出;
  • -u:读取未设置变量时通常报错;
  • pipefail:管道退出码取最后一个失败命令,而不是只取最后一条命令;
  • -E:让 ERR trap 在函数、命令替换等上下文中继承,具体行为仍受 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 常见于 BSD sysexits 约定中的参数用法错误;
  • 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

使用 findxargs -0 是为了避免文件名包含空格时被错误拆分。若项目包含没有 .sh 后缀但实际是 Shell 的可执行文件,也应通过目录约定或 shebang 扫描纳入检查。

CI 环境还应固定或记录:

  • Bash 版本;
  • Bats 版本;
  • ShellCheck 版本;
  • 运行用户;
  • locale;
  • 时区;
  • 是否启用并行测试;
  • 是否存在 GNU 与 BusyBox 工具差异。

例如,脚本使用 sed -idate -dfind -printf 等能力时,不能仅凭“现代 Linux”就认为所有发行版实现完全一致。Debian、Ubuntu、Fedora、Alpine 的用户空间工具来源和默认行为可能不同;应明确支持的 Shell 和工具实现,或提供兼容代码。


十一、诊断失败测试的顺序

当 Bats 测试失败时,先区分四类问题:

  1. 脚本退出码错误:检查错误传播、set -e 上下文和 trap
  2. 输出错误:区分标准输出与标准错误;run 捕获的 $output 通常是合并结果。
  3. Fixture 错误:确认路径、权限、环境变量和初始文件内容。
  4. 副作用错误:用 find, stat, ls -la 检查临时文件、目标文件和文件权限。

手工复现时可以打开 Bash 跟踪:

PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
bash -x ./bin/atomic-copy source dest

但不要把包含密码、令牌或敏感路径的 set -x 输出写入生产日志。对于需要长期保留的诊断,更适合在脚本中显式输出稳定、无敏感信息的错误消息。

ShellCheck、Bats、Fixture 和故障注入分别覆盖静态语义、进程行为、可重复状态和失败路径。只有把四者连接起来,测试才会从“命令运行过”提升为“成功、失败、清理和副作用都符合契约”。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。