「速通Shell」Shell 脚本测试详解

27 阅读9分钟

前面几篇我们一路从循环、字符串、数组讲到错误处理、模块化。模块化让代码有了清晰的组织,错误处理让脚本靠得住,但还差最后一块——怎么保证改完代码后旧功能还正常? 这就是测试要解决的问题。

写 shell 脚本的人,对测试的态度两极分化严重。一类是从来不测,认为脚本就那么几行,跑通了就完了;另一类是把它当 Python 写,动不动就上完整测试框架。其实两端的极端都不可取——shell 脚本的测试有它自己的逻辑。

这一篇我们从写可测试的代码开始,经过自己写一个 assert 框架、处理外部依赖,最后到集成测试与 CI 集成。整篇会用上一篇的部署工具作为实战例子,演示怎么给真实项目加测试。

一、shell 测试的特殊性

在聊具体怎么做之前,我们先理解 shell 脚本测试的独特之处。这些独特性决定了 shell 测试的思路跟 Python/Go 完全不同。

1.1 副作用是常态

Python 函数默认是纯的——给定输入返回输出,不动外部世界。shell 函数不一样,很多看起来是函数的东西本质是命令调用:

# 这不是"纯函数",是带副作用的命令链
deploy_to_production() {
    ssh prod-server "systemctl restart myapp"   # 远程命令
    rm -rf /tmp/deploy_cache                     # 删文件
    curl -X POST https://api.example.com/log    # 调 API
}

这种函数很难直接测试——你调一次它就真去连服务器、删文件、发请求了。shell 测试的核心挑战就是把这些副作用隔离开。

1.2 全局状态共享

# 脚本顶部定义的变量
ENV="production"
DB_HOST="localhost"# 中间的函数引用了它们
connect_db() {
    echo "Connecting to $DB_HOST"
}

这个 connect_db 函数看起来是纯函数,实际依赖了文件作用域的 $DB_HOST。不设置这个变量就调它,行为完全不可预期。

Python 的类有 self 隔离,Go 的闭包有显式捕获,shell 什么都是全局的。测试 shell 函数必须显式控制输入和环境。

1.3 状态难清理

test_log_writes_to_file() {
    log "test message" /var/log/test.log
    cat /var/log/test.log
}

第一次跑没问题,第二次跑因为文件已存在可能就出问题了;测试完了 /var/log/test.log 还在污染系统。shell 测试需要严格的测试间隔离。

1.4 退出码的非黑即白

shell 里成功和失败是单一信号——退出码 0 或非 0。这跟 Python 的异常、Go 的 error 不同,没有半成功或特定错误类型。要区分找不到文件失败和权限不足失败,得自己定义退出码或捕获 stderr。

理解了这些特殊性,我们就有了一个心智基础。shell 测试不是套个测试框架那么简单,它要解决三个问题:怎么隔离副作用、怎么控制输入、怎么表达"预期失败"

二、写可测试的代码

代码可测试不是测试阶段才考虑的事情——它是设计阶段就该想清楚的。这一节讲怎么写容易被测的 shell 函数。

2.1 函数即单元

最常见的反模式是脚本式——所有逻辑写在文件顶层,没有函数封装:

# 不可测试的写法
LOG_LEVEL=INFO
load_config() { ... }
config=$(load_config)
if [[ "$config" == "production" ]]; then
    ssh prod ...
fi

这种代码没法单独测 load_config 的某段逻辑——因为它执行了副作用。改成函数式:

# 可测试的写法
load_config() {
    local file=$1
    # ... 只做"读+解析"
    echo "$parsed_config"
}
​
if [[ "$(load_config env.conf)" == "production" ]]; then
    ssh prod ...
fi

把所有做事的代码包进函数,每个函数只做一件事,顶层只负责调用+编排。这样每个函数可以独立测试。

2.2 接受输入而不是依赖全局

# 难测试:依赖全局变量
deploy() {
    ssh "$DEPLOY_HOST" "systemctl restart myapp"
}
​
# 易测试:显式参数
deploy() {
    local host=$1
    ssh "$host" "systemctl restart myapp"
}

第二种写法更易测——传不同 host 就能测不同分支,不需要改全局状态。写函数时养成所有外部依赖都从参数传的习惯。

2.3 把副作用集中到一处

如果一个函数同时做了算东西和做事情,测起来很痛苦。把它们分开:

# 难测:函数混合了"计算"和"执行"
deploy_all() {
    local servers=$(list_servers)
    for s in $servers; do
        deploy $s
    done
}
​
# 易测:先计算再执行,分开测
get_servers_to_deploy() {
    local servers=$(list_servers)
    echo "$servers"
}
​
deploy() {
    local host=$1
    ssh "$host" "systemctl restart myapp"
}
​
# 顶层编排(集成测试覆盖)
for s in $(get_servers_to_deploy); do
    deploy $s
done

get_servers_to_deploy 是纯计算(无副作用),deploy 是有副作用的命令,两者分开测:get_servers_to_deploy 测逻辑,deploy 测集成行为。

2.4 早返回代替嵌套

# 难读也难测
process_file() {
    if [ -f "$1" ]; then
        if [ -r "$1" ]; then
            # ... 实际逻辑
            return 0
        else
            return 1
        fi
    else
        return 1
    fi
}
​
# 易读也易测
process_file() {
    local file=$1
    [ -f "$file" ] || return 1
    [ -r "$file" ] || return 1
    # ... 实际逻辑
    return 0
}

第二种写法叫"早返回"(early return)。每种失败场景一行就能测——比如 process_file "/nonexistent" 测的就是文件不存在的失败路径。

2.5 配置文件用环境变量或参数传入

上一篇我们的 load_config 函数接收文件名作为参数。这意味着测试时可以传任何临时文件,不需要污染真实配置。

# 测试时
load_config "/tmp/test_config_$$"

$$ 是进程 ID,每个测试用例的文件都是独立的。

三、自己写一个轻量 assert 框架

shell 测试的核心是判断实际行为是否等于预期行为。这个判断需要一组"断言"函数。Python 有 unittest.assertEqual,shell 里我们就自己写。

3.1 最基本的 assert 函数

# tests/lib_test.sh - 测试工具库
[[ -n "${_LIB_TEST_LOADED:-}" ]] && return
_LIB_TEST_LOADED=1
​
# 测试统计
TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0
FAILED_TESTS=()
​
# ============================================================
# 断言函数
# ============================================================# 通用失败处理
_assert_fail() {
    local test_name=$1
    local message=$2
    local actual=$3
    local expected=$4
​
    echo "  ✗ $test_name" >&2
    echo "    消息: $message" >&2
    if [[ -n "$expected" ]]
    then
        echo "    期望: $expected" >&2
        echo "    实际: $actual" >&2
    fi
    FAILED_TESTS+=("$test_name")
    ((TESTS_FAILED++)) || true
}
​
# assert_eq <测试名> <实际值> <期望值> [消息]
assert_eq() {
    local test_name=$1
    local actual=$2
    local expected=$3
    local message=${4:-}
​
    ((TESTS_RUN++)) || true
​
    if [[ "$actual" == "$expected" ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "$actual" "$expected"
    fi
}
​
# assert_contains <测试名> <实际值> <期望子串> [消息]
assert_contains() {
    local test_name=$1
    local actual=$2
    local expected=$3
    local message=${4:-}
​
    ((TESTS_RUN++)) || true
​
    if [[ "$actual" == *"$expected"* ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "$actual" "包含 '$expected'"
    fi
}
​
# assert_success <测试名> <退出码> [消息]
assert_success() {
    local test_name=$1
    local actual=$2
    local message=${3:-}
​
    ((TESTS_RUN++)) || true
​
    if [[ "$actual" -eq 0 ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "$actual" "0"
    fi
}
​
# assert_failure <测试名> <退出码> [消息]
assert_failure() {
    local test_name=$1
    local actual=$2
    local message=${3:-}
​
    ((TESTS_RUN++)) || true
​
    if [[ "$actual" -ne 0 ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "$actual" "非零"
    fi
}
​
# assert_true <测试名> <实际值> [消息]
assert_true() {
    local test_name=$1
    local actual=$2
    local message=${3:-}
​
    ((TESTS_RUN++)) || true
​
    if [[ "$actual" == "true" || "$actual" == "0" ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "$actual" "true"
    fi
}
​
# assert_file_exists <测试名> <文件路径> [消息]
assert_file_exists() {
    local test_name=$1
    local file=$2
    local message=${3:-}
​
    ((TESTS_RUN++)) || true
​
    if [[ -f "$file" ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "文件不存在: $file" "文件存在"
    fi
}
​
# ============================================================
# 测试汇总
# ============================================================
print_summary() {
    echo "" >&2
    echo "========================================" >&2
    echo "测试结果" >&2
    echo "========================================" >&2
    echo "运行: $TESTS_RUN, 通过: $TESTS_PASSED, 失败: $TESTS_FAILED" >&2
​
    if [[ $TESTS_FAILED -gt 0 ]]
    then
        echo "" >&2
        echo "失败的测试:" >&2
        for t in "${FAILED_TESTS[@]}"
        do
            echo "  - $t" >&2
        done
        return 1
    fi
​
    echo "全部通过!" >&2
    return 0
}

这个 assert 库覆盖了 shell 测试里 90% 的断言需求:

  • assert_eq:值相等
  • assert_contains:包含子串
  • assert_success / assert_failure:退出码判断
  • assert_true:布尔判断
  • assert_file_exists:文件存在
  • print_summary:汇总

所有错误信息输出到 stderr(>&2),不影响 stdout 拿测试结果。所有测试函数都把测试结果累积到全局变量里(这是 shell 测试的妥协——必须有共享状态)。

3.2 一个完整的测试用例

# tests/test_log.sh - 日志库测试source "$(dirname "${BASH_SOURCE[0]}")/../lib/lib_log.sh"
source "$(dirname "${BASH_SOURCE[0]}")/lib_test.sh"# 测试 1: log_info 输出格式正确
test_log_info_format() {
    local output
    output=$(LOG_LEVEL=INFO log_info "test message" 2>&1)
    assert_contains "log_info 包含时间戳" "$output" "["
    assert_contains "log_info 包含级别" "$output" "[INFO]"
    assert_contains "log_info 包含消息" "$output" "test message"
}
​
# 测试 2: 日志级别过滤
test_log_level_filter() {
    # DEBUG 级别在默认 LOG_LEVEL=INFO 下应该不输出
    local output
    output=$(LOG_LEVEL=INFO log_debug "debug message" 2>&1)
    assert_eq "LOG_LEVEL=INFO 时 debug 不输出" "$output" ""
​
    # 但当 LOG_LEVEL=DEBUG 时应该输出
    output=$(LOG_LEVEL=DEBUG log_debug "debug message" 2>&1)
    assert_contains "LOG_LEVEL=DEBUG 时 debug 输出" "$output" "debug message"
}
​
# 测试 3: 日志输出到文件
test_log_to_file() {
    local tmpfile="/tmp/test_log_$$"
    LOG_LEVEL=INFO LOG_FILE="$tmpfile" log_info "to file" 2>/dev/null
​
    assert_file_exists "日志文件被创建" "$tmpfile"
​
    local content
    content=$(cat "$tmpfile")
    assert_contains "文件包含消息" "$content" "to file"
​
    rm -f "$tmpfile"
}
​
# ============================================================
# 测试入口
# ============================================================
echo "运行 test_log.sh ..." >&2
​
test_log_info_format
test_log_level_filter
test_log_to_file
​
print_summary

跑测试:

$ bash tests/test_log.sh
运行 test_log.sh ...
  ✓ log_info 包含时间戳
  ✓ log_info 包含级别
  ✓ log_info 包含消息
  ✓ LOG_LEVEL=INFO 时 debug 不输出
  ✓ LOG_LEVEL=DEBUG 时 debug 输出
  ✓ 日志文件被创建
  ✓ 文件包含消息
========================================
测试结果
========================================
运行: 7, 通过: 7, 失败: 0
全部通过!

跟 Python 的 pytest 比,这个测试框架丑得多——没有自动发现、没有参数化、没有 fixture。但它能用,日常 shell 测试够用了。

3.3 test 包装器

如果想让某个 assert 失败时整个测试函数立刻停止,可以加个包装器:

# 测试函数包装器
run_test() {
    local test_func=$1
​
    echo "" >&2
    echo "[$test_func]" >&2
​
    # 每个测试前重置失败计数(局部)
    local failed_before=$TESTS_FAILED
​
    "$test_func"
​
    local failed_after=$TESTS_FAILED
    if [[ $failed_after -gt $failed_before ]]
    then
        echo "  测试 $test_func 有失败" >&2
    fi
}

或者用 set +e 包裹单个 assert 块:

test_something() {
    set +e
    assert_eq "case 1" "a" "b"   # 这个失败不退出
    assert_eq "case 2" "c" "d"   # 这个继续测
    set -e
}

shell 测试的控制流比 Python 麻烦得多,但只要你写的函数小、断言集中,复杂度不会爆。

四、处理外部依赖

shell 函数的外部依赖主要分三类:文件系统网络外部命令。这一节我们看怎么假装这些依赖,让测试跑得又快又稳。

4.1 临时目录隔离文件系统

测试涉及文件时,永远用临时目录,不要污染真实文件系统:

setup_tmpdir() {
    TEST_TMPDIR=$(mktemp -d /tmp/shell_test.XXXXXX)
    trap "rm -rf '$TEST_TMPDIR'" EXIT
}
​
# 在每个测试函数开头调用
test_something() {
    setup_tmpdir
    # 在 $TEST_TMPDIR 里建文件、操作、断言
    touch "$TEST_TMPDIR/test_file"
    assert_file_exists "文件被创建" "$TEST_TMPDIR/test_file"
}

mktemp -d 创建独立目录,$$ 保证唯一性,trap "rm -rf" EXIT 清理。这是 shell 测试的标准卫生。

4.2 PATH 隔离外部命令

测试用了 curlssh 这类命令时,不能让测试真的发请求。最简单的方法是替换 PATH:

# tests/test_deploy.shsource "$(dirname "${BASH_SOURCE[0]}")/../lib/lib_common.sh"
source "$(dirname "${BASH_SOURCE[0]}")/lib_test.sh"# 创建 mock 工具目录
MOCK_BIN=$(mktemp -d)
​
# 创建假的 curl(不真发请求,只记录调用)
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
echo "MOCK curl called: $*" >> /tmp/mock_calls.log
echo "Mock response"
EOF
chmod +x "$MOCK_BIN/curl"# 把 mock 目录加到 PATH 最前面
export PATH="$MOCK_BIN:$PATH"# 测试时调用 curl,实际执行的是 mock 版本
test_deploy_calls_api() {
    rm -f /tmp/mock_calls.log
    deploy_to_api "https://example.com" "data"
    assert_file_exists "mock 日志被创建" /tmp/mock_calls.log
    assert_contains "curl 被调用" "$(cat /tmp/mock_calls.log)" "curl"
}
​
test_deploy_calls_api

PATH="$MOCK_BIN:$PATH" 让脚本里的 curl 命令实际执行的是我们写的 mock 脚本。这是 shell 测试的核心技巧——通过修改环境变量,替换掉任何外部命令。

4.3 函数级别的 mock

如果被测函数调用了其他 shell 函数,可以用同名的空函数覆盖:

# tests/test_deploy.sh# 真实函数:会去连服务器
deploy_to_server() {
    local host=$1
    ssh "$host" "systemctl restart myapp"
}
​
# 测试时覆盖:什么都不做,只记录参数
deploy_to_server() {
    echo "MOCK deploy_to_server: $1" >> /tmp/mock_calls.log
    return 0
}
​
# 现在调用 deploy_to_server 执行的是 mock 版本
test_deploy() {
    rm -f /tmp/mock_calls.log
    main_deploy "dev"
    assert_contains "deploy_to_server 被调用" "$(cat /tmp/mock_calls.log)" "deploy_to_server"
}

但这有个问题:被覆盖的函数必须先 source 或定义过。如果原函数是在被测脚本里定义的,测试文件要先 source 那个脚本,然后重新定义同名函数。

4.4 处理时间依赖

很多脚本依赖 datesleep、cron 时间。测试时怎么控制?

方案一:接受一个"时间函数"参数。

# 不好的写法
is_expired() {
    local expiry=$1
    [[ $(date +%s) -gt $expiry ]]
}
​
# 好的写法
is_expired() {
    local expiry=$1
    local now=${2:-$(date +%s)}   # 默认用真实时间,但可以传入
    [[ "$now" -gt "$expiry" ]]
}
​
# 测试
test_is_expired() {
    # 模拟当前时间是 2024-01-01
    local fake_now=$(date -d "2024-01-01" +%s)
    local expiry=$(date -d "2024-12-31" +%s)
​
    assert_false "未过期" "$(is_expired "$expiry" "$fake_now" && echo true || echo false)"
}

方案二:用环境变量覆盖 date 命令。

# 提供 mock 的 date
cat > "$MOCK_BIN/date" <<'EOF'
#!/bin/bash
# 测试时固定返回 2024-01-01
case "$*" in
    "+%s")
        echo "1704067200"  # 2024-01-01 00:00:00 UTC
        ;;
    *)
        /usr/bin/date "$@"
        ;;
esac
EOF
chmod +x "$MOCK_BIN/date"

方案三:把时间作为变量存到全局。

CURRENT_TIME=${CURRENT_TIME:-$(date +%s)}
is_expired() {
    local expiry=$1
    [[ "$CURRENT_TIME" -gt "$expiry" ]]
}

测试时:

CURRENT_TIME="1704067200" is_expired "..."

三种方案各有适用场景,第一种最干净(函数显式接受时间参数),第三种最简单(适合小脚本),第二种最灵活(适合时间依赖复杂的场景)。

4.5 处理网络和数据库

网络和数据库是 shell 测试里最难完全 mock的——它们有协议、有状态、有副作用。常见做法:

网络(curl、ssh、scp) :用 PATH mock(4.2 节)。或者用 nc 启动本地假服务。

数据库(mysql、psql) :同样 PATH mock,模拟返回结果。

第三方 API:用 PATH mock 替换 curl。复杂的接口可以用 python -m http.server 启本地假服务。

实操建议:业务脚本里把调用外部 API封装成函数:

# lib_api.sh
api_call() {
    local endpoint=$1
    curl -s "https://api.example.com/$endpoint"
}
​
# 测试时
api_call() {
    echo '{"status":"ok","data":[]}'   # mock 返回
}

单一封装点让 mock 简单太多——你只需要覆盖一个函数,就能假装调过 API。

五、测试组织

随着测试用例增多,怎么组织是个问题。这一节讲一个小而全的测试目录结构。

5.1 标准 tests 目录

project/
├── bin/
├── lib/
├── src/
└── tests/
    ├── lib_test.sh              # 测试工具库
    ├── test_log.sh              # log 库测试
    ├── test_config.sh           # config 库测试
    ├── test_deploy.sh           # deploy 业务测试
    ├── test_integration.sh      # 集成测试
    ├── fixtures/                # 测试数据
    │   ├── config_valid.conf
    │   └── config_invalid.conf
    └── run_all_tests.sh         # 测试入口

test_*.sh 命名让测试自动发现变得简单——任何 test_*.sh 文件都是一个测试套件。

5.2 测试入口:run_all_tests.sh

#!/bin/bash
# tests/run_all_tests.sh - 运行所有测试set -euo pipefail
​
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
​
# 加载测试工具
source "$SCRIPT_DIR/lib_test.sh"# 找到所有测试文件
test_files=("$SCRIPT_DIR"/test_*.sh)
​
if [[ ${#test_files[@]} -eq 0 ]]
then
    echo "没找到测试文件" >&2
    exit 1
fiecho "找到 ${#test_files[@]} 个测试文件" >&2
echo "" >&2
​
# 累计统计
TOTAL_RUN=0
TOTAL_PASSED=0
TOTAL_FAILED=0
​
# 运行每个测试文件
for test_file in "${test_files[@]}"
do
    if [[ "$(basename "$test_file")" = "lib_test.sh" || "$(basename "$test_file")" = "run_all_tests.sh" ]]
    then
        continue
    fi
​
    echo "运行 $(basename "$test_file") ..." >&2
    echo "----------------------------------------" >&2
​
    # 重置计数器
    TESTS_RUN=0
    TESTS_PASSED=0
    TESTS_FAILED=0
    FAILED_TESTS=()
​
    # 跑测试
    if bash "$test_file"
    then
        :  # 测试文件本身成功
    else
        :  # print_summary 会返回非零
    fi
​
    TOTAL_RUN=$((TOTAL_RUN + TESTS_RUN))
    TOTAL_PASSED=$((TOTAL_PASSED + TESTS_PASSED))
    TOTAL_FAILED=$((TOTAL_FAILED + TESTS_FAILED))
done# 打印总结
echo "" >&2
echo "========================================" >&2
echo "总测试结果" >&2
echo "========================================" >&2
echo "运行: $TOTAL_RUN, 通过: $TOTAL_PASSED, 失败: $TOTAL_FAILED" >&2
​
if [[ $TOTAL_FAILED -gt 0 ]]
then
    exit 1
fi
exit 0

跑所有测试:

$ bash tests/run_all_tests.sh
找到 4 个测试文件
​
运行 test_log.sh ...
----------------------------------------
  ✓ log_info 包含时间戳
  ...
​
========================================
总测试结果
========================================
运行: 28, 通过: 27, 失败: 1

任意测试失败,整个脚本退出非零——这正是 CI 系统所需要的信号。

5.3 fixtures:测试数据

复杂的测试需要测试夹具——预先准备好的输入数据:

# tests/fixtures/config_valid.conf
DB_HOST=localhost
DB_PORT=3306
DB_USER=admin
DB_PASSWORD=secret
# tests/test_config.shtest_load_valid_config() {
    source "$(dirname "${BASH_SOURCE[0]}")/../lib/lib_config.sh"
​
    local config="$SCRIPT_DIR/fixtures/config_valid.conf"
    load_config "$config"
​
    assert_eq "DB_HOST 加载正确" "$DB_HOST" "localhost"
    assert_eq "DB_PORT 加载正确" "$DB_PORT" "3306"
    assert_eq "DB_USER 加载正确" "$DB_USER" "admin"
}
​
test_load_missing_config() {
    source "$(dirname "${BASH_SOURCE[0]}")/../lib/lib_config.sh"
​
    local missing="/tmp/nonexistent_$$"
    local exit_code=0
    load_config "$missing" || exit_code=$?
​
    assert_failure "加载不存在的配置应该失败" "$exit_code"
}

fixture 文件只放在 tests/fixtures/ 里,生产代码和测试代码完全分离

5.4 setUp / tearDown 模式

Python 的 unittestsetUptearDown 在每个测试前后执行。shell 里可以用包装函数模拟:

# tests/lib_test.sh# 测试计数器
TEST_COUNT=0
​
# 每个测试前的初始化
setUp() {
    TEST_COUNT=$((TEST_COUNT + 1))
    TEST_TMPDIR=$(mktemp -d /tmp/shell_test.XXXXXX)
    export TEST_TMPDIR
}
​
# 每个测试后的清理
tearDown() {
    if [[ -n "$TEST_TMPDIR" && -d "$TEST_TMPDIR" ]]
    then
        rm -rf "$TEST_TMPDIR"
    fi
}
​
# 测试包装器
run_test() {
    local test_func=$1
    setUp
    "$test_func"
    local exit_code=$?
    tearDown
    return $exit_code
}

使用:

test_log_writes_to_file() {
    local logfile="$TEST_TMPDIR/test.log"
    LOG_FILE="$logfile" log_info "hello"
​
    assert_file_exists "日志文件被创建" "$logfile"
}
​
# 注册测试
run_test test_log_writes_to_file

run_test 自动处理 setUp 和 tearDown,每个测试都跑在独立的临时目录里。

六、集成测试

单元测试验证函数对不对,集成测试验证组合起来能不能跑。这一节看几个典型的集成测试场景。

6.1 端到端脚本测试

# tests/test_integration.shsource "$(dirname "${BASH_SOURCE[0]}")/lib_test.sh"
source "$(dirname "${BASH_SOURCE[0]}")/../lib/lib_common.sh"
​
PROJECT_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
SCRIPT="$PROJECT_ROOT/bin/myscript"test_help_command() {
    local output
    output=$("$SCRIPT" --help 2>&1)
    local exit_code=$?
​
    assert_success "--help 退出码为 0" "$exit_code"
    assert_contains "帮助信息提到用法" "$output" "用法"
}
​
test_missing_args() {
    local output
    output=$("$SCRIPT" 2>&1)
    local exit_code=$?
​
    assert_failure "无参数应该失败" "$exit_code"
    assert_contains "错误信息提到用法" "$output" "用法"
}
​
test_version_flag() {
    local output
    output=$("$SCRIPT" --version 2>&1)
​
    assert_contains "版本号被输出" "$output" "1.0.0"
}
​
test_help_command
test_missing_args
test_version_flag
​
print_summary

集成测试不 mock,直接调脚本。它验证的是脚本作为整体能跑,单元测试覆盖不到的边界条件在这里兜底

6.2 用临时环境跑完整流程

部署类脚本的集成测试可以这样设计:用临时目录当生产环境,跑完整的部署流程,验证最终状态。

# tests/test_deploy_integration.shsetup_test_env() {
    # 创建模拟的"远端服务器"目录
    export FAKE_REMOTE=$(mktemp -d)
    mkdir -p "$FAKE_REMOTE/app"
    echo "v1.0.0" > "$FAKE_REMOTE/app/VERSION"
​
    # 替换 SSH 为本地 cp
    cat > "$FAKE_REMOTE/ssh" <<EOF
#!/bin/bash
# mock ssh: 第一个参数是"主机",忽略它,执行后面的命令
shift
eval "$*"
EOF
    chmod +x "$FAKE_REMOTE/ssh"
    export PATH="$FAKE_REMOTE:$PATH"
}
​
test_full_deploy_workflow() {
    setup_test_env
​
    local config="$TEST_TMPDIR/deploy.conf"
    cat > "$config" <<EOF
DEPLOY_HOST=fake-host
DEPLOY_USER=tester
APP_VERSION=v1.2.0
EOF
​
    # 调用部署脚本
    "$PROJECT_ROOT/bin/deploy" deploy dev
​
    # 验证最终状态
    local version
    version=$(cat "$FAKE_REMOTE/app/VERSION")
    assert_eq "版本被更新" "$version" "v1.2.0"
}

集成测试比单元测试慢,但它验证的是真实场景下能跑。日常开发可以只跑单元测试,发布前跑一遍集成测试。

6.3 在 CI 里跑测试

最后一步是把测试接到 CI 系统里。GitHub Actions 示例:

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Run shell tests
        run: bash tests/run_all_tests.sh
      - name: Check exit code
        run: exit 0

bash tests/run_all_tests.sh 非零退出时,CI 任务直接标红。任何合并到主干的 PR 都必须通过测试。

七、总结

shell 脚本测试跟其他语言不一样——它没有像 pytest 那样开箱即用的框架,但核心思路是一致的:写可测试的代码、用 assert 表达预期、隔离副作用、组织测试、接入 CI。这一篇我们覆盖了完整流程:

  • 可测试的代码:函数化、显式参数、副作用分离、早返回
  • assert 函数:自己写一套轻量的断言库,足够覆盖 90% 场景
  • 外部依赖:用临时目录、PATH mock、函数覆盖、时间注入
  • 测试组织:tests 目录、fixtures、run_all_tests.sh 入口
  • 集成测试:端到端验证、CI 集成

最重要的认知是:shell 测试不是套框架那么简单,它要解决副作用隔离、状态控制、退出码表达这几个特殊问题。理解了这些,自己的 assert 库、自己的 mock 方案、自己的测试组织——都是水到渠成的事。


本文示例在 GNU bash 4.3+ 环境下测试通过。mktemp -d 在所有主流系统上都有。PATH mock 在所有 POSIX 系统上工作。CI 集成示例用的是 GitHub Actions,其他 CI 系统(GitLab CI、Jenkins)的配置大同小异。