强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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 + 导出
测试太慢涉及网络/磁盘标记慢测试,分开运行

18.11 扩展阅读