用 Java、Ruby 和 Python 等语言编写应用程序的软件开发者拥有复杂的库来帮助他们随着时间的推移维护其软件的完整性。他们创建测试,在结构化环境中运行应用程序,进行一系列的执行,以确保其软件的所有方面都按预期工作。
当这些测试在持续集成 (CI) 系统中实现自动化时,它们会变得更加强大,每次推送到源代码存储库都会触发测试运行,并在测试失败时立即通知开发者。这种快速反馈提高了开发者对其应用程序功能完整性的信心。
Bash 自动化测试系统 (BATS) 使编写 Bash 脚本和库的开发者能够将 Java、Ruby、Python 和其他开发者使用的相同实践应用于其 Bash 代码。
安装 BATS
BATS GitHub 页面包含安装说明。有两个 BATS 辅助库提供更强大的断言或允许覆盖 BATS 使用的 Test Anything Protocol (TAP) 输出格式。这些可以安装在标准位置,并由所有脚本引用。将完整版本的 BATS 及其辅助库包含在正在测试的每组脚本或库的 Git 存储库中可能更方便。这可以使用 git submodule 系统来完成。
以下命令会将 BATS 及其辅助库安装到 Git 存储库中的 test 目录中。
git submodule init
git submodule add https://github.com/sstephenson/bats test/libs/bats
git submodule add https://github.com/ztombol/bats-assert test/libs/bats-assert
git submodule add https://github.com/ztombol/bats-support test/libs/bats-support
git add .
git commit -m 'installed bats'
要同时克隆 Git 存储库并安装其子模块,请使用 git clone 的
--recurse-submodules 标志。
每个 BATS 测试脚本都必须由 bats 可执行文件执行。如果将 BATS 安装到源代码仓库的 test/libs 目录中,则可以使用以下命令调用测试
./test/libs/bats/bin/bats <path to test script>
或者,将以下内容添加到每个 BATS 测试脚本的开头
#!/usr/bin/env ./test/libs/bats/bin/bats
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
并执行 chmod +x <测试脚本的路径>。这将 a) 使它们可以通过安装在 ./test/libs/bats 中的 BATS 执行,并且 b) 包含这些辅助库。BATS 测试脚本通常存储在 test 目录中,并以被测试的脚本命名,但带有 .bats 扩展名。例如,测试 bin/build 的 BATS 脚本应称为 test/build.bats。
您还可以通过将正则表达式传递给 BATS 来运行整套 BATS 测试文件,例如 ./test/lib/bats/bin/bats test/*.bats。
组织库和脚本以进行 BATS 覆盖
必须以一种有效地将其内部工作原理暴露给 BATS 的方式组织 Bash 脚本和库。通常,当调用或执行库函数和 shell 脚本时,运行许多命令的函数和脚本不适用于有效的 BATS 测试。
例如,build.sh 是许多人编写的典型脚本。它本质上是一大堆代码。有些人甚至可能将这一大堆代码放在库中的一个函数中。但是,不可能在 BATS 测试中运行一大堆代码,并在单独的测试用例中覆盖它可能遇到的所有类型的故障。以足够的覆盖率测试这堆代码的唯一方法是将其分解为许多小的、可重用的,最重要的是,可独立测试的函数。
向库中添加更多函数很简单。增加的好处是,其中一些函数本身可能会变得非常有用。一旦将库函数分解为许多较小的函数,您就可以在 BATS 测试中 source 该库,并像运行任何其他命令一样运行这些函数来测试它们。
Bash 脚本也必须分解为多个函数,脚本的主要部分在执行脚本时应该调用这些函数。此外,还有一个非常有用的技巧可以使使用 BATS 测试 Bash 脚本变得更加容易:将脚本主要部分中执行的所有代码移动到一个函数中,例如 run_main。然后,将以下内容添加到脚本的末尾
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]
then
run_main
fi
这段额外的代码做了一些特别的事情。它使脚本在作为脚本执行时与通过 source 带入环境时的行为不同。此技巧使脚本可以像测试库一样进行测试,通过 source 它并测试各个函数。例如,这是 build.sh 经过重构,以获得更好的 BATS 可测试性。
编写和运行测试
如上所述,BATS 是一个符合 TAP 的测试框架,其语法和输出对于那些使用过其他符合 TAP 的测试套件(例如 JUnit、RSpec 或 Jest)的人来说会很熟悉。它的测试被组织成单独的测试脚本。测试脚本被组织成一个或多个描述性的 @test 块,这些块描述了被测试应用程序的单元。每个 @test 块将运行一系列命令,这些命令准备测试环境,运行要测试的命令,并对被测试命令的退出和输出进行断言。许多断言函数与 bats、bats-assert 和 bats-support 库一起导入,这些库在 BATS 测试脚本的开头加载到环境中。这是一个典型的 BATS 测试块
@test "requires CI_COMMIT_REF_SLUG environment variable" {
unset CI_COMMIT_REF_SLUG
assert_empty "${CI_COMMIT_REF_SLUG}"
run some_command
assert_failure
assert_output --partial "CI_COMMIT_REF_SLUG"
}
如果 BATS 脚本包含 setup 和/或 teardown 函数,则它们会在每个测试块运行前后自动由 BATS 执行。这使得创建环境变量、测试文件以及执行一个或所有测试所需的其他操作成为可能,然后在每次测试运行后将其拆除。Build.bats 是我们新格式化的 build.sh 脚本的完整 BATS 测试。(此测试中的 mock_docker 命令将在下面的模拟/存根部分中进行解释。)
当测试脚本运行时,BATS 使用 exec 将每个 @test 块作为单独的子进程运行。这使得在一个 @test 中导出环境变量甚至函数成为可能,而不会影响其他 @test 或污染您当前的 shell 会话。测试运行的输出是一种标准格式,可以被人类理解,并且可以被 TAP 使用者以编程方式解析或操作。这是 CI_COMMIT_REF_SLUG 测试块失败时的输出示例
✗ requires CI_COMMIT_REF_SLUG environment variable
(from function `assert_output' in file test/libs/bats-assert/src/assert.bash, line 231,
in test file test/ci_deploy.bats, line 26)
`assert_output --partial "CI_COMMIT_REF_SLUG"' failed
-- output does not contain substring --
substring (1 lines):
CI_COMMIT_REF_SLUG
output (3 lines):
./bin/deploy.sh: join_string_by: command not found
oc error
Could not login
--
** Did not delete , as test failed **
1 test, 1 failure
这是成功测试的输出
✓ requires CI_COMMIT_REF_SLUG environment variable
助手
与任何 shell 脚本或库一样,BATS 测试脚本可以包含辅助库,以在测试之间共享通用代码或增强其功能。这些辅助库,例如 bats-assert 和 bats-support,甚至可以使用 BATS 进行测试。
如果测试目录中的文件数量变得难以管理,可以将库放置在与 BATS 脚本相同的测试目录中,或者放置在 test/libs 目录中。BATS 提供了 load 函数,该函数接受相对于被测试脚本(在本例中为 test)的 Bash 文件路径,并 source 该文件。文件必须以 .bash 前缀结尾,但传递给 load 函数的文件路径不能包含该前缀。build.bats 加载 bats-assert 和 bats-support 库,一个小的 helpers.bash 库和一个 docker_mock.bash 库(如下所述),代码如下,位于解释器魔术行下方的测试脚本开头
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
load 'helpers'
load 'docker_mock'
存根测试输入和模拟外部调用
大多数 Bash 脚本和库在运行时执行函数和/或可执行文件。通常,它们被编程为基于这些函数或可执行文件的退出状态或输出(stdout、stderr)以特定方式运行。为了正确测试这些脚本,通常有必要制作这些命令的伪造版本,这些版本旨在在特定测试期间以特定方式运行,这个过程称为“存根”。可能还需要监视被测试的程序,以确保它调用特定的命令,或者它使用特定的参数调用特定的命令,这个过程称为“模拟”。有关更多信息,请查看这篇关于 Ruby RSpec 中模拟和存根的精彩 讨论,它适用于任何测试系统。
Bash shell 提供了可在 BATS 测试脚本中用于进行模拟和存根的技巧。所有这些都需要使用带有 -f 标志的 Bash export 命令来导出一个函数,该函数会覆盖原始函数或可执行文件。这必须在执行被测试程序之前完成。这是一个覆盖 cat 可执行文件的简单示例
function cat() { echo "THIS WOULD CAT ${*}" }
export -f cat
此方法以相同的方式覆盖函数。如果测试需要覆盖被测试脚本或库中的函数,则必须在存根或模拟该函数之前 source 被测试的脚本或库。否则,在 source 脚本时,存根/模拟将被实际函数替换。此外,请确保在运行要测试的命令之前进行存根/模拟。这是 build.bats 中的一个示例,它模拟了 build.sh 中描述的 raise 函数,以确保 login 函数引发特定的错误消息
@test ".login raises on oc error" {
source ${profile_script}
function raise() { echo "${1} raised"; }
export -f raise
run login
assert_failure
assert_output -p "Could not login raised"
}
通常,没有必要在测试后取消设置存根/模拟函数,因为 export 只会影响当前 @test 块的 exec 期间的当前子进程。但是,可以模拟/存根 BATS assert* 函数内部使用的命令(例如 cat、sed 等)。在运行这些断言命令之前,必须 unset 这些模拟/存根函数,否则它们将无法正常工作。这是来自 build.bats 的一个示例,它模拟 sed,运行 build_deployable 函数,并在运行任何断言之前取消设置 sed
@test ".build_deployable prints information, runs docker build on a modified Dockerfile.production and publish_image when its not a dry_run" {
local expected_dockerfile='Dockerfile.production'
local application='application'
local environment='environment'
local expected_original_base_image="${application}"
local expected_candidate_image="${application}-candidate:${environment}"
local expected_deployable_image="${application}:${environment}"
source ${profile_script}
mock_docker build --build-arg OAUTH_CLIENT_ID --build-arg OAUTH_REDIRECT --build-arg DDS_API_BASE_URL -t "${expected_deployable_image}" -
function publish_image() { echo "publish_image ${*}"; }
export -f publish_image
function sed() {
echo "sed ${*}" >&2;
echo "FROM application-candidate:environment";
}
export -f sed
run build_deployable "${application}" "${environment}"
assert_success
unset sed
assert_output --regexp "sed.*${expected_dockerfile}"
assert_output -p "Building ${expected_original_base_image} deployable ${expected_deployable_image} FROM ${expected_candidate_image}"
assert_output -p "FROM ${expected_candidate_image} piped"
assert_output -p "build --build-arg OAUTH_CLIENT_ID --build-arg OAUTH_REDIRECT --build-arg DDS_API_BASE_URL -t ${expected_deployable_image} -"
assert_output -p "publish_image ${expected_deployable_image}"
}
有时,同一个命令(例如 foo)会在同一个被测试函数中被多次调用,带有不同的参数。这些情况需要创建一组函数
- mock_foo: 将期望的参数作为输入,并将这些参数持久化到 TMP 文件
- foo: 命令的模拟版本,它使用持久化的期望参数列表处理每个调用。必须使用 export -f 导出此命令。
- cleanup_foo: 删除 TMP 文件,用于 teardown 函数。这可以测试以确保 @test 块在删除之前成功。
由于此功能通常在不同的测试中重复使用,因此创建可以像其他库一样加载的辅助库是有意义的。
一个好的例子是 docker_mock.bash。它被加载到 build.bats 中,并在任何测试调用 Docker 可执行文件的函数的测试块中使用。 使用 docker_mock 的典型测试块如下所示:
@test ".publish_image fails if docker push fails" {
setup_publish
local expected_image="image"
local expected_publishable_image="${CI_REGISTRY_IMAGE}/${expected_image}"
source ${profile_script}
mock_docker tag "${expected_image}" "${expected_publishable_image}"
mock_docker push "${expected_publishable_image}" and_fail
run publish_image "${expected_image}"
assert_failure
assert_output -p "tagging ${expected_image} as ${expected_publishable_image}"
assert_output -p "tag ${expected_image} ${expected_publishable_image}"
assert_output -p "pushing image to gitlab registry"
assert_output -p "push ${expected_publishable_image}"
}
此测试设置了对 Docker 将被调用两次,且具有不同参数的预期。 第二次调用 Docker 失败后,它运行被测试的命令,然后测试退出状态和对 Docker 的预期调用。
mock_docker.bash 引入的 BATS 的一个方面是 ${BATS_TMPDIR} 环境变量,BATS 在开始时设置该变量,以允许测试和助手在标准位置创建和销毁 TMP 文件。 如果测试失败,mock_docker.bash 库不会删除其持久化的 mock 文件,但它会打印出其位置,以便可以查看和删除它。 您可能需要定期清理此目录中的旧 mock 文件。
关于 mocking/stubbing 的一个注意事项: build.bats 测试有意识地违反了一条测试原则,即:不要 mock 你不拥有的东西! 该原则要求,对测试开发者没有编写的命令(如 docker、cat、sed 等)的调用,应包装在它们自己的库中,这些库应在使用它们的脚本的测试中进行 mock。 然后应该在不 mock 外部命令的情况下测试包装器库。
这是一个很好的建议,忽略它会付出代价。 如果 Docker CLI API 发生变化,测试脚本将无法检测到此更改,从而导致误报,直到被测试的 build.sh 脚本在新版本的 Docker 的生产环境中运行时才会显现出来。 测试开发人员必须决定他们想要严格遵守此标准的程度,但他们应该了解其决策所涉及的权衡。
结论
将测试机制引入任何软件开发项目都会在 a) 开发和维护代码及测试所需的时间和组织增加,以及 b) 开发人员对其应用程序在其生命周期内的完整性所具有的信心提高之间进行权衡。 测试机制可能并不适用于所有脚本和库。
通常,满足以下一个或多个条件的脚本和库应使用 BATS 进行测试:
- 它们值得存储在源代码控制中
- 它们用于关键流程,并且依赖于在很长一段时间内稳定运行
- 它们需要定期修改以添加/删除/修改其功能
- 它们被其他人使用
一旦决定将测试规范应用于一个或多个 Bash 脚本或库,BATS 就会提供其他软件开发环境中可用的全面测试功能。
致谢:我感谢 Darrin Mann 向我介绍 BATS 测试。
3 条评论