测试是任何应用程序不可或缺的一部分,编写自动化测试对于确保代码安全至关重要。 但是,当你用完全不同的语言重写程序时,会怎么做? 你如何确保你的新旧程序做同样的事情?在本文中,我将描述我们将 Bash 脚本集合更改为组织良好的 Go 库的过程,以及我们如何确保在此过程中没有任何问题。

开始

  在 Flipp,我们有自己的微服务平台,它允许打包和部署代码,作为持续交付流水线的一部分。 还提供了额外的功能,比如权限验证(所以我们不会部署会失败的东西,因为它没有读取或写入资源的权限)。这些脚本是用 Bash 编写的。 Bash 是与 Linux 可执行文件交互的好方法。 它非常快,并且不需要安装任何特定的编程语言,因为没有编译或解释通过 shell 本身。 但是 Bash 使用起来很挑剔,很难测试,并且没有像大多数编程语言那样的“标准库”。
  我们本可以继续使用 Bash。但是,不断出现的主要痛点是对几乎所有内容都要使用环境变量。 当你编写 Bash 时,无法判断某个特定的环境变量是否是脚本的输入,它是否是由于某些外部进程而恰好设置的,或者它是否是脚本应该使用的局部变量 “自己的。”
  此外,如果将脚本分成文件以便平台可以使用它们,将无法阻止用户在不告诉你的情况下调用它的一小部分。 如果想尝试弃用某个功能,这几乎是不可能的,因为一切都是对世界开放的。在进行任何类型的重大更改时,我们唯一的选择基本上是发布一个新版本,“在生产中对其进行测试”,并在这些测试完成后将其提升为默认版本。 当有几十个使用这些脚本的服务时,这工作得很好。 一旦膨胀到几百个,它就变得不那么理想了。因此不得不重写这些东西,以便它们实际上是可维护的。

准备工作

  我们决定用 Go 重写脚本。 它不仅是我们已经在 Flipp 用于高吞吐量 API 服务的语言,还允许轻松编译到需要的任何目标架构,并附带一些很棒的命令行库,如 Cobra。 此外,这意味着我们可以使用配置文件定义部署的行为,而不是使用大量混乱的环境变量。但问题仍然存在 —— 我们如何测试这个东西? 单元和功能测试通常在重构之前使用,因此可以确保输入和输出匹配。 但我们正在谈论用一种完全不同的语言重写它。 怎么能确定没有破坏东西?
我们决定采用三步法:

  1. 通过让测试框架覆盖所有测试用例来描述现有脚本的行为。
  2. 用现在仍然能测试通过的方式重写 Go 中的脚本。 以这样一种方式编写,它可以将环境变量或配置文件作为其输入。
  3. 重构和更新 Go 库,以便可以利用编程语言的灵活性和强大功能。 根据需要更改或添加测试。

描述行为:Bats 简介

  Bats 是 Bash 的测试框架。 它相当简单 —— 它提供了测试工具、设置和拆卸功能,以及一种按文件组织测试的方法。 使用 Bats,可以运行任何命令并提供退出代码、输出、环境变量、文件内容等内容。这是创建一种方法来测试我们现有脚本的第一步。 但这还远远不够。 自动化测试的关键部分之一是存根或模拟功能的能力。 在例子中,不想实际调出 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

  最后一部分是将快照测试引入到主要测试工具中。 这意味着将这些虚拟输出文件以及实际命令输出保存到存储库中的文件中。 操作顺序是这样的:

  1. 新的测试用例已经写好了。 此时,没有任何输出文件。
  2. 新测试用例在设置了 UPDATE_SNAPSHOTS 环境变量的情况下运行。 这会将虚拟输出文件和命令输出保存到正在运行的文件夹的输出目录中。这些将提交到 repo。
  3. 当重新运行测试用例时,输出将保存到同一文件夹内的 current_calls 目录中。
  4. 命令完成后,调用一个脚本,将输出目录的内容与 current_calls 目录的内容进行比较。
  5. 如果输出相同,则报告成功并删除 current_calls 目录。
  6. 如果存在差异,它会报告失败并将 current_calls 目录保留在原处,以便检查它并使用差异工具。

行为描述:陷阱

有几件事情必须解决,才能让它在我们的持续集成流水线上正常工作:

  • Bash 脚本在任何运行它的计算机上运行,这意味着你的机器的当前目录和 CI 流水线的机器可能是不同的。 因此,我们必须在所有输出文件中搜索当前目录并将其替换为 %%BASE_DIR%% 。 这确保无论在何处运行,要比较的输出始终相同。
  • 一些命令使用 \e 指令输出彩色文本。 这导致在 Mac 和 Linux 上保存到输出文件的文本略有不同,因此也必须在这里进行一些查找/替换。
  • 必须有一种方法来检查调用的命令是否真的失败了 ——有时预计它会失败,如果失败没有按预期发生,测试本身应该失败。 在例子中,必须在被测命令之前和之后运行相当多的代码,因此退出代码不再可用于 Bats 测试文件。 因此,必须设置一个环境变量来指示是否预计当前命令会失败。
  • 希望使实际测试文件简洁明了,以避免可能出现的手动错误。 在共享的测试代码中,根据测试文件的名称确定套件文件夹,在许多情况下,测试只是一行,其中包含要测试的套件中的文件夹名称。

描述行为:设计测试

  长征现在开始了。本质上,Bash 脚本中的每个 if 和循环语句都代表另一个测试用例。 在某些情况下,一个代码分支显然是相当孤立的,可以由一个案例覆盖。 在其他情况下,分支机构可能会以奇怪的方式进行交互。 这意味着必须以乘法方式煞费苦心地生成测试用例。
  例如,需要测试何时部署单个服务而不是多个服务,以及何时只运行主要部署步骤而不是完整的工作流程。 在这种情况下,这意味着四个独立的测试套件。这一步可能花费了最长的时间! 完成后,可以有把握地说已经描述了现有部署脚本的行为。 现在准备重写它。

重写:让我们开始用go吧!

  在 Go 库的第一次迭代中,每次想做一些外部的事情时,都会故意调用 Bash(在这种情况下,使用 go-sh 库),比如网络请求。 这样,我们的第一个 Go 版本与 Bash 版本完全相同,包括它如何与外部命令交互。

重构和更新:实现胜利

  一旦使用此版本通过了所有测试,就可以开始让它更像一个 Go 程序。 例如,与其直接调用 curl 并处理其检查 HTTP 状态的繁琐方式,不如使用 Go 的内置 HTTP 函数来发出请求更有意义。然而,一旦停止直接调用命令,就不再有现有的测试用例来验证我们的行为了!输出取决于那些虚拟脚本的存在,我们已经停止调用它们。
  实际上并不希望测试输出与旧的相同 - curl 输出特别复杂,如果我们想让输出的内容看起来就像是调用Bash curl输出的,也没有什么好处。
为了克服这一点,必须采取三个步骤。

  • 必须编写一个库来包装命令,因为它们正在被调用。 比如,一个函数,它取了一个URL,一个方法,POST数据等等,此时,它仍然调用curl命令。
  • 然后将其更改为使用 Go HTTP 函数而不是 curl。 围绕该库编写单元测试,因此知道它至少可以正确调用 HTTP 函数。将此库的“模拟”版本写入其自己的输出文件,类似于“虚拟”脚本的工作方式。
  • 最后,重新运行测试的快照。 此时,必须手动比较原始 curl.calls 文件和新 requests.calls 文件的输出,以验证它们在语义上是否相同。 使用 diff 工具,可以很容易地从视觉上分辨出什么时候相同,什么时候不相同。
      换句话说,尽管这一步让我们失去了 “什么都没变 “的确定性,但我们还是能够准确定位我们的变化,这样我们就知道所有的差异都与这一变化有关,并能直观地确认它的作用。

结论

  由于环境变化,我们仍然需要进行一些手动测试,但最终结果非常好。 我们能够用所有新服务的 Go 版本替换部署脚本,甚至能够开发一个脚本来自动化所有现有服务的拉取请求(使用 multi-gitter),以允许团队在他们需要时迁移到新版本。这是一段谨慎而漫长的旅程,但它极大地帮助我们到达了目的地。 你可以在这里看到我们使用的脚本的编辑版本!