脚本测试与静态检查:Bats 与 shellcheck 实战

讲解如何用 Bats 为 Shell 脚本编写用例与断言、搭建 setup 与 teardown 夹具、mock 外部命令,并用 shellcheck 做静态检查与规则豁免,最后接入 CI 与覆盖率统计。

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 长期托管——毕竟一个天天在后台跑的定时任务,出错的代价比手动执行更高。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战