深色模式
脚本单元测试
摘要:脚本也是代码,同样需要检查与测试。本文用
shellcheck做静态检查(发现引号、未定义变量等隐患),用bats给 Shell 函数写单元测试,并给出让脚本可测试的两个关键改造:函数化与依赖注入。
适用环境
bash
# 静态检查工具
sudo apt install -y shellcheck || brew install shellcheck
shellcheck --version
# 测试框架
git clone https://github.com/bats-core/bats-core.git /tmp/bats-core
cd /tmp/bats-core && sudo ./install.sh /usr/local && bats --version1
2
3
4
5
6
7
2
3
4
5
6
7
操作步骤
一、静态检查:先跑一遍 shellcheck
bash
shellcheck backup.sh
# 只看严重问题
shellcheck --severity=error backup.sh
# 输出可机读格式,便于接入 CI
shellcheck --format=gcc backup.sh1
2
3
4
5
2
3
4
5
常见被抓到的问题:
bash
# SC2086: 变量未加引号 → rm -rf $DIR/ 在 DIR 为空时变 rm -rf /
# SC2155: 声明与赋值分开写,否则拿不到命令的退出码
# SC2164: cd 失败未处理
# SC2181: 直接检查 $? 不如用 if cmd1
2
3
4
2
3
4
修正示例:
bash
# 错误写法
local result=$(grep x file)
# 正确写法(能拿到 grep 的退出码)
local result
result=$(grep x file) || result=""1
2
3
4
5
6
2
3
4
5
6
二、让脚本可测试:函数化 + 依赖注入
不可测试的脚本长这样——逻辑与副作用混在一起:
bash
#!/usr/bin/env bash
df -P / | awk 'NR==2{print $5}' | tr -d '%' | while read -r u; do
(( u > 80 )) && echo "ALERT"
done1
2
3
4
2
3
4
改成可测试的形式:
bash
#!/usr/bin/env bash
# disk.sh
set -euo pipefail
# 可被测试覆盖的纯函数
disk_usage() {
df -P "${1:?需要路径}" | awk 'NR==2{gsub(/%/,"",$5); print $5}'
}
# 判定逻辑独立出来,只依赖输入
judge() {
local usage="$1" warn="${2:-80}"
if (( usage >= warn )); then echo "ALERT"; else echo "OK"; fi
}
# 只有被直接执行时才跑 main,被 source 时只加载函数(测试的前提)
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
u="$(disk_usage /)"
judge "$u" "${WARN:-80}"
fi1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
注意
if [[ "${BASH_SOURCE[0]}" == "$0" ]] 这一行是 Shell 脚本可测试的关键。它让测试文件可以 source 脚本只导入函数,而不触发实际的副作用(如删除文件、调用生产接口)。
三、用 bats 写测试
bash
cat > test_disk.bats <<'EOF'
#!/usr/bin/env bats
setup() {
# 载入被测脚本的函数(不执行 main)
source ./disk.sh
}
@test "judge: 低于阈值返回 OK" {
run judge 50 80
[ "$status" -eq 0 ]
[ "$output" = "OK" ]
}
@test "judge: 达到阈值返回 ALERT" {
run judge 80 80
[ "$status" -eq 0 ]
[ "$output" = "ALERT" ]
}
@test "judge: 超过阈值返回 ALERT" {
run judge 95 80
[ "$output" = "ALERT" ]
}
@test "disk_usage: 对根分区返回 0-100 的整数" {
run disk_usage /
[ "$status" -eq 0 ]
[[ "$output" =~ ^[0-9]+$ ]]
[ "$output" -ge 0 ] && [ "$output" -le 100 ]
}
EOF
bats test_disk.bats1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
输出示例(bats 会用 TAP 格式汇报通过/失败项):
text
✓ judge: 低于阈值返回 OK
✓ judge: 达到阈值返回 ALERT
✓ judge: 超过阈值返回 ALERT
✓ disk_usage: 对根分区返回 0-100 的整数
4 tests, 0 failures1
2
3
4
5
2
3
4
5
四、用 mock 隔离外部依赖
bash
@test "备份: 上传失败时返回非零" {
# 用 PATH 覆盖的方式 mock 掉 aws 命令
mkdir -p "$BATS_TEST_TMPDIR/bin"
printf '#!/usr/bin/env bash\nexit 1\n' > "$BATS_TEST_TMPDIR/bin/aws"
chmod +x "$BATS_TEST_TMPDIR/bin/aws"
PATH="$BATS_TEST_TMPDIR/bin:$PATH"
run upload_backup /tmp/nonexist.tar.gz my-bucket
[ "$status" -ne 0 ]
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
把外部命令(云 CLI、curl、systemctl)用 mock 替换,测试才能在没有云账号、没有真实环境的机器上稳定运行。
五、接入 CI
yaml
# .github/workflows/scripts.yml 片段
- name: Shell 静态检查
run: shellcheck --severity=error $(git ls-files '*.sh')
- name: 运行 bats 测试
run: bats test/1
2
3
4
5
2
3
4
5
六、Python 脚本的测试
python
# test_backup.py
import backup
def test_keep_policy():
files = ["a-20261001.tar.gz", "a-20261002.tar.gz", "a-20261009.tar.gz"]
assert backup.pick_expired(files, keep_days=7, now="20261009") == ["a-20261001.tar.gz"]1
2
3
4
5
6
2
3
4
5
6
bash
python3 -m pytest -q test_backup.py1
验证
- [ ]
shellcheck --severity=error对所有脚本零告警 - [ ]
bats test/全部通过 - [ ] 被测脚本使用
BASH_SOURCE守卫,可被source而不执行副作用 - [ ] 测试中不依赖真实云资源/网络(已用 mock)
bash
shellcheck --severity=error *.sh && bats test/ && echo "全部通过"1
常见坑
source脚本时触发了副作用:缺少BASH_SOURCE守卫,测试一跑就真删文件。- 测试依赖真实环境:依赖云 CLI、真实 IP,换个机器就失败,测试会被人跳过。
- 只测 happy path:只测正常输入,边界(空值、负数、超大值)没覆盖。
- shellcheck 全量告警太多被忽略:应先用
--severity=error卡住严重项,再逐步清理 warning。 - 测试写完不跑:没接进 CI,几周后就与脚本实际行为脱节。