Bash 脚本编写教程 / 18 - 测试
18 - 测试
18.1 为什么需要测试
Bash 脚本也需要测试:
| 没有测试 | 有测试 |
|---|---|
| 改一行怕影响全局 | 改完跑测试,秒知结果 |
| 上线前手动验证 | CI 自动验证 |
| 文档过时 | 测试就是文档 |
| Bug 隐藏到生产环境 | 开发阶段就发现 |
18.2 bats-core 测试框架
安装 bats
# Ubuntu/Debian
sudo apt-get install bats
# macOS
brew install bats-core
# 从源码安装
git clone https://github.com/bats-core/bats-core.git
cd bats-core
sudo ./install.sh /usr/local
第一个 bats 测试
#!/usr/bin/env bats
# test_math.bats
@test "加法运算" {
result=$((2 + 3))
[ "$result" -eq 5 ]
}
@test "字符串拼接" {
result="Hello, $(echo World)"
[ "$result" = "Hello, World" ]
}
@test "文件存在检查" {
[ -f /etc/hosts ]
}
# 运行测试
bats test_math.bats
# 运行目录下所有测试
bats tests/
# TAP 格式输出
bats --tap test_math.bats
bats 断言
#!/usr/bin/env bats
# 加载 bats 辅助库
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
@test "使用 assert" {
run echo "Hello, World!"
assert_success
assert_output "Hello, World!"
}
@test "检查退出码" {
run bash -c "exit 42"
assert_failure
assert_status 42
}
@test "输出包含子串" {
run echo "Hello, World!"
assert_success
assert_output --partial "World"
}
@test "输出匹配正则" {
run date "+%Y-%m-%d"
assert_success
assert_output --regexp '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
}
@test "输出行数" {
run printf "line1\nline2\nline3\n"
assert_success
assert [ ${#lines[@]} -eq 3 ]
}
bats 辅助函数速查
| 函数 | 说明 | 示例 |
|---|---|---|
run | 执行命令并捕获输出/状态 | run ls -la |
assert_success | 断言命令成功(exit 0) | assert_success |
assert_failure | 断言命令失败 | assert_failure |
assert_status N | 断言特定退出码 | assert_status 1 |
assert_output | 断言完整输出 | assert_output "hello" |
assert_output --partial S | 断言包含子串 | assert_output --partial "err" |
assert_output --regexp P | 断言正则匹配 | assert_output --regexp '^[0-9]+' |
assert_line N "text" | 断言第N行 | assert_line 0 "hello" |
refute_output | 断言无输出 | refute_output |
assert [ condition ] | 自定义断言 | assert [ -f "$file" ] |
18.3 bats 测试结构
#!/usr/bin/env bats
# ---- setup/teardown ----
setup() {
# 每个测试用例之前执行
export TEST_DIR=$(mktemp -d)
export PATH="$BATS_TEST_DIRNAME/../src:$PATH"
}
teardown() {
# 每个测试用例之后执行
rm -rf "$TEST_DIR"
}
# ---- 测试用例 ----
@test "创建文件" {
touch "$TEST_DIR/test.txt"
[ -f "$TEST_DIR/test.txt" ]
}
@test "写入并读取" {
echo "hello" > "$TEST_DIR/data.txt"
result=$(cat "$TEST_DIR/data.txt")
[ "$result" = "hello" ]
}
@test "测试函数调用" {
source "$BATS_TEST_DIRNAME/../src/utils.sh"
result=$(to_upper "hello")
[ "$result" = "HELLO" ]
}
18.4 测试实际脚本
假设有一个要测试的脚本:
#!/bin/bash
# src/validate.sh —— 要测试的脚本
validate_email() {
local email="$1"
[[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]
}
validate_port() {
local port="$1"
[[ "$port" =~ ^[0-9]+$ ]] && ((port >= 1 && port <= 65535))
}
validate_ip() {
local ip="$1"
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
IFS='.' read -r a b c d <<< "$ip"
((a <= 255 && b <= 255 && c <= 255 && d <= 255))
}
to_upper() {
echo "${1^^}"
}
to_lower() {
echo "${1,,}"
}
对应的测试文件:
#!/usr/bin/env bats
# tests/test_validate.bats
setup() {
source "$BATS_TEST_DIRNAME/../src/validate.sh"
}
# ---- validate_email ----
@test "valid email: simple" {
run validate_email "[email protected]"
assert_success
}
@test "valid email: with dots and plus" {
run validate_email "[email protected]"
assert_success
}
@test "invalid email: missing @" {
run validate_email "userexample.com"
assert_failure
}
@test "invalid email: missing domain" {
run validate_email "user@"
assert_failure
}
@test "invalid email: empty string" {
run validate_email ""
assert_failure
}
# ---- validate_port ----
@test "valid port: 80" {
run validate_port "80"
assert_success
}
@test "valid port: 65535" {
run validate_port "65535"
assert_success
}
@test "invalid port: 0" {
run validate_port "0"
assert_failure
}
@test "invalid port: 65536" {
run validate_port "65536"
assert_failure
}
@test "invalid port: non-numeric" {
run validate_port "abc"
assert_failure
}
# ---- validate_ip ----
@test "valid IP: 192.168.1.1" {
run validate_ip "192.168.1.1"
assert_success
}
@test "valid IP: 0.0.0.0" {
run validate_ip "0.0.0.0"
assert_success
}
@test "valid IP: 255.255.255.255" {
run validate_ip "255.255.255.255"
assert_success
}
@test "invalid IP: 256.1.1.1" {
run validate_ip "256.1.1.1"
assert_failure
}
@test "invalid IP: 1.2.3" {
run validate_ip "1.2.3"
assert_failure
}
# ---- to_upper/to_lower ----
@test "to_upper: hello -> HELLO" {
result=$(to_upper "hello")
[ "$result" = "HELLO" ]
}
@test "to_lower: HELLO -> hello" {
result=$(to_lower "HELLO")
[ "$result" = "hello" ]
}
18.5 shunit2 测试框架
#!/bin/bash
# test_shunit2.sh
# 加载 shunit2
source /usr/local/lib/shunit2
# setup/tearDown
setUp() {
TEST_DIR=$(mktemp -d)
}
tearDown() {
rm -rf "$TEST_DIR"
}
# 测试用例
test_file_creation() {
touch "$TEST_DIR/test.txt"
assertTrue "文件应被创建" "[ -f '$TEST_DIR/test.txt' ]"
}
test_string_operations() {
result=$(echo "hello" | tr '[:lower:]' '[:upper:]')
assertEquals "应转为大写" "HELLO" "$result"
}
test_numeric_comparison() {
result=$((2 + 3))
assertEquals "2+3应等于5" "5" "$result"
}
test_true_condition() {
assertTrue "应为真" "[ 1 -eq 1 ]"
}
test_false_condition() {
assertFalse "应为假" "[ 1 -eq 2 ]"
}
test_null_value() {
value=""
assertNull "应为空" "$value"
}
test_not_null() {
value="hello"
assertNotNull "不应为空" "$value"
}
# 运行所有测试
# shunit2 会自动发现并运行以 test_ 开头的函数
18.6 测试目录结构
project/
├── src/
│ ├── validate.sh
│ └── utils.sh
├── tests/
│ ├── test_helper/
│ │ └── bats-support/ # bats 辅助库
│ ├── test_validate.bats
│ └── test_utils.bats
├── Makefile
└── .github/
└── workflows/
└── test.yml
Makefile
.PHONY: test lint
test:
@echo "运行测试..."
bats tests/
lint:
@echo "运行 ShellCheck..."
shellcheck -x src/*.sh
ci: lint test
@echo "所有检查通过!"
18.7 CI 集成(GitHub Actions)
# .github/workflows/test.yml
name: Shell 脚本测试
on:
push:
paths: ['**.sh']
pull_request:
paths: ['**.sh']
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 安装依赖
run: |
sudo apt-get update
sudo apt-get install -y bats shellcheck
- name: ShellCheck 静态分析
run: |
shellcheck -x src/*.sh
- name: 运行测试
run: bats tests/
- name: 生成测试报告
if: always()
run: |
bats --tap tests/ > test-report.tap
18.8 TDD(测试驱动开发)
# TDD 流程:红 → 绿 → 重构
# 1. 先写测试(红色 —— 测试失败)
@test "extract_domain: 从 URL 提取域名" {
run extract_domain "https://www.example.com/path"
assert_success
assert_output "www.example.com"
}
# 运行:bats → FAIL(函数不存在)
# 2. 写最少代码让测试通过(绿色)
extract_domain() {
local url="$1"
echo "$url" | sed -E 's|^https?://([^/]+).*|\1|'
}
# 运行:bats → PASS
# 3. 重构代码
extract_domain() {
local url="$1"
url="${url#*://}" # 去掉协议
echo "${url%%/*}" # 去掉路径
}
# 运行:bats → PASS(重构后测试仍通过)
# 4. 添加更多测试用例
@test "extract_domain: 无路径" {
run extract_domain "https://example.com"
assert_success
assert_output "example.com"
}
@test "extract_domain: HTTP" {
run extract_domain "http://example.com/path"
assert_success
assert_output "example.com"
}
@test "extract_domain: 带端口" {
run extract_domain "https://example.com:8080/path"
assert_success
assert_output "example.com:8080"
}
18.9 业务场景:配置文件验证器测试
#!/bin/bash
# src/config_validate.sh
validate_config() {
local config_file="$1"
[[ -f "$config_file" ]] || { echo "文件不存在"; return 1; }
[[ -r "$config_file" ]] || { echo "文件不可读"; return 1; }
# 检查必需字段
local required_fields=("host" "port" "database")
for field in "${required_fields[@]}"; do
if ! grep -q "^${field}=" "$config_file"; then
echo "缺少必需字段: $field"
return 1
fi
done
# 验证端口范围
local port
port=$(grep "^port=" "$config_file" | cut -d= -f2 | tr -d ' ')
if [[ -n "$port" ]]; then
if ! [[ "$port" =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then
echo "无效端口: $port"
return 1
fi
fi
echo "配置验证通过"
return 0
}
#!/usr/bin/env bats
# tests/test_config_validate.bats
setup() {
source "$BATS_TEST_DIRNAME/../src/config_validate.sh"
TEST_DIR=$(mktemp -d)
}
teardown() {
rm -rf "$TEST_DIR"
}
@test "文件不存在" {
run validate_config "/nonexistent/config.ini"
assert_failure
assert_output --partial "文件不存在"
}
@test "完整配置通过验证" {
cat > "$TEST_DIR/config.ini" << 'EOF'
host=localhost
port=8080
database=myapp
EOF
run validate_config "$TEST_DIR/config.ini"
assert_success
assert_output "配置验证通过"
}
@test "缺少 host 字段" {
cat > "$TEST_DIR/config.ini" << 'EOF'
port=8080
database=myapp
EOF
run validate_config "$TEST_DIR/config.ini"
assert_failure
assert_output --partial "缺少必需字段: host"
}
@test "端口超出范围" {
cat > "$TEST_DIR/config.ini" << 'EOF'
host=localhost
port=99999
database=myapp
EOF
run validate_config "$TEST_DIR/config.ini"
assert_failure
assert_output --partial "无效端口"
}
@test "端口非数字" {
cat > "$TEST_DIR/config.ini" << 'EOF'
host=localhost
port=abc
database=myapp
EOF
run validate_config "$TEST_DIR/config.ini"
assert_failure
}
18.10 注意事项
| 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 测试依赖外部状态 | 文件/网络/服务 | 使用 mock 和 fixture |
| setup/teardown 副作用 | 测试间相互影响 | 确保完全隔离 |
run 吞掉退出码 | 需要检查 $status | 使用 assert_success/assert_failure |
| bats 子 Shell 问题 | 变量作用域 | 使用 source + 导出 |
| 测试太慢 | 涉及网络/磁盘 | 标记慢测试,分开运行 |