使用黑盒测试用Go重写 Bash 脚本

2024-05-10   出处: Stack Overflow  作/译者:Daniel Orner/暖阳

在用新语言重写软件时,如何测试新旧程序是否做同样的事情?

Article hero image

测试是任何应用程序都不可或缺的一部分,而编写自动化测试用例对于确保代码的安全性至关重要。但是,当你用完全不同的语言重写一个程序时,你该怎么办?你如何确保新旧程序做同样的事情?

在这篇文章中,我将描述我们进行的一次旅程,将一组Bash脚本转变为一个组织良好的Go库,并且我们如何确保在这个过程中没有出现任何问题。

一开始…

Flipp,我们有自己的微服务平台,它允许我们打包和部署代码,作为持续交付流程的一部分。我们还提供了额外的功能,比如权限验证(这样我们就不会因为没有读写资源的权限而导致部署失败)。

这些脚本是用 Bash 编写的。Bash 是与 Linux 可执行文件进行交互的好方法。它的速度非常快,而且不需要安装任何特定的编程语言,因为除了Shell本身外,没有编译或解释过程。但 Bash 使用起来很麻烦,很难测试,而且不像大多数编程语言那样有一个 “标准库”。

我们或许可以继续使用 Bash。然而,主要的痛点是,几乎所有的东西都要使用环境变量。在编写 Bash脚本 时,我们无法判断某个特定的环境变量是脚本的输入,还是仅仅由于某些外部过程而设置的东西抑或是脚本应该 “拥有 “的本地变量

此外,如果你将脚本分离成文件,以便平台可以使用它们,那么你就无法阻止任何用户在未告知你的情况下调用其中的一小部分。如果你想废除某个功能,那几乎是不可能的,因为一切都是开放的。

在进行任何重大改动时,我们唯一的选择基本上就是发布一个新版本,“在生产环境中进行测试”,并在测试完成后将其升级为默认版本。当我们只有几十个使用这些脚本的服务时,这种方法还算管用。一旦我们的服务膨胀到几百个,这种方法就变得不那么理想了。

我们不得不重写这些脚本,以使其更易于维护。

准备工作

我们决定用 Go 语言重写脚本。它不仅是我们在 Flipp 用于高吞吐量 API 服务的一种语言,还可以轻松编译成我们需要的任何目标架构,并提供了一些出色的命令行库,如 Cobra。此外,这意味着我们可以通过配置文件来定义部署行为,而不是使用大量混乱的环境变量。

但问题依然存在—我们该如何测试这个东西呢?在重构之前,通常会使用单元测试和功能测试来确保输入和输出相匹配。但我们想要用完全不同的语言重新编写它。怎么能确保不会出现问题呢?

我们决定采取三个步骤:

  1. 通过测试框架覆盖所有现有情况,描述现有脚本的行为
  2. 用 Go 语言重写脚本,使所有现有测试仍能通过。编写代码时,使其可以接受环境变量或配置文件作为输入。
  3. 重构和更新 Go 库,以便我们能够利用编程语言的灵活性和强大功能。根据需要更改或添加测试。

描述行为:引入Bats

Bats是一个 Bash 测试框架。它相当简单—提供了一个测试工具包、setup和teardown函数,以及一种按文件组织测试用例的方法。使用 Bats,你可以运行任何命令,并对退出代码、输出、环境变量、文件内容等进行验证。

这是创建测试现有脚本方法的第一步。但这还远远不够。自动化测试的关键之一是StubMock功能的能力。在我们的例子中,我们并不想实际调用docker或curl命令,也不想在测试框架中进行任何真正的部署。

这里的关键是操纵 shell 路径,以将任何调用定向到“虚拟”脚本。这些脚本可以检查命令的输入,以及在测试设置过程中可能设置的环境变量,并将其输出打印到文件中,以便在测试运行后进行检查。一个虚拟脚本示例如下:

#! /bin/bash
echo -e "docker $*" >> "${CALL_DIR}/docker.calls"

if [[ "$*" == *--version* ]]; then
 echo "Docker version 20.10.5, build 55c4c88"
fi
if [[ $1 == "build" && "$*" == *docker-image-fail* ]]; then
 exit 1
fi

脚本运行后的输出文件示例可能如下所示:

# docker.calls
docker build --pull -f systems/my-service/Dockerfile
docker push my-docker-repo.com/my-service:current-branch

最后一步是在我们的主要测试工具中引入快照测试。这意味着我们要将这些虚拟输出文件和实际命令输出保存到代码库的文件中。操作顺序大致如下:

  • 编写新的测试用例。此时,我们还没有任何输出文件。
  • 使用UPDATE_SNAPSHOTS​ 环境变量集运行新的测试。这会将虚拟输出文件和命令输出,保存到一个与当前运行文件夹相对应的 outputs 目录中。这些文件会提交到代码库中。
  • 当我们重新运行测试用例时,输出会保存到同一文件夹下的 current_calls 目录中。
  • 命令执行完毕后,我们会调用一个脚本,比较outputs目录和 current_calls 目录的内容。
  • 如果相同,则报告成功并删除 current_calls 目录。
  • 如果有差异,则报告失败,并保留 current_calls 目录,以便我们可以检查并使用diff工具进行比较。

描述行为:注意事项

为了让它在持续集成流程上正常工作,我们必须解决一些问题:

  • Bash脚本会在运行它的计算机上运行,这意味着你的计算机和 CI流程的计算机上的当前目录可能不同。因此,我们必须在所有输出文件中搜索当前目录,并将其替换为 %%BASE_DIR%%。这样就确保了无论在哪里运行,要比较的输出总是相同的。
  • 某些命令使用 \e 指令输出彩色文本。这导致保存到 Mac 和 Linux 输出文件中的文本略有不同,因此我们也必须在这里进行一些查找/替换。

  • 我们需要一种方法来检查调用的命令是否真的失败了—有时我们期望它失败,而如果失败未按预期发生,则测试本身就应该失败。在我们的例子中,我们需要在被测命令之前和之后运行大量代码,但这样Bats 测试文件就无法获得退出码。因此,我们必须设置一个环境变量,来指示当前命令是否会失败。
  • 我们希望保持实际测试文件的简洁,以避免可能的人工错误。在我们的共享测试代码中,我们根据测试文件的名称确定了suite文件夹,在许多情况下,测试用例只是一行包含该套件内要测试的文件夹名称的代码。

描述行为: 设计测试用例

艰苦的工作开始了。基本上,Bash 脚本中的每个 if 和循环语句都代表着另一个测试用例。在某些情况下,很明显一个代码分支相当独立,可以用一个单独的case来覆盖。而在其他情况下,这些分支可能以奇怪的方式相互交互。这就意味着我们必须费力地以乘法方式来生成测试用例。

例如,我们需要对正在部署的单个服务进行测试,而不是对正在部署的多个服务进行测试;还需要对正在运行的主要部署步骤进行测试,而不是对整个工作流程进行测试。在这种情况下,这意味着需要四个独立的测试套件。

这一步可能耗时最长!完成后,我们可以肯定地说,我们已经描述了现有部署脚本的行为。现在,我们准备重写它。

重写: 让我们开始吧!

在 Go 库的第一次迭代中,每当我们想执行一些外部操作(比如Web请求)时,我们都会特意调用 Bash(在本例中,使用 go-sh库)。这样,我们的第一个 Go 版本与 Bash 版本完全相同,包括它与外部命令的交互方式。

重构和更新:实现胜利

一旦所有测试都通过了这个版本,我们就可以让它更像一个 Go 程序了。例如,不再直接调用 curl 并用它繁琐的方式校验 HTTP 状态,而是使用 Go 内置的 HTTP 函数来发送请求。

然而,一旦我们不再直接调用命令,就无法再通过现有的测试来验证我们的行为了!输出结果依赖于这些虚拟脚本的存在,而我们已经停止调用它们。

实际上,我们并不希望我们的测试输出与以前的输出完全相同—特别是curl的输出非常复杂,如果我们把Go代码的输出强行模拟成类似Bash的curl调用,那将毫无意义。

为了克服这一点,我们必须采取三个步骤。

  • 首先,我们必须编写一个库,将现在正在调用的命令封装起来。例如,一个接收 URL、方法、POST 数据等的函数。此时,它仍然调用curl命令。
  • 然后,我们将其改为使用 Go 的 HTTP 函数,而不是 curl。我们编写单元测试来测试该库是否正确调用了HTTP函数。我们还让这个库的 “mock”版本写入自己的输出文件,类似于我们的“虚拟”脚本的工作方式。
  • 最后,我们重新运行测试快照。此时,我们必须手动比较原始的 curl.calls 文件和新的 requests.calls 文件的输出结果,以验证它们在语义上是否一致。使用 diff 工具,通过可视化方式很容易判断哪些是相同的,哪些是不同的。

换句话说,尽管这一步让我们失去了那种坚不可摧的确定性,但我们还是能够精确定位我们的改动,以确保所有的差异都与这一改动有关,并能直观地确认它是否有效。

结论

由于环境变化,我们仍需进行一些手工测试,但最终结果非常好。我们用 Go 版本替换了所有新服务的部署脚本,甚至还开发了一个脚本来自动处理所有现有服务的拉取请求(使用 multi-gitter),以便让团队在有能力时迁移到新版本。

这是一段谨慎而漫长的旅程,但它极大地帮助我们达到了目标。你可以在这个代码片段 中查看我们曾使用的脚本的历史版本!


声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
142° /1426 人阅读/0 条评论 发表评论

登录 后发表评论