Bats测试模式Skill bats-testing-patterns

此技能专注于使用Bash自动化测试系统(Bats)进行全面的shell脚本单元测试。它涵盖了测试模式、固定装置、模拟技术、错误处理和CI/CD集成,旨在提升脚本测试效率和代码质量。关键词包括:Bats测试、shell脚本测试、自动化测试、单元测试、CI/CD管道、测试驱动开发、固定装置、断言模式、DevOps。

测试 0 次安装 0 次浏览 更新于 3/22/2026

name: bats-testing-patterns 描述:掌握Bash自动化测试系统(Bats)以进行全面的shell脚本测试。在编写shell脚本测试、CI/CD管道或需要shell实用程序的测试驱动开发时使用。

Bats测试模式

关于使用Bats(Bash自动化测试系统)编写全面shell脚本单元测试的综合指南,包括测试模式、固定装置和生产级shell测试的最佳实践。

何时使用此技能

  • 编写shell脚本的单元测试
  • 为脚本实施测试驱动开发(TDD)
  • 在CI/CD管道中设置自动化测试
  • 测试边缘案例和错误条件
  • 验证不同shell环境下的行为
  • 为脚本构建可维护的测试套件
  • 创建复杂测试场景的固定装置
  • 测试多种shell方言(bash、sh、dash)

Bats基础

什么是Bats?

Bats(Bash自动化测试系统)是一个TAP(测试任何协议)兼容的shell脚本测试框架,提供:

  • 简单、自然的测试语法
  • 与CI系统兼容的TAP输出格式
  • 固定装置和设置/拆卸支持
  • 断言助手
  • 并行测试执行

安装

# macOS使用Homebrew
brew install bats-core

# Ubuntu/Debian
git clone https://github.com/bats-core/bats-core.git
cd bats-core
./install.sh /usr/local

# 从npm(Node.js)安装
npm install --global bats

# 验证安装
bats --version

文件结构

project/
├── bin/
│   ├── script.sh
│   └── helper.sh
├── tests/
│   ├── test_script.bats
│   ├── test_helper.sh
│   ├── fixtures/
│   │   ├── input.txt
│   │   └── expected_output.txt
│   └── helpers/
│       └── mocks.bash
└── README.md

基本测试结构

简单测试文件

#!/usr/bin/env bats

# 如果存在,加载测试助手
load test_helper

# 设置在每个测试前运行
setup() {
    export TMPDIR=$(mktemp -d)
}

# 拆卸在每个测试后运行
teardown() {
    rm -rf "$TMPDIR"
}

# 测试:简单断言
@test "函数成功时返回0" {
    run my_function "input"
    [ "$status" -eq 0 ]
}

# 测试:输出验证
@test "函数输出正确结果" {
    run my_function "test"
    [ "$output" = "expected output" ]
}

# 测试:错误处理
@test "函数在缺少参数时返回1" {
    run my_function
    [ "$status" -eq 1 ]
}

断言模式

退出代码断言

#!/usr/bin/env bats

@test "命令成功" {
    run true
    [ "$status" -eq 0 ]
}

@test "命令按预期失败" {
    run false
    [ "$status" -ne 0 ]
}

@test "命令返回特定退出代码" {
    run my_function --invalid
    [ "$status" -eq 127 ]
}

@test "可以捕获命令结果" {
    run echo "hello"
    [ $status -eq 0 ]
    [ "$output" = "hello" ]
}

输出断言

#!/usr/bin/env bats

@test "输出匹配字符串" {
    result=$(echo "hello world")
    [ "$result" = "hello world" ]
}

@test "输出包含子字符串" {
    result=$(echo "hello world")
    [[ "$result" == *"world"* ]]
}

@test "输出匹配模式" {
    result=$(date +%Y)
    [[ "$result" =~ ^[0-9]{4}$ ]]
}

@test "多行输出" {
    run printf "line1
line2
line3"
    [ "$output" = "line1
line2
line3" ]
}

@test "行变量包含输出" {
    run printf "line1
line2
line3"
    [ "${lines[0]}" = "line1" ]
    [ "${lines[1]}" = "line2" ]
    [ "${lines[2]}" = "line3" ]
}

文件断言

#!/usr/bin/env bats

@test "文件被创建" {
    [ ! -f "$TMPDIR/output.txt" ]
    my_function > "$TMPDIR/output.txt"
    [ -f "$TMPDIR/output.txt" ]
}

@test "文件内容匹配预期" {
    my_function > "$TMPDIR/output.txt"
    [ "$(cat "$TMPDIR/output.txt")" = "expected content" ]
}

@test "文件可读" {
    touch "$TMPDIR/test.txt"
    [ -r "$TMPDIR/test.txt" ]
}

@test "文件具有正确权限" {
    touch "$TMPDIR/test.txt"
    chmod 644 "$TMPDIR/test.txt"
    [ "$(stat -f %OLp "$TMPDIR/test.txt")" = "644" ]
}

@test "文件大小正确" {
    echo -n "12345" > "$TMPDIR/test.txt"
    [ "$(wc -c < "$TMPDIR/test.txt")" -eq 5 ]
}

设置和拆卸模式

基本设置和拆卸

#!/usr/bin/env bats

setup() {
    # 创建测试目录
    TEST_DIR=$(mktemp -d)
    export TEST_DIR

    # 导入被测试脚本
    source "${BATS_TEST_DIRNAME}/../bin/script.sh"
}

teardown() {
    # 清理临时目录
    rm -rf "$TEST_DIR"
}

@test "使用TEST_DIR测试" {
    touch "$TEST_DIR/file.txt"
    [ -f "$TEST_DIR/file.txt" ]
}

带资源的设置

#!/usr/bin/env bats

setup() {
    # 创建目录结构
    mkdir -p "$TMPDIR/data/input"
    mkdir -p "$TMPDIR/data/output"

    # 创建测试固定装置
    echo "line1" > "$TMPDIR/data/input/file1.txt"
    echo "line2" > "$TMPDIR/data/input/file2.txt"

    # 初始化环境
    export DATA_DIR="$TMPDIR/data"
    export INPUT_DIR="$DATA_DIR/input"
    export OUTPUT_DIR="$DATA_DIR/output"
}

teardown() {
    rm -rf "$TMPDIR/data"
}

@test "处理输入文件" {
    run my_process_script "$INPUT_DIR" "$OUTPUT_DIR"
    [ "$status" -eq 0 ]
    [ -f "$OUTPUT_DIR/file1.txt" ]
}

全局设置/拆卸

#!/usr/bin/env bats

# 从test_helper.sh加载共享设置
load test_helper

# setup_file在所有测试前运行一次
setup_file() {
    export SHARED_RESOURCE=$(mktemp -d)
    echo "Expensive setup" > "$SHARED_RESOURCE/data.txt"
}

# teardown_file在所有测试后运行一次
teardown_file() {
    rm -rf "$SHARED_RESOURCE"
}

@test "第一个测试使用共享资源" {
    [ -f "$SHARED_RESOURCE/data.txt" ]
}

@test "第二个测试使用共享资源" {
    [ -d "$SHARED_RESOURCE" ]
}

模拟和桩模式

函数模拟

#!/usr/bin/env bats

# 模拟外部命令
my_external_tool() {
    echo "mocked output"
    return 0
}

@test "函数使用模拟工具" {
    export -f my_external_tool
    run my_function
    [[ "$output" == *"mocked output"* ]]
}

命令桩

#!/usr/bin/env bats

setup() {
    # 创建桩目录
    STUBS_DIR="$TMPDIR/stubs"
    mkdir -p "$STUBS_DIR"

    # 添加到PATH
    export PATH="$STUBS_DIR:$PATH"
}

create_stub() {
    local cmd="$1"
    local output="$2"
    local code="${3:-0}"

    cat > "$STUBS_DIR/$cmd" <<EOF
#!/bin/bash
echo "$output"
exit $code
EOF
    chmod +x "$STUBS_DIR/$cmd"
}

@test "函数使用桩curl工作" {
    create_stub curl "{ \"status\": \"ok\" }" 0
    run my_api_function
    [ "$status" -eq 0 ]
}

变量桩

#!/usr/bin/env bats

@test "函数处理环境覆盖" {
    export MY_SETTING="override_value"
    run my_function
    [ "$status" -eq 0 ]
    [[ "$output" == *"override_value"* ]]
}

@test "函数在变量未设置时使用默认值" {
    unset MY_SETTING
    run my_function
    [ "$status" -eq 0 ]
    [[ "$output" == *"default"* ]]
}

固定装置管理

使用固定装置文件

#!/usr/bin/env bats

# 固定装置目录:tests/fixtures/

setup() {
    FIXTURES_DIR="${BATS_TEST_DIRNAME}/fixtures"
    WORK_DIR=$(mktemp -d)
    export WORK_DIR
}

teardown() {
    rm -rf "$WORK_DIR"
}

@test "处理固定装置文件" {
    # 复制固定装置到工作目录
    cp "$FIXTURES_DIR/input.txt" "$WORK_DIR/input.txt"

    # 运行函数
    run my_process_function "$WORK_DIR/input.txt"

    # 比较输出
    diff "$WORK_DIR/output.txt" "$FIXTURES_DIR/expected_output.txt"
}

动态固定装置生成

#!/usr/bin/env bats

generate_fixture() {
    local lines="$1"
    local file="$2"

    for i in $(seq 1 "$lines"); do
        echo "Line $i content" >> "$file"
    done
}

@test "处理大型输入文件" {
    generate_fixture 1000 "$TMPDIR/large.txt"
    run my_function "$TMPDIR/large.txt"
    [ "$status" -eq 0 ]
    [ "$(wc -l < "$TMPDIR/large.txt")" -eq 1000 ]
}

高级模式

测试错误条件

#!/usr/bin/env bats

@test "函数因缺失文件失败" {
    run my_function "/nonexistent/file.txt"
    [ "$status" -ne 0 ]
    [[ "$output" == *"not found"* ]]
}

@test "函数因无效输入失败" {
    run my_function ""
    [ "$status" -ne 0 ]
}

@test "函数因权限拒绝失败" {
    touch "$TMPDIR/readonly.txt"
    chmod 000 "$TMPDIR/readonly.txt"
    run my_function "$TMPDIR/readonly.txt"
    [ "$status" -ne 0 ]
    chmod 644 "$TMPDIR/readonly.txt"  # 清理
}

@test "函数提供有用的错误消息" {
    run my_function --invalid-option
    [ "$status" -ne 0 ]
    [[ "$output" == *"Usage:"* ]]
}

测试依赖项

#!/usr/bin/env bats

setup() {
    # 检查必需工具
    if ! command -v jq &>/dev/null; then
        skip "jq未安装"
    fi

    export SCRIPT="${BATS_TEST_DIRNAME}/../bin/script.sh"
}

@test "JSON解析工作" {
    skip_if ! command -v jq &>/dev/null
    run my_json_parser '{"key": "value"}'
    [ "$status" -eq 0 ]
}

测试shell兼容性

#!/usr/bin/env bats

@test "脚本在bash中工作" {
    bash "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1
}

@test "脚本在sh(POSIX)中工作" {
    sh "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1
}

@test "脚本在dash中工作" {
    if command -v dash &>/dev/null; then
        dash "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1
    else
        skip "dash未安装"
    fi
}

并行执行

#!/usr/bin/env bats

@test "多个独立操作" {
    run bash -c 'for i in {1..10}; do
        my_operation "$i" &
    done
    wait'
    [ "$status" -eq 0 ]
}

@test "并发文件操作" {
    for i in {1..5}; do
        my_function "$TMPDIR/file$i" &
    done
    wait
    [ -f "$TMPDIR/file1" ]
    [ -f "$TMPDIR/file5" ]
}

测试助手模式

test_helper.sh

#!/usr/bin/env bash

# 导入被测试脚本
export SCRIPT_DIR="${BATS_TEST_DIRNAME%/*}/bin"

# 常用测试工具
assert_file_exists() {
    if [ ! -f "$1" ]; then
        echo "Expected file to exist: $1"
        return 1
    fi
}

assert_file_equals() {
    local file="$1"
    local expected="$2"

    if [ ! -f "$file" ]; then
        echo "File does not exist: $file"
        return 1
    fi

    local actual=$(cat "$file")
    if [ "$actual" != "$expected" ]; then
        echo "File contents do not match"
        echo "Expected: $expected"
        echo "Actual: $actual"
        return 1
    fi
}

# 创建临时测试目录
setup_test_dir() {
    export TEST_DIR=$(mktemp -d)
}

cleanup_test_dir() {
    rm -rf "$TEST_DIR"
}

与CI/CD集成

GitHub Actions工作流

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Install Bats
        run: |
          npm install --global bats

      - name: Run Tests
        run: |
          bats tests/*.bats

      - name: Run Tests with Tap Reporter
        run: |
          bats tests/*.bats --tap | tee test_output.tap

Makefile集成

.PHONY: test test-verbose test-tap

test:
	bats tests/*.bats

test-verbose:
	bats tests/*.bats --verbose

test-tap:
	bats tests/*.bats --tap

test-parallel:
	bats tests/*.bats --parallel 4

coverage: test
	# 可选:生成覆盖率报告

最佳实践

  1. 每个测试只测试一件事 - 单一责任原则
  2. 使用描述性测试名称 - 清楚地说明测试内容
  3. 测试后清理 - 始终在拆卸中删除临时文件
  4. 测试成功和失败路径 - 不要只测试快乐路径
  5. 模拟外部依赖项 - 隔离被测试单元
  6. 使用固定装置处理复杂数据 - 使测试更可读
  7. 在CI/CD中运行测试 - 早期发现回归
  8. 跨shell方言测试 - 确保可移植性
  9. 保持测试快速 - 尽可能并行运行
  10. 记录复杂测试设置 - 解释不寻常模式

资源