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

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

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

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

一、shell 测试的特殊性

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

1.1 副作用是常态

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

bash 复制代码
# 这不是"纯函数",是带副作用的命令链
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 全局状态共享

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

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

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

1.3 状态难清理

matlab 复制代码
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 函数即单元

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

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

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

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

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

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

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

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

2.3 把副作用集中到一处

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

bash 复制代码
# 难测:函数混合了"计算"和"执行"
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 早返回代替嵌套

bash 复制代码
# 难读也难测
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 函数接收文件名作为参数。这意味着测试时可以传任何临时文件,不需要污染真实配置。

bash 复制代码
# 测试时
load_config "/tmp/test_config_$$"

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

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

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

3.1 最基本的 assert 函数

bash 复制代码
# 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 一个完整的测试用例

bash 复制代码
# 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

跑测试:

ini 复制代码
$ 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 失败时整个测试函数立刻停止,可以加个包装器:

bash 复制代码
# 测试函数包装器
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 块:

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

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

四、处理外部依赖

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

4.1 临时目录隔离文件系统

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

bash 复制代码
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:

bash 复制代码
# tests/test_deploy.sh
​
source "$(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 函数,可以用同名的空函数覆盖:

bash 复制代码
# 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 时间。测试时怎么控制?

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

bash 复制代码
# 不好的写法
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 命令。

bash 复制代码
# 提供 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"

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

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

测试时:

ini 复制代码
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封装成函数:

bash 复制代码
# 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 目录

python 复制代码
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

bash 复制代码
#!/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
fi
​
echo "找到 ${#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

跑所有测试:

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

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

5.3 fixtures:测试数据

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

ini 复制代码
# tests/fixtures/config_valid.conf
DB_HOST=localhost
DB_PORT=3306
DB_USER=admin
DB_PASSWORD=secret
bash 复制代码
# tests/test_config.sh
​
test_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 里可以用包装函数模拟:

bash 复制代码
# 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
}

使用:

bash 复制代码
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 端到端脚本测试

bash 复制代码
# tests/test_integration.sh
​
source "$(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 用临时环境跑完整流程

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

bash 复制代码
# tests/test_deploy_integration.sh
​
setup_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 示例:

yaml 复制代码
# .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)的配置大同小异。

相关推荐
柒号华仔1 天前
「速通Shell」Shell 脚本模块化
shell
柒号华仔2 天前
「速通Shell」Shell编程的错误处理与日志
shell
柒号华仔3 天前
「速通Shell」Shell 数组、关联数组与 mapfile
shell
苏灿烤鱼5 天前
一套进程代替八件套,桌面更稳还是单点更大
linux·github·shell
茶本无香5 天前
通用报表自动化框架:Java调用Shell传参执行PostgreSQL SQL模板
java·sql·postgresql·shell
柒号华仔6 天前
「速通Shell」Shell 循环与遍历
shell·编程语言
十里春风_jzh7 天前
Tabby 修改版:更美观的现代终端
shell·tabby
茶本无香7 天前
Java调用Shell脚本执行SQL数据库操作:从入门到实战
java·sql·shell
柒号华仔9 天前
「速通Shell」织线为面,Shell条件测试和判断
shell