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 脚本编写回归测试的价值与测试覆盖范围

前置知识

框架对比

ShellSpecBats (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)
推荐 ShellSpec 语法更直观,内置 Mock/Stub,新手友好。Bats 适合已有 TAP 生态的团队。

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 outputshould include "ok"stdout 包含
The stderrshould be emptystderr 为空
The statusshould be success退出码为 0
The line 1should equal "..."第 1 行输出等于
The variable Xshould 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 kubectlwhich 等探测也能骗过,且天然覆盖所有子进程调用。注意 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 ActionsPR 触发自动运行
# 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 补齐
覆盖率目标 Shell 脚本追求 100% 行覆盖率是可行的(脚本通常不长),但重点是分支覆盖——每条 if/for/while 的每个出口都要有测试。确实无法覆盖的行,标注注释说明理由。

覆盖率接入 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 摘要

常见错误

  1. ShellSpec 找不到 shellspec 命令:安装后未配置 PATH。解决:在 ~/.bashrc 中添加 export PATH="$HOME/.local/lib/shellspec:$PATH",或用 curl -fsSL https://git.io/shellspec | sh -s -- --prefix /usr/local 安装到系统目录。
  2. Bats 测试中 run 后变量为空:忘记用 run 包裹命令,直接调用命令不会捕获输出。正确写法:run your_command,然后检查 $output$status
  3. Mock 未生效:ShellSpec 中 mock 必须在 When call 之前声明,且 mock 的命令名必须与脚本中实际调用的命令完全一致(含路径)。
  4. 测试间状态污染:多个测试共享 /tmp 下的文件时,前一个测试的残留会影响后续测试。务必在 cleanup() 中清理临时文件和环境变量。
  5. Bats 中 setup_file / teardown_file 不执行:Bats 1.x 才支持这些函数,旧版本需升级。检查 bats --version 确认版本 ≥ 1.0。
  6. mock 实现里 echo 污染 stdout 断言:fake 命令的输出会被 When call 捕获进 The output。给 mock 的实现加 echo >&2 写 stderr,或只记录不输出。
  7. 共享 fixture 被测试代码修改后其他用例失败:fixture 文件是隐性依赖。在 setup() 里从模板重建(cp fixtures/tpl.conf "$TEST_DIR/"),保证每个用例拿到全新副本。

最佳实践

  1. 测试文件与被测脚本分离:测试文件放在 spec/test/ 目录下,不要混在脚本目录中,保持项目结构清晰。
  2. 每个测试只验证一个行为:一个 It@test 只验证一个断言点,失败时能快速定位问题,而不是模糊的"某个功能不对"。
  3. 利用 CI 自动运行测试:在 GitHub Actions 或 GitLab CI 中配置 push/PR 触发测试,确保每次代码变更都经过验证,避免"本地能跑线上挂了"。
  4. 用 Mock 隔离外部依赖:测试脚本逻辑时 Mock 掉 sshrsynckubectl 等外部命令,既加快测试速度,又避免对真实环境产生副作用。
  5. 为测试编写测试:复杂测试逻辑本身也可能有 bug,可以用简单的已知输入验证测试用例的正确性,防止"测试通过但测试写错了"。
  6. fake 命令输出用 stderr:mock/fake 实现的调试输出写 >&2,stdout 留给被测脚本,断言互不干扰。
  7. 覆盖率门禁阶梯收紧:先定 60% 跑通流程,每月提高 10%,配合 review 强制新代码带测试,比一次定 90% 更容易落地。

练习题

  1. 为以下脚本编写 ShellSpec 测试:一个 grep_log.sh 脚本,接收日志文件路径和关键字参数,输出匹配行数。测试需覆盖正常匹配、无匹配、文件不存在三种场景。
  2. 用 Bats 编写一个 user_add.sh 的测试,验证脚本在用户名为空时返回错误码 1,在用户名已存在时返回错误码 2。思考如何 Mock id 命令来模拟用户已存在的场景。
  3. 在 GitHub Actions 中为你的 Shell 脚本项目添加测试流水线,要求:push 时运行所有测试,测试失败时发送钉钉通知。
  4. 为你项目里最复杂的一个函数(参数最多、分支最多)写一组 Parameters 参数化用例,覆盖全部分支出口,用 kcov 验证覆盖率提升。
  5. 搭建 Bats 工程结构(fixtures/ + helpers.bash + bats-assert),给 backup.sh 写 5 个用例:正常备份、目标目录不存在、源目录为空、rsync 失败、权限不足。

本章总结

ShellSpec 与 Bats 让 Shell 脚本告别"改一行悄悄破坏其他功能"的裸奔状态:ShellSpec 以 BDD 语法和内置 Mock 更易上手,Bats 以 TAP 生态见长。测试的关键是隔离外部依赖、清理测试状态,并接入 CI 让每次变更自动回归。

延伸阅读

↑ 回到顶部