1. 为什么 Shell 脚本更需要测试
一句话总结: Shell 脚本没有类型系统、没有编译器,一个未加引号的变量就能静默改错数据,所以它比编译型语言更依赖测试与静态检查。
部署脚本、备份脚本、数据清洗脚本一旦出错,后果往往不是「报个异常」而是「删了不该删的目录」「备份了空文件还报告成功」。这类脚本通常没有测试,改一行靠人肉在测试机上跑一遍。
1.1 脚本的三类典型故障
| 故障类型 | 例子 | 静态检查能抓到吗 |
|---|---|---|
| 词分裂 | rm -rf $dir/* 中 $dir 为空 | 能(SC2086) |
| 错误未传播 | 中间命令失败但脚本继续 | 部分(SC2181) |
| 逻辑错误 | 排除规则写反、边界判断错 | 不能,必须靠测试 |
#!/usr/bin/env bash
set -euo pipefail
# 反面教材:dir 为空时变成 rm -rf /*
dir="$1"
rm -rf $dir/*
一句话总结: 静态检查负责「模式化的低级错误」,测试负责「行为是否符合预期」,两者缺一不可。
1.2 工具选型
- shellcheck:静态分析器,不需要运行脚本,零成本接入;
- Bats(Bash Automated Testing System):TAP 兼容的测试框架,用 bash 写用例;
- shfmt:格式化工具,统一缩进与风格,让 diff 干净。
# 安装(Debian/Ubuntu)
sudo apt-get install -y shellcheck
sudo apt-get install -y bats
# macOS
brew install shellcheck bats-core
2. Bats 入门:用例与断言
一句话总结: 一个 Bats 文件就是一个可执行的测试套件,
@test "名字" { ... }定义用例,run执行被测命令并把结果放进$status与$output。
2.1 第一个用例
#!/usr/bin/env bats
# tests/greet.bats
@test "greet 输出包含用户名" {
run ./greet.sh alice
[ "$status" -eq 0 ]
[ "${lines[0]}" = "hello alice" ]
}
# 运行
bats tests/greet.bats
# TAP 输出(便于 CI 解析)
bats --tap tests/greet.bats
# 只跑匹配名字的用例;--jobs 可并行(bats-core 1.8+)
bats --filter "用户名" tests/
bats --jobs 4 tests/
一句话总结:
run是 Bats 的核心原语:它捕获退出码、标准输出与逐行数组,避免用例里到处写临时文件。
2.2 run 暴露的三个变量
#!/usr/bin/env bats
@test "run 的三个变量" {
run bash -c 'echo first; echo second; exit 3'
[ "$status" -eq 3 ] # 退出码
[ "${lines[0]}" = "first" ] # 逐行数组
[ "${lines[1]}" = "second" ]
[[ "$output" == *"first"* ]] # 完整输出(含换行)
}
注意:run 会吞掉命令的输出,所以用例中不要再直接 echo 做调试;需要排查时用 bats --print-output-on-failure。
3. 夹具:setup 与 teardown
一句话总结: Bats 提供
setup/teardown(每个用例前后)与setup_file/teardown_file(整个文件前后)四个钩子,是准备临时目录与清理副作用的正确位置。
3.1 四个钩子的执行时机
#!/usr/bin/env bats
setup_file() {
# 整个文件只跑一次:适合昂贵的一次性准备
export SHARED_FIXTURE="$(mktemp -d)"
}
setup() {
# 每个用例之前:准备独立沙箱
TEST_TMP="$(mktemp -d)"
cd "$TEST_TMP" || exit 1
}
teardown() {
# 每个用例之后:即使失败也会执行
rm -rf "$TEST_TMP"
}
teardown_file() {
rm -rf "$SHARED_FIXTURE"
}
一句话总结: 把「每个用例都要独立」的副作用放
setup/teardown,把「整个文件共享」的昂贵准备放setup_file。
3.2 用 load 复用夹具
#!/usr/bin/env bats
# tests/helpers.bash
make_repo() {
git init -q "$1"
git -C "$1" config user.email t@t && git -C "$1" config user.name t
}
#!/usr/bin/env bats
# tests/git.bats
load 'helpers'
setup() {
REPO="$(mktemp -d)"
make_repo "$REPO"
}
teardown() { rm -rf "$REPO"; }
@test "初始仓库无提交" {
run git -C "$REPO" rev-parse HEAD
[ "$status" -ne 0 ]
}
load 相对于测试文件所在目录解析,配合 BATS_TEST_DIRNAME 可以加载任意位置的公共函数。
4. mock 外部命令
一句话总结: Shell 里 mock 的本质是「让同名命令先被找到」,既可以用 bash 函数遮蔽,也可以往
PATH前面塞一个假二进制。
4.1 用函数遮蔽命令
函数优先级高于外部命令,所以定义同名函数就能拦截调用。
#!/usr/bin/env bats
setup() {
# mock curl:不联网,返回固定 JSON
curl() { echo '{"status":"ok","version":"1.2.3"}'; }
export -f curl
}
@test "解析 API 返回的版本号" {
run ./fetch-version.sh
[ "$status" -eq 0 ]
[ "$output" = "1.2.3" ]
}
如果被测脚本是通过 bash script.sh 启动的独立进程,函数不会继承,此时改用 export -f 加 bash -c,或直接使用下面的假二进制方案。
一句话总结: 函数遮蔽适合「同一进程内 source 进来的函数」,独立子进程场景必须走假二进制。
4.2 用假二进制注入 PATH
#!/usr/bin/env bats
setup() {
MOCKBIN="$(mktemp -d)"
cat > "$MOCKBIN/rsync" <<'EOF'
#!/bin/sh
echo "rsync called with: $*" >> "$RSYNC_LOG"
exit 0
EOF
chmod +x "$MOCKBIN/rsync"
export RSYNC_LOG="$MOCKBIN/calls.log"
export PATH="$MOCKBIN:$PATH"
}
teardown() { rm -rf "$MOCKBIN"; }
@test "备份脚本调用 rsync 且带 --delete" {
run ./backup.sh /src /dst
[ "$status" -eq 0 ]
grep -q -- '--delete' "$RSYNC_LOG"
}
假二进制方案还能记录调用参数、模拟非零退出码、模拟超时,是测试外部依赖最通用的手段。
5. shellcheck 静态检查
一句话总结: shellcheck 按规则号报告问题,理解高频规则后,绝大多数的修法是「加引号」「加
|| exit」「去掉无用的$」,而不是无脑加豁免。
5.1 高频规则
#!/usr/bin/env bash
set -euo pipefail
dir=$1
rm -rf $dir/* # SC2086:未加引号会词分裂
files=$(ls) # SC2012:用 glob 或 find 代替解析 ls
[ $? -eq 0 ] # SC2181:直接 if cmd; then
for f in $(cat list); do # SC2046:未加引号的命令替换
echo $f
done
# 报告并给出修复建议
shellcheck backup.sh
# 只看 error 级别以上
shellcheck --severity=error backup.sh
# 指定方言(默认按 shebang 推断)
shellcheck -s bash backup.sh
# 跟随 source 引入的文件(跨文件分析)
shellcheck -x -P SCRIPTDIR deploy.sh
一句话总结: 把
shellcheck --severity=error设成 CI 的硬门槛,warning 作为趋势指标,团队落地阻力最小。
5.2 豁免与配置
#!/usr/bin/env bash
set -euo pipefail
# 单行豁免
# shellcheck disable=SC1091
source /etc/os-release
# 整个文件豁免(放在文件顶部)
# shellcheck shell=bash
# shellcheck disable=SC2086
# 说明来源路径,消除 SC1090/SC1091
# shellcheck source=lib/common.sh
source lib/common.sh
# .shellcheckrc(项目根目录,全仓库生效)
severity=warning
external-sources=true
source-path=SCRIPTDIR
disable=SC2317
豁免必须带理由注释,否则几个月后没人敢删。建议在代码评审中要求:任何 disable 都要写明为什么这行是安全的。
6. CI 集成与覆盖率
一句话总结: CI 里把 shellcheck 作为快速门禁、Bats 作为行为验证、kcov 作为覆盖率趋势,三者组合就能让脚本质量可度量。
6.1 GitHub Actions 流水线
name: shell-ci
on: [push, pull_request]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y shellcheck bats
- name: Static check
run: shellcheck --severity=warning scripts/*.sh
- name: Unit tests
run: bats --tap tests/
一句话总结: 先跑 shellcheck(秒级、失败快)再跑 Bats(分钟级),能在最短时间内给出最有价值的反馈。
6.2 kcov 覆盖率
# 安装 kcov 后统计 Bats 覆盖的脚本行
kcov --include-path=scripts/ --exclude-pattern=tests/ coverage/ bats tests/
# 结果在 coverage/index.html,含按文件的行覆盖与分支覆盖
覆盖率的意义在于发现没被测到的分支(例如 case 里没人走的错误分支),而不是追求 100% 数字。建议把「关键脚本覆盖率不低于 70%」写进团队规范。
7. 实战:给部署脚本加测试
一句话总结: 一个可测试的脚本应当把「纯逻辑」抽成函数、把「副作用」放到
main,这样测试可以只 source 函数而不用真的部署。
7.1 被测脚本
#!/usr/bin/env bash
set -euo pipefail
# scripts/version.sh
# 从 tag 推导下一个版本号(纯函数,易测)
next_version() {
local current="$1" bump="${2:-patch}"
local major minor patch
IFS=. read -r major minor patch <<<"${current#v}"
case "$bump" in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
patch) patch=$((patch + 1)) ;;
*) echo "unknown bump: $bump" >&2; return 1 ;;
esac
printf 'v%s.%s.%s\n' "$major" "$minor" "$patch"
}
# 只有直接执行时才跑 main,被 source 时不跑
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
next_version "${1:-v1.0.0}" "${2:-patch}"
fi
7.2 测试套件
#!/usr/bin/env bats
# tests/version.bats
load '../scripts/version.sh'
@test "patch 递增" {
run next_version "v1.2.3" patch
[ "$status" -eq 0 ]
[ "$output" = "v1.2.4" ]
}
@test "minor 递增会清零 patch" {
run next_version "v1.2.3" minor
[ "$output" = "v1.3.0" ]
}
@test "非法 bump 返回非零" {
run next_version "v1.2.3" nonsense
[ "$status" -ne 0 ]
}
# 本地一次跑完检查与测试
shellcheck --severity=warning scripts/*.sh \
&& bats --tap tests/
注意 load 一个「会执行 main」的脚本很危险,所以脚本末尾的 BASH_SOURCE 守卫不是可选项,而是让脚本可测试的前提。
8. 总结
| 环节 | 要点 |
|---|---|
| 动机 | Shell 缺类型与编译检查,静默错误代价高 |
| 框架 | Bats 用 bash 写用例,TAP 输出天然适配 CI |
| 断言 | run 捕获 $status/$output/$lines |
| 夹具 | setup/teardown 管每个用例,*_file 管整个文件 |
| mock | 函数遮蔽同进程命令,假二进制注入 PATH 管子进程 |
| 静态检查 | shellcheck 高频规则修「引号、$?、命令替换」 |
| 豁免 | 必须带理由注释,--severity=error 作 CI 硬门槛 |
| 集成 | shellcheck 先跑,Bats 后跑,kcov 看覆盖趋势 |
把测试与静态检查接进 CI 之后,脚本的每一次修改都会自动回答两个问题:有没有明显的低级错误(shellcheck)和行为有没有被改坏(Bats)。有了这层保障,下一步就可以放心地把脚本交给 systemd 长期托管——毕竟一个天天在后台跑的定时任务,出错的代价比手动执行更高。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。