为什么要为Bash脚本编写单元测试?因为Bash脚本通常会执行一些与操作系统相关的操作,可能会对运行环境造成一些不可逆的操作,比如修改或删除文件,升级系统中的软件包等。因此,为了保证安全性和可靠性对于Bash脚本,在将它们部署到生产环境之前必须进行充分的测试,以确保它们的行为符合我们的预期。我们如何安全可靠地测试Bash脚本?可能有人会说我们可以使用Docker容器。是的,这样做既安全又方便。在容器隔离的环境下,不用担心脚本破坏我们的系统,重建可用的测试环境也非常简单快捷。但是,请考虑以下常见场景:场景一:在执行Bash脚本测试之前,我们需要提前安装好所有Bash脚本中会用到的第三方工具,否则这些测试会因为命令而被发现执行失败。例如,我们在脚本中使用Bazel构建工具。我们必须提前安装并配置好Bazel,同时不要忘记为了正常使用Bazel,我们需要一个支持使用Bazel构建的工程。场景二:测试结果的稳定性可能取决于脚本中访问的第三方服务的稳定性。比如我们在脚本中使用curl命令从一个网络服务中获取数据,但是这个服务有时会访问不上。可能是网络不稳定造成的,也可能是服务本身不稳定。或者如果我们为了测试脚本不同的分支逻辑,需要第三方服务返回不同的数据,我们可能很难去修改这个第三方服务的数据。场景三:Bash脚本的测试用例的执行时间取决于脚本中使用的命令的执行时间。比如我们在脚本中使用Gradle构建一个项目,由于项目大小不同,一次Gradle的构建可能需要3分钟或者3个小时。这只是一个测试用例,如果我们有20个或100个测试用例怎么办?我们还能在几秒钟内拿到检测报告吗?即使使用容器来执行Bash脚本测试,也无法避免以上几个问题。问题。环境的准备过程可能会随着测试用例的增加而变得繁琐。测试用例的稳定性和执行时间取决于第三方命令和服务的稳定性和执行时间,可能很难用不同的数据覆盖不同的测试场景。对于测试Bash脚本,我们真正要验证的是Bash脚本的执行逻辑。例如,在Bash脚本中,内部调用的命令的选项和参数可能会根据传入的参数进行组合,我们需要验证的是这些选项和参数确实符合预期。至于为什么在接受这些选项和参数后调用的命令失败,也许我们并不关心所有可能的原因。因为会有更多的外部因素,比如硬件和网络是否正常工作,第三方服务是否正常运行,构建项目所需的编译器是否安装和配置正确,授权和认证信息是否正确。有效等。但是对于Bash脚本来说,这些外部原因的结果就是被调用的命令执行成功或者失败。所以Bash脚本只需要关注脚本中调用的命令是否能成功执行,以及命令输出什么,决定后续执行脚本中的哪些不同的分支逻辑。这个命令可以像我们预期的那样使用这些选项参数吗?很简单,单独在命令行执行即可。如果它在命令行上没有按预期工作,它也不会在Bash脚本中按预期工作。这种错误几乎与Bash脚本无关了。因此,为了尽可能去除那些影响Bash脚本验证的外部因素,我们应该考虑为Bash脚本编写单元测试,重点关注Bash脚本的执行逻辑。Bash脚本的单元测试是什么样的测试?首先,所有存在于PATH环境变量路径中的命令都不应该在单元测试中执行。对于Bash脚本,调用的命令可以正常运行,有返回值,有输出。但是,脚本中调用的这些命令都是模拟的,以模拟对应的真实命令的行为。这样我们在Bash脚本的单元测试中就避免了很大一部分外部依赖,测试的执行速度不会受到真实命令的影响。其次,每个单元测试用例应该相互独立。这意味着这些测试用例可以独立执行或任意乱序执行,而不会影响验证结果。最后,这些测试用例可以在不同的操作系统上执行,并且都应该得到相同的验证结果。例如,在Bash脚本中使用了只能在GNU/Linux上可用的命令,在Windows或macOS上也可以执行相应的单元测试,结果是一致的。如何为Bash脚本编写单元测试?与其他编程语言一样,Bash也有多种测试框架,如Bats、Shunit2等,但这些框架实际上并不能隔离PATH环境变量中的所有命令。有一个名为BachTestingFramework的测试框架,它是目前唯一可以为Bash脚本编写真正单元测试的框架。BachTestingFramework最独特的特点是它默认不执行任何位于PATH环境变量中的命令,因此BachTestingFramework非常适合验证Bash脚本的执行逻辑。并且它还带来了以下好处:简单且无需安装。我们可以执行这些测试。例如,调用大量第三方命令的Bash脚本可以在全新的环境中执行。快速因为不会真正执行所有的命令,所以每个测试用例的执行速度都非常快。安全,因为它不会执行任何外部命令,所以即使Bash脚本中的某些错误导致执行危险命令,例如rm-rf*。巴赫将确保这些危险命令不会被执行。无论运行环境如何,只能在GNU/Linux上运行的脚本测试都可以在Windows上执行。由于操作系统和Bash的一些限制,BachTestingFramework无法做到:拦截使用绝对路径调用的命令实际上,我们应该避免在Bash脚本中使用绝对路径。如果实在无法避免,我们可以将这个绝对路径抽取出来作为一个变量,或者放到一个函数中,然后使用@mockAPI来模拟这个函数。拦截>、>>、<<等I/O重定向。是的,I/O重定向是无法拦截的。我们也可以把这些重定向操作隔离成一个函数,然后模拟这个函数。Bach测试框架使用Bach测试框架需要Bashv4.3或更高版本。GNU/Linux也需要Coreutils和Diffutils,它们已经默认安装在常用的发行版中。Bach已通过Linux/macOS/Cygwin/GitBash/FreeBSD等操作系统或运行环境的验证。Bashv4.3+Coreutils(GNU/Linux)Diffutils(GNU/Linux)安装Bach测试框架Bach测试框架安装非常简单,只需下载https://github.com/bach-sh/bach/raw/master/bach.sh到你的项目中,在测试脚本中使用source命令导入BachTestingFramework的bach.sh。例如:sourcepath/to/bach.sh一个简单的例子不同于其他测试框架,BachTestingFramework的每个测试用例由两个Bash函数组成,一个是test-开头的测试执行函数,另一个是is以-assert结尾的同名测试验证函数。比如下面的例子,有两个测试用例,分别是–test-rm-rf–test-rm-your-dot-git一个完整的测试用例:#!/usr/bin/envbashset-euopipefailsourcebach.sh#importBachTestingFrameworktest-rm-rf(){#Bach的标准测试用例由两个方法组成#-test-rm-rf#-test-rm-rf-assert#这个方法`test-rm-rf`是测试用例Executeproject_log_path=/tmp/project/logssudorm-rf"$project_log_ptah/"#注意,这里有笔误!}test-rm-rf-assert(){#这个方法`test-rm-rf-assert`是对测试用例的验证sudorm-rf/#这是真正会执行的命令#不要慌张!使用Bach测试框架不会让这个命令真正执行!}test-rm-your-dot-git(){#模拟`find`命令查找你家目录下的所有`.git`目录,假设会找到两个目录@mockfind~-typed-name.git===@stdout~/src/your-awesome-project/.git\~/src/code/.git#开始执行!删除您的主目录下的所有`.git`目录!find~-typed-name.git|xargs--rm-rf}test-rm-your-dot-git-assert(){#Verificationisfinalin`test-rm-your-dot-git`测试执行方法Will执行以下命令。rm-rf~/src/your-awesome-project/.git~/src/code/.git}bach会分别运行每个测试用例的两个方法,验证两个方法中执行的命令和参数是否一致.比如第一个方法test-rm-rf就是执行Bach的测试用例,对应的测试验证方法是test-rm-rf-assert。在第二个测试用例test-rm-your-dot-git中,@mockAPI用于模拟命令find~typed-name.git的行为,用于查找用户目录下的所有.git目录。模拟之后,这条命令并不会真正执行,而是会使用@stdoutAPI在标准终端上输出两个虚拟目录名。然后我们就可以执行真正的命令了,将find命令的输出传递给xargs命令,在rm-rf命令后合并。在对应的测试验证函数test-rm-your-dot-git-assert中,验证find的结果是否为~-typed-name.git|xargs--rm-rf相当于命令rm-rf~/src/your-awesome-project/.git~/src/code/.git@mock是BachTestingFramework中非常重要的API。使用此API,我们可以模拟Bash脚本中使用的任何命令的行为或输出。例如@mockcurl--silentgoogle.com===\@stdout"baidu.com"模拟命令curl--silentgoogle.com的执行结果输出baidu.com。在真实的正常场景下,我们无法访问google.com和获取baidu.com。此模拟可用于验证Bash脚本在处??理对命令的不同响应时的行为。@mockAPI甚至支持更复杂的行为模拟。我们可以自定义一个复杂的模拟逻辑,比如:@mockls<<\CMDif[["$var"-eq1]];then@stdoutoneelse@stdoutothersfiCMD在这个模拟中,ls命令的输出结果会根据到变量$var的值。使用@mockAPI模拟的命令在执行时表现相同。但是如果你想模拟重复执行同一条命令并返回不同的值,BachTestingFramework也提供了@@mockAPI,例如:@@mockuuid===@stdoutaaaa-1111-2222@@mockuuid===@stdoutbbbb-3333-4444@@mockuuid===@stdoutcccc-5555-6666这三个模拟命令模拟uuid,重复3次返回不同的结果,并按照模拟顺序输出对应的模拟输出。如果在所有的模拟输出都执行完之后,那么重复会一直输出最后一次模拟的输出。更详细的API介绍请查看Bach测试框架官网https://bach.sh。使用BachTestingFramework也可以让我们更安全、更方便地练习Bash编程。比如我们要实现一个函数cleanup,删除参数中指定的文件。一个实现可能是:functioncleanup(){rm$1}这个函数的实现其实是一个安全问题,因为对于Bash来说,一个变量是否用双引号引起来是非常重要的。在这个实现中,变量$1没有被引用,后果很严重。下面我们将使用@touchAPI创建几个文件,其中会有一个文件栏,文件名中有特殊字符。我们都知道,对于包含特殊字符的文件名,必须用双引号引起来。现在这个清理实现没有使用双引号,但是在传递参数的时候使用了双引号,还会像我们预期的那样执行吗?functioncleanup(){rm-rf$1}test-learn-bash-no-double-quote-star(){#创建了三个文件,其中一个名为"bar*"的文件@touchbar1bar2bar3"bar*"#删除错误的文件namebar*,不删除其他文件,使用双引号传递参数,正确cleanup"bar*"}test-learn-bash-no-double-quote-star-assert(){rm-rf"bar*"}这个测试用例会失败,从验证结果我们可以看出,应该只删除文件bar,但是在cleanup函数中,因为省略了双引号,变量会被展开两次。实际执行的命令是rm-rf"bar*"bar1bar2bar3。现在修复函数cleanup,将变量$1放入双引号中:functioncleanup(){rm-rf"$1"}再次执行测试,你会发现命令rm-rf"bar*"确实被执行了。BachTestingFramework目前在宝马集团和华为内部使用。在宝马集团一个上千人的大型项目中,BachTestingFramework保证了几个非常重要的构建脚本的维护。这些脚本的可靠性和稳定性决定了一个数千人团队的工作效率。现在你可以在本地快速验证这些构建脚本的执行逻辑,避免了构建集群中一些特殊场景在本地难以复现的问题。.
