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
# 可选:生成覆盖率报告
最佳实践
- 每个测试只测试一件事 - 单一责任原则
- 使用描述性测试名称 - 清楚地说明测试内容
- 测试后清理 - 始终在拆卸中删除临时文件
- 测试成功和失败路径 - 不要只测试快乐路径
- 模拟外部依赖项 - 隔离被测试单元
- 使用固定装置处理复杂数据 - 使测试更可读
- 在CI/CD中运行测试 - 早期发现回归
- 跨shell方言测试 - 确保可移植性
- 保持测试快速 - 尽可能并行运行
- 记录复杂测试设置 - 解释不寻常模式
资源
- Bats GitHub: https://github.com/bats-core/bats-core
- Bats文档: https://bats-core.readthedocs.io/
- TAP协议: https://testanything.org/
- 测试驱动开发: https://en.wikipedia.org/wiki/Test-driven_development