4.20 Shell 脚本自动化测试——ShellSpec 与 Bats 实战
预计阅读时间:12 分钟
📖 目录
2.1:Shell 脚本入门 和 2.8:Shell 脚本进阶 编写了大量 Shell 脚本。但脚本没有测试就像程序没有 bug 检查——改一行可能悄悄破坏其他功能。ShellSpec 和 Bats 是 Bash 脚本的测试框架,让你用声明式语法为脚本编写可维护的测试。
学习目标
学完本章,你将能够:
- 掌握 ShellSpec 的 BDD 语法(Describe / It / When call)与断言体系
- 掌握 Bats 的 @test 用例编写与 TAP 输出格式
- 掌握 Mock / Stub / Spy 技术隔离 ssh、rsync、kubectl 等外部命令
- 理解测试间状态污染的成因与 setup / cleanup 的正确用法
- 掌握将 ShellSpec / Bats 集成到 GitHub Actions 等 CI 流水线的方法
- 理解为 Shell 脚本编写回归测试的价值与测试覆盖范围
前置知识
- 2.1:Shell 脚本入门 Shell 脚本入门——变量、条件、函数等脚本基础
- 2.8:Shell 脚本进阶 Shell 脚本进阶——高级语法与脚本调试
- 4.19:Shell 实战案例集 Shell 脚本实战案例集——真实场景脚本与常见坑
- 4.9:CI/CD 持续部署 CI/CD 持续部署入门——流水线触发与自动化验证
框架对比
| ShellSpec | Bats (Bash Automated Testing System) | |
|---|---|---|
| 语法 | BDD 风格(describe/it/when) | TAP 输出(@test) |
| Mock/Stub | 内置 mock/stub/spy | 需 bats-support/bats-assert |
| 代码覆盖 | 内置 coverage 报告 | 需 bats-coverage 插件 |
| 安装 | 单脚本,无依赖 | npm 或 git 安装 |
| 社区 | 活跃(shellspec.info) | 成熟(GitHub 20k+ stars) |
1. ShellSpec 快速上手
# 安装
curl -fsSL https://git.io/shellspec | sh
# 创建测试文件
cat > spec/hello_spec.sh << 'EOF'
Describe "hello.sh"
It "打印问候语"
When call ./hello.sh "Linux"
The output should include "Hello, Linux"
The status should be success
End
It "无参数时使用默认值"
When call ./hello.sh
The output should include "Hello, World"
The status should be success
End
End
EOF
# 运行测试
shellspec
BDD 语法结构
# Describe - 测试对象
# Context / When - 条件
# It - 具体用例
# The output / status / line - 断言
Describe "deploy.sh"
setup() {
# 每个 It 执行前运行
export DEPLOY_ENV="staging"
export DRY_RUN="true"
}
cleanup() {
# 每个 It 执行后运行
rm -rf /tmp/deploy-test
}
Context "staging 环境"
It "使用正确的配置文件"
When call source ./deploy.sh --env staging --dry-run
The output should include "config: staging.conf"
The status should be success
End
End
Context "production 环境"
It "需要确认提示"
When call ./deploy.sh --env production --dry-run
The output should include "Are you sure"
The status should be success
End
End
End
describe / it / expect 语法详解
Describe "calculator.sh"
Describe "add()"
Parameters # 参数化用例:同一断言跑多组数据
"1" "2" "3"
"10" "5" "15"
"-1" "1" "0"
End
Example "加法计算正确: $1 + $2 = $3"
When call add "$1" "$2"
The output should equal "$3"
End
End
It "除数为 0 时报错"
When call div 1 0
The stderr should include "division by zero"
The status should be failure
End
It "变量测试(The variable)"
export COUNT=5
When call inc COUNT
The variable COUNT should equal "6"
The value "hello" should start with "he"
End
End
| 断言形式 | 示例 | 含义 |
|---|---|---|
The output | should include "ok" | stdout 包含 |
The stderr | should be empty | stderr 为空 |
The status | should be success | 退出码为 0 |
The line 1 | should equal "..." | 第 1 行输出等于 |
The variable X | should equal 6 | 变量 X 的值等于 |
The value "x" | should start with "h" | 字面量断言 |
Parameters 是 ShellSpec 最强的特性:一组数据跑同一用例,覆盖率翻倍而代码不翻倍。运行参数化用例时会为每组数据生成独立的结果行,失败只标红具体那一组。
2. Mock 与 Stub
# Mock 外部命令(不真正执行)
Describe "backup.sh"
It "调用 rsync 但不真正执行"
mock rsync "echo 'rsync called: $@'"
When call ./backup.sh /data /backup
The output should include "rsync called"
The status should be success
End
It "模拟 ssh 连接失败"
mock ssh "return 1"
When call ./remote-backup.sh server:/data
The status should be failure
End
End
# Stub 模拟文件读取
Describe "parse_config.sh"
It "正确解析配置文件"
create_file "/tmp/test.conf" "KEY=value1"
When call parse_config "/tmp/test.conf"
The output should include "KEY=value1"
End
End
Fixture 管理与 PATH 重写 Mock
临时目录隔离 + 用假的二进制替换外部命令,是 Shell 测试隔离的两大法宝:
# 每个 It 使用独立临时目录,避免测试间污染
Describe "backup.sh"
setup() {
TEST_DIR=$(mktemp -d /tmp/backup-test.XXXXXX)
mkdir -p "$TEST_DIR/data" "$TEST_DIR/backup"
echo "important" > "$TEST_DIR/data/file1.txt"
}
cleanup() { rm -rf "$TEST_DIR"; }
It "备份文件到目标目录"
When call ./backup.sh "$TEST_DIR/data" "$TEST_DIR/backup"
The path "$TEST_DIR/backup/file1.txt" should be file
End
End
# PATH 重写:伪造一个假的 kubectl 放在优先路径
mkdir -p fixtures/bin
cat > fixtures/bin/kubectl << 'EOF'
#!/bin/bash
echo "FAKE kubectl: $*"
EOF
chmod +x fixtures/bin/kubectl
Describe "deploy.sh"
setup() { export PATH="$PWD/fixtures/bin:$PATH" }
It "调用 kubectl apply"
When call ./deploy.sh
The output should include "FAKE kubectl: apply"
End
End
PATH 重写比 shell 函数 Mock 更接近真实环境:被测试脚本里的 command -v kubectl、which 等探测也能骗过,且天然覆盖所有子进程调用。注意 fake 脚本要模拟输入输出行为(退出码、stdout、stderr),而不仅是打印一行。
Spy:验证命令是否被正确调用
Mock 替代行为,Spy 记录调用——两者搭配可以断言「脚本不但做了正确的事,还以正确的方式调用了它」:
Describe "backup.sh"
It "以压缩模式调用 tar"
mock tar "echo 'tar called: $*'"
When call ./backup.sh --compress /data
The output should include "tar called: -czf"
End
It "按正确顺序执行备份步骤"
mock ssh "echo 'ssh: $*'"
mock rsync "echo 'rsync: $*'"
When call ./deploy.sh --sync
The output should include "ssh: root@host"
The output should include "rsync: /data /backup"
The lines of output should eq 2 # 恰好调用两个外部命令
End
End
实战技巧:给 mock 的实现里加 echo "CALLED: $*" >&2 写 stderr,测试里断言 The stderr——这样 stdout 断言留给被测脚本自身输出,两者互不干扰。
3. Bats 测试
# 安装 Bats
npm install -g bats
# 或 git 安装
git clone https://github.com/bats-core/bats-core.git
# 测试文件
cat > test/hello.bats << 'EOF'
setup() {
source ./hello.sh
}
@test "hello 命令输出正确" {
run hello "Linux"
[ "$status" -eq 0 ]
[[ "$output" == *"Hello, Linux"* ]]
}
@test "hello 无参数使用默认值" {
run hello
[ "$status" -eq 0 ]
[[ "$output" == *"Hello, World"* ]]
}
@test "hello 拒绝空参数" {
run hello ""
[ "$status" -ne 0 ]
}
EOF
# 运行
bats test/
setup / teardown 与 skip
setup() { # 每个 @test 前执行
export TEST_DIR=$(mktemp -d)
cd "$TEST_DIR"
}
teardown() { # 每个 @test 后执行(无论成败)
rm -rf "$TEST_DIR"
}
setup_file() { # 整个文件只执行一次(Bats 1.x+)
mkdir -p /tmp/bats-fixtures
}
teardown_file() { # 文件末尾执行一次
rm -rf /tmp/bats-fixtures
}
@test "跳过的用例" {
skip "尚未实现" # 输出 SKIP,不算失败
run ./feature.sh
}
@test "条件跳过" {
if [[ "$CI" != "true" ]]; then
skip "仅 CI 环境执行"
fi
}
bats-assert 断言库
# 安装(Git 子模块方式)
git submodule add https://github.com/bats-core/bats-assert test/bats-assert
git submodule add https://github.com/bats-core/bats-support test/bats-support
# 测试文件中加载
load 'bats-support/load'
load 'bats-assert/load'
@test "使用断言库" {
run ./backup.sh --dry-run
assert_success # 断言 exit code 为 0
assert_output --partial "backup ok"
assert_line --index 0 "=== backup start ==="
run ./backup.sh /nonexistent
assert_failure # 断言 exit code 非 0
assert_output --regexp "No such file"
}
| 断言 | 作用 |
|---|---|
assert_success / assert_failure | 断言命令退出码为 0 / 非 0 |
assert_output <expected> | 断言完整输出等于期望值(可加 --partial 做包含匹配) |
assert_output --regexp | 按正则匹配输出 |
assert_line --index n | 断言第 n 行内容(行号从 0 开始) |
refute_output / refute_line | 断言输出/行不包含期望值 |
Bats 工程结构:fixture 与 helpers
test/
├── hello.bats # 测试用例
├── helpers.bash # 公共函数(load 加载)
├── fixtures/ # 固定数据与 fake 二进制
│ ├── data/
│ │ ├── valid.conf
│ │ └── broken.conf
│ └── bin/
│ └── kubectl # fake kubectl(PATH 重写)
└── bats-assert/ # 断言库(子模块)
# helpers.bash:公共 fixture 工厂,避免每个用例重复造数据
make_conf() { printf 'server=%s\nport=%s\n' "$2" "$3" > "$1"; }
setup() { TEST_DIR=$(mktemp -d); }
teardown() { rm -rf "$TEST_DIR"; }
@test "解析合法配置" {
make_conf "$TEST_DIR/t.conf" 127.0.0.1 8080
run parse_config "$TEST_DIR/t.conf"
assert_output --partial "127.0.0.1"
}
4. 测试 Ansible Role
# 用 ShellSpec 测试 Ansible Role 输出
Describe "ansible role: nginx"
# 模拟 ansible-playbook 的输出
setup() {
export ROLE_PATH="roles/nginx"
}
It "生成正确的 nginx.conf"
When call ansible-playbook test.yml --check
The output should include "changed=0"
The status should be success
End
It "模板渲染正确"
When call cat templates/nginx.conf.j2
The output should include "listen 80"
End
End
5. 测试策略
| 测试类型 | 工具 | 覆盖范围 |
|---|---|---|
| 单元测试 | ShellSpec / Bats | 单个函数的输入输出 |
| 集成测试 | ShellSpec + Docker | 脚本与真实服务交互 |
| 端到端测试 | ShellSpec + Docker Compose | 完整部署流程验证 |
| CI 集成 | GitHub Actions | PR 触发自动运行 |
# GitHub Actions 示例
name: Shell Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install ShellSpec
run: curl -fsSL https://git.io/shellspec | sh
- name: Run tests
run: shellspec
完整 CD 流水线:shellcheck + bats + 报告上传
# .github/workflows/shell-tests.yml — 覆盖静态检查与全部测试
name: Shell Tests
on:
push:
branches: [main]
pull_request:
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ShellCheck 静态检查
run: |
sudo apt-get install -y shellcheck
shellcheck --severity=warning scripts/*.sh
# 严重级别:error / warning / info / style
- name: 安装 Bats 与断言库
run: |
git clone --depth 1 https://github.com/bats-core/bats-core.git
sudo ./bats-core/install.sh /usr/local
bats --version
- name: 运行 Bats 测试
run: bats --print-output-on-failure test/
- name: 安装并运行 ShellSpec
run: |
curl -fsSL https://git.io/shellspec | sh
~/.local/bin/shellspec
- name: 上传测试报告(失败时也能看)
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: |
reports/
spec/reports/
测试优先级:什么脚本值得测
| 脚本类型 | 优先级 | 建议 |
|---|---|---|
| 部署 / 发布脚本 | 最高 | 全套测试 + 覆盖率门禁 |
| 数据处理脚本(备份、同步) | 高 | 核心函数单元测试 + 失败分支覆盖 |
| CI 流水线脚本 | 中 | Mock 外部命令,保证本地可跑 |
| 一次性维护脚本 | 低 | 只做 shellcheck,不写测试 |
6. 测试覆盖率(kcov)
Shell 脚本没有原生覆盖率,kcov 通过代码插桩统计每行执行情况:
# 安装 kcov(依赖 gcc / cmake / binutils)
sudo apt install -y gcc cmake pkg-config binutils-dev libdw-dev
git clone https://github.com/SimonKagstrom/kcov.git
cd kcov && mkdir build && cd build
cmake .. && make && sudo make install
# 对 Bats 测试跑覆盖率
kcov --exclude-pattern=/usr/lib \
--include-path="$PWD" \
/tmp/coverage bats test/hello.bats
# 对 ShellSpec 测试跑覆盖率
kcov --exclude-pattern=/usr/lib,/tmp \
--include-path="$PWD" \
/tmp/coverage-shellspec shellspec
# 查看报告
ls /tmp/coverage/*.html # 每个脚本一份 HTML 报告
grep -o 'coverage: [0-9.]*%' /tmp/coverage/*/index.html
kcov 报告会按行标注"执行/未执行",重点看两类盲区:
- 错误分支从未测试:
if [ $? -ne 0 ]的失败分支、exit 1路径——这正是脚本最容易出 bug 的地方 - 边界参数未覆盖:空字符串、超长输入、特殊字符文件名,用 Parameters 或参数化 @test 补齐
覆盖率接入 CI 门禁
# CI 中跑完 kcov 后解析覆盖率,低于阈值直接失败
COVERAGE=$(grep -o 'coverage: [0-9.]*%' /tmp/coverage/*/index.html \
| awk '{s+=$2} END {printf "%.1f", s/NR}')
echo "总覆盖率: $COVERAGE%"
if (( $(echo "$COVERAGE < 70" | bc -l) )); then
echo "覆盖率低于 70%,请补充测试"
exit 1
fi
# 门禁要点:
# 1. 阈值按仓库成熟度阶梯式收紧:60% → 70% → 80%
# 2. 新脚本要求 100% 行覆盖再合入(review 时看 diff 有没有测试)
# 3. 覆盖率报告上传 artifact,PR 评论自动贴 diff 摘要
常见错误
- ShellSpec 找不到
shellspec命令:安装后未配置 PATH。解决:在~/.bashrc中添加export PATH="$HOME/.local/lib/shellspec:$PATH",或用curl -fsSL https://git.io/shellspec | sh -s -- --prefix /usr/local安装到系统目录。 - Bats 测试中
run后变量为空:忘记用run包裹命令,直接调用命令不会捕获输出。正确写法:run your_command,然后检查$output和$status。 - Mock 未生效:ShellSpec 中
mock必须在When call之前声明,且 mock 的命令名必须与脚本中实际调用的命令完全一致(含路径)。 - 测试间状态污染:多个测试共享
/tmp下的文件时,前一个测试的残留会影响后续测试。务必在cleanup()中清理临时文件和环境变量。 - Bats 中
setup_file/teardown_file不执行:Bats 1.x 才支持这些函数,旧版本需升级。检查bats --version确认版本 ≥ 1.0。 - mock 实现里 echo 污染 stdout 断言:fake 命令的输出会被
When call捕获进The output。给 mock 的实现加echo >&2写 stderr,或只记录不输出。 - 共享 fixture 被测试代码修改后其他用例失败:fixture 文件是隐性依赖。在
setup()里从模板重建(cp fixtures/tpl.conf "$TEST_DIR/"),保证每个用例拿到全新副本。
最佳实践
- 测试文件与被测脚本分离:测试文件放在
spec/或test/目录下,不要混在脚本目录中,保持项目结构清晰。 - 每个测试只验证一个行为:一个
It或@test只验证一个断言点,失败时能快速定位问题,而不是模糊的"某个功能不对"。 - 利用 CI 自动运行测试:在 GitHub Actions 或 GitLab CI 中配置 push/PR 触发测试,确保每次代码变更都经过验证,避免"本地能跑线上挂了"。
- 用 Mock 隔离外部依赖:测试脚本逻辑时 Mock 掉
ssh、rsync、kubectl等外部命令,既加快测试速度,又避免对真实环境产生副作用。 - 为测试编写测试:复杂测试逻辑本身也可能有 bug,可以用简单的已知输入验证测试用例的正确性,防止"测试通过但测试写错了"。
- fake 命令输出用 stderr:mock/fake 实现的调试输出写
>&2,stdout 留给被测脚本,断言互不干扰。 - 覆盖率门禁阶梯收紧:先定 60% 跑通流程,每月提高 10%,配合 review 强制新代码带测试,比一次定 90% 更容易落地。
练习题
- 为以下脚本编写 ShellSpec 测试:一个
grep_log.sh脚本,接收日志文件路径和关键字参数,输出匹配行数。测试需覆盖正常匹配、无匹配、文件不存在三种场景。 - 用 Bats 编写一个
user_add.sh的测试,验证脚本在用户名为空时返回错误码 1,在用户名已存在时返回错误码 2。思考如何 Mockid命令来模拟用户已存在的场景。 - 在 GitHub Actions 中为你的 Shell 脚本项目添加测试流水线,要求:push 时运行所有测试,测试失败时发送钉钉通知。
- 为你项目里最复杂的一个函数(参数最多、分支最多)写一组
Parameters参数化用例,覆盖全部分支出口,用 kcov 验证覆盖率提升。 - 搭建 Bats 工程结构(fixtures/ + helpers.bash + bats-assert),给 backup.sh 写 5 个用例:正常备份、目标目录不存在、源目录为空、rsync 失败、权限不足。
本章总结
ShellSpec 与 Bats 让 Shell 脚本告别"改一行悄悄破坏其他功能"的裸奔状态:ShellSpec 以 BDD 语法和内置 Mock 更易上手,Bats 以 TAP 生态见长。测试的关键是隔离外部依赖、清理测试状态,并接入 CI 让每次变更自动回归。
延伸阅读
- 2.1:Shell 脚本入门 Shell 脚本入门
- 2.8:Shell 脚本进阶 Shell 脚本进阶
- 4.19:Shell 实战案例集 Shell 脚本实战案例集
- 4.9:CI/CD 持续部署 CI/CD 持续部署入门
- cicd01 流水线门禁设计——PR 门禁与回归