拥有此神技,脚本调试从此与 echo、set、test 说分手
点击上方“民工哥技术之路”,选择“设为星标”
回复“1024”获取独家整理的学习资料!
作者:柴锋
原文链接:https://chaifeng.com/unit-testing-bash-scripts/
作者:柴锋
原文链接:https://chaifeng.com/unit-testing-bash-scripts/
为什么要为 Bash 脚本写单元测试?
因为 Bash 脚本通常都是在执行一些与操作系统有关的操作,可能会对运行环境造成一些不可逆的操作,比如修改或者删除文件、升级系统中的软件包等。
所以为了确保 Bash 脚本的安全可靠,在生产环境中部署之前一定需要做好足够的测试以确保其行为符合我们的预期。
场景一:在执行 Bash 脚本测试前,我们需要需要事先安装好所有在 Bash 脚本中会用到的第三方工具,否则这些测试将会因为命令找不到而执行失败。例如,我们在脚本中使用了 Bazel 这个构建工具。我们必须提前安装并配置好 Bazel,而且不要忘记为了能够正常使用 Bazel 还得需要一个支持使用 Bazel 构建的工程。
场景二:测试结果的稳定性可能取决于脚本中访问的第三方服务的稳定性。比如,我们在脚本中使用 curl
命令从一个网络服务中获取数据,但这个服务有时候可能会访问失败。有可能是因为网络不稳定导致的,也可能是因为这个服务本身不稳定。再或者如果我们需要第三方服务返回不同的数据以便测试脚本的不同分支逻辑,但我们可能很难去修改这个第三方服务的数据。场景三:Bash 脚本的测试用例的执行时间取决于脚本中使用的命令的执行时间。例如,如果我们中脚本中使用了
Gradle
来构建一个工程,由于不同的工程大小 Gradle 的一个构建可能要执行3分钟或者3个小时。这还只是一个测试用例,如果我们还有20个或者100个测试用例呢?我们是否还能在几秒内获得测试报告呢?
如果说我们就是想知道这个命令搭配上这些选项参数是否能按我们预期的那样工作呢?很简单,那就单独在命令行里面去执行一下。如果在命令行中也不能按预期的工作,放到 Bash 脚本里面也一样不会按预期的工作。这种错误和 Bash 脚本几乎没什么关系了。
什么样的测试才是 Bash 脚本的单元测试?
PATH
环境变量的路径中的命令都不应该在单元测试中被执行。对 Bash 脚本来说,被调用的这些命令可以正常运行,有返回值,有输出。但脚本中调用的这些命令都是被模拟出来的,用于模拟对应的真实命令的行为。这样,我们在 Bash 脚本的单元测试中就避免了很大一部分的外部依赖,而且测试的执行速度也不会受到真实命令的影响了。怎样为 Bash 脚本写单元测试?
PATH
环境变量中的命令。有一个名为 Bach Testing Framework 的测试框架是目前唯一一个可以为 Bash 脚本编写真正的单元测试的框架。简单
什么也不用安装。我们就可以执行这些测试。比如可以在一个全新的环境中执行一个调用了大量第三方命令的 Bash 脚本。快
因为所有的命令都不会被真正执行,所以每一个测试用例的执行都非常快。安全
因为不会执行任何外部的命令,所以即使因为 Bash 脚本中的某些错误导致执行了一个危险的命令,比如rm -rf *
。Bach 会保证这些危险命令不会被执行。与运行环境无关
可以在 Windows 上去执行只能工作在 GNU/Linux 上的脚本的测试。
拦截使用绝对路径调用的命令
事实上我们应该避免在 Bash 脚本中使用绝对路径,如果不可避免的要使用,我们可以把这个绝对路径抽取为一个变量,或者放入到一个函数中,然后用@mock
API 去模拟这个函数。拦截诸如 >
、>>
、<<
等等这样的 I/O 重定向
是的,无法拦截 I/O 重定向。我们也同样可以把这些重定向操作隔离到一个函数中,然后再模拟这个函数。
Bach Testing Framework 的使用
Bash v4.3+ Coreutils (GNU/Linux) Diffutils (GNU/Linux)
安装 Bach Testing Framework
source
命令导入 Bach Testing Framework 的 bach.sh
即可。比如:
source path/to/bach.sh
一个简单的例子
test-
开头的测试执行函数,另一个是同名的以 -assert
结尾的测试验证函数。– test-rm-rf
– test-rm-your-dot-git
一个完整的测试用例:
#!/usr/bin/env bash
set -euo pipefail
source bach.sh # 导入 Bach Testing Framework
test-rm-rf() {
# Bach 的标准测试用例是由两个方法组成
# - test-rm-rf
# - test-rm-rf-assert
# 这个方法 `test-rm-rf` 是测试用例的执行
project_log_path=/tmp/project/logs
sudo rm -rf "$project_log_ptah/" # 注意,这里有个笔误!}
test-rm-rf-assert() {
# 这个方法 `test-rm-rf-assert` 是测试用例的验证
sudo rm -rf / # 这就是真实的将会执行的命令
# 不要慌!使用 Bach 测试框架不会让这个命令真的执行!}
test-rm-your-dot-git() {
# 模拟 `find` 命令来查找你的主目录下的所有 `.git` 目录,假设会找到两个目录
@mock find ~ -type d -name .git === @stdout ~/src/your-awesome-project/.git \
~/src/code/.git
# 开始执行!删除你的主目录下的所有 `.git` 目录!find ~ -type d -name .git | xargs -- rm -rf
}
test-rm-your-dot-git-assert() {
# 验证在 `test-rm-your-dot-git` 这个测试执行方法中最终是否会执行以下这个命令。rm -rf ~/src/your-awesome-project/.git ~/src/code/.git
}
test-rm-rf-assert
这个方法test-rm-your-dot-git
中使用了 @mock
API 来模拟了命令 find ~ type d -name .git
的行为,这个命令用来找出用户目录下的所有 .git 目录。模拟之后,这个命令并不会真的执行,而是利用了 @stdout
API 在标准终端上输出了两个虚拟的目录名。find
命令的输出结果传递给 xargs
命令,并组合到 rm -rf
命令之后。test-rm-your-dot-git-assert
里面就验证是 find ~ -type d -name .git | xargs -- rm -rf
的运行结果是否等同于命令 rm -rf ~/src/your-awesome-project/.git ~/src/code/.git
@mock
是 Bach Testing Framework 中很重要的一个 API,利用这个 API 我们就可以模拟 Bash 脚本中所使用的任意命令的行为或者输出。比如
@mock curl --silent google.com === \
@stdout "baidu.com"
curl --silent google.com
的执行结果是输出 baidu.com
。在真实的正常场景下,我们是无法做到访问 google.com
得到的是 baidu.com
。这样模拟之后就可以用来验证 Bash 脚本中处理一个命令不同响应时的行为了。@mock
API 甚至还支持更复杂的行为模拟,我们可以自定义一个复杂的模拟逻辑,比如:@mock ls <<\CMD
if [[ "$var" -eq 1 ]]; then
@stdout one
else
@stdout others
fi
CMD
在这个模拟中,会根据变量 $var
的值来决定命令 ls
的输出 one
还是 others
。
@mock
API 模拟的命令在任何时候执行的时候都是同样的行为。但如果要模拟同一个命令重复执行的时候要返回不同的值,Bach Testing Framework 还提供了一个 @@mock
这个 API,比如:@@mock uuid === @stdout aaaa-1111-2222
@@mock uuid === @stdout bbbb-3333-4444
@@mock uuid === @stdout cccc-5555-6666
uuid
在重复执行三次的时候都返回不同的结果,按照模拟的先后顺序分别输出对应的模拟输出。如果在执行完所有的模拟输出后,再重复执行将会始终输出最后一个模拟的输出。比如,我们希望实现一个函数 cleanup
用来删除参数上指定的文件。一个实现可能是:
function cleanup() {
rm $1
}
这个函数的实现其实是有安全问题的,因为对于 Bash 来说,有没有把一个变量用双引号包含起来是非常重要的。在这个实现中,变量 $1 就没有用双引号,这会带来严重的后果。下面我们将使用 @touch API 来创建几个文件,其中将有一个文件名中含有特殊字符 的文件 bar。
function cleanup() {
rm -rf $1
}
test-learn-bash-no-double-quote-star() {
# 创建了三个文件,其中有一个名为 "bar*" 的文件
@touch bar1 bar2 bar3 "bar*"
# 要删除这个错误的文件名 bar*,而不删除其他文件,使用了双引号来传参,这是正确的
cleanup "bar*"
}
test-learn-bash-no-double-quote-star-assert() {
rm -rf "bar*"
}
bar
,但是在函数 cleanup
里面,因为遗漏了双引号,会导致变量被二次展开。实际执行的命令是 rm -rf "bar*" bar1 bar2 bar3
。cleanup
,把变量 $1
放入双引号:function cleanup() {
rm -rf "$1"
}
再次执行测试,会发现确实执行的是命令 rm -rf "bar*"
。
Bach Testing Framework 目前已经在宝马集团和华为内部使用了。在宝马集团的一个有数千人规模的大型项目里,Bach Testing Framework 保证了数个非常重要的构建脚本的维护。
这些脚本的可靠性和稳定性决定了数千人团队的工作效率,现在就可以在本地快速验证这些构建脚本的执行逻辑,也避免了在本地很难复现一些构建集群中的特殊场景的问题。
整理了 15 个好用的 API 接口管理神器,你们随便挑...