前面几篇我们一路从循环、字符串、数组讲到错误处理、模块化。模块化让代码有了清晰的组织,错误处理让脚本靠得住,但还差最后一块------怎么保证改完代码后旧功能还正常? 这就是测试要解决的问题。
写 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 隔离外部命令
测试用了 curl、ssh 这类命令时,不能让测试真的发请求。最简单的方法是替换 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 处理时间依赖
很多脚本依赖 date、sleep、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 的 unittest 有 setUp 和 tearDown 在每个测试前后执行。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在所有主流系统上都有。PATHmock 在所有 POSIX 系统上工作。CI 集成示例用的是 GitHub Actions,其他 CI 系统(GitLab CI、Jenkins)的配置大同小异。