# Bach 单元测试框架 [![Build Status](https://travis-ci.org/bach-sh/bach.svg)](https://travis-ci.org/bach-sh/bach) [![GitHub Actions](https://github.com/bach-sh/bach/workflows/Testing%20Bach/badge.svg)](https://github.com/bach-sh/bach) [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![License: MPL v2](https://img.shields.io/badge/License-MPL%20v2-blue.svg)](https://www.mozilla.org/en-US/MPL/2.0/) [![Run on Repl.it](https://repl.it/badge/github/bach-sh/bach)](https://repl.it/github/bach-sh/bach) Bach is a [Bash](https://www.gnu.org/software/bash/) testing framework, can be used to test scripts that contain dangerous commands like `rm -rf /`. No surprises, no pain. [Bach](https://bach.sh) 是一个 Bash 脚本测试框架,可以用来测试包含了类似 `rm -rf /` 这样危险命令的脚本,不会给你惊喜,不会让你感到痛苦。 - 网站: https://bach.sh - 代码: https://github.com/bach-sh/bach - [See the English version of this document](README.md) ## Bach 入门 Bach 单元测试框架作为一个真正的 Bash 脚本的单元测试框架,意味着任何在 PATH 环境变量中的命令都成为了被测的 Bash 脚本的外部依赖,这些外部命令都不会被真正的执行。换句话说,在 Bach 的测试中,除了部分的内置命令外,所有的命令都是 "Dry Run" 的。所以,在 Bach 中,验证的是命令被执行的时候是否使用了期望的参数,而非验证命令的执行结果。毕竟,我们测试的是 Bash 脚本的行为,而非测试那些命令是否可以正常工作。Bach 也提供了一系列的 API 可以用于模拟命令的执行。 ### Bach 的依赖 Bach 需要 Bash v4.3 或更高版本。在 GNU/Linux 上还需要 Coreutils 和 Diffutils,在常用的发行版中都已经默认安装好了。Bach 在 Linux/macOS/Cygwin/Git Bash/FreeBSD 等操作系统或者运行环境中验证通过。 - [Bash](https://www.gnu.org/software/bash/) v4.3+ - [Coreutils](https://www.gnu.org/software/coreutils/coreutils.html) (*GNU/Linux*) - [Diffutils](https://www.gnu.org/software/diffutils/diffutils.html) (*GNU/Linux*) ### 安装 Bach Bach 的安装很简单,只需要下载 [bach.sh](https://github.com/bach-sh/bach/raw/master/bach.sh) 到你的项目中,在测试脚本中用 `source` 命令导入 Bach 框架的 `bach.sh` 即可。 比如: source path/to/bach.sh #### 一个简单的测试示例 #!/usr/bin/env bash source bach.sh test-rm-rf() { # Bach 的标准测试用例是由两个方法组成 # - test-rm-rf # - test-rm-rf-assert # 这个方法 `test-rm-rf` 是测试用例的执行 project_log_path=/tmp/project/logs rm -rf "$project_log_ptah/" # 注意,这里有个笔误! } test-rm-rf-assert() { # 这个方法 `test-rm-rf-assert` 是测试用例的验证 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 } 更多的测试示例请看 [tests/bach-testing-framework.test.sh](tests/bach-testing-framework.test.sh) #### 如何运行测试? 推荐在测试用例文件的第一行使用 Shebang: ```bash #!/usr/bin/env bash ``` 使用 `#!/usr/bin/env bash` 而不是 `#!/bin/bash` 的好处是,它会从用户的 `PATH` 环境变量中查找 `bash` 可执行文件。这尤其重要,因为在某些操作系统(如 macOS)上,默认的 `/bin/bash` 是一个非常陈旧的 3.x 版本,而 Bach 框架需要 Bash v4.3 或更高版本。通过 Homebrew (`brew install bash`) 安装的较新版本的 Bash 通常位于 `/usr/local/bin/bash` 或类似路径下,`#!/usr/bin/env bash` 可以确保调用到这个正确的、较新的版本。 设置好 Shebang 后,给测试用例文件添加可执行权限后便可直接运行(推荐方式): ```bash chmod +x your-test-file.test.sh ./your-test-file.test.sh ``` 或者,你也可以直接用 `bash` 命令来执行测试脚本: ```bash bash your-test-file.test.sh ``` **重要提示:** 请不要使用 `source` 命令来执行测试用例(例如 `source your-test-file.test.sh`)。`source` 会在当前的 Shell 环境中执行脚本,这会污染你当前的会话,并可能导致预料之外的副作用。测试用例应当在一个独立的子 Shell 进程中执行,以保证测试环境的纯净和隔离。 #### Windows shebang 得是 ``` #!/bin/bash ``` 而非 ``` #!/bin/sh ``` 若以 Cygwin 而非 Git Bash 运行,要将 `bach.sh` 的行尾序列改为 `LF`. ### 用 Bach 来写脚本测试 与我们所熟悉的测试框架不同的是,Bach 的标准测试用例是由两个方法组成,这样做的目的是为了让测试用例的验证变得简单。测试用例的执行部分是写在以 `test-` 开头的方法中,然后 Bach 会寻找与这个测试方法名称对应的以 `-assert` 结尾的测试验证方法。所以,每一个 Bach 的测试执行方法都必须不能以 `-assert` 作为后缀。比如,一个名为 `test-rm-rf` 的测试执行方法,对应的测试验证方法是 `test-rm-rf-assert`。 例子: source bach.sh test-rm-rf() { project_log_path=/tmp/project/logs sudo rm -rf "$project_log_ptah/" # 这里写错了变量名,Bash 默认让变量变成空字符串,这可能是个严重的问题! } test-rm-rf-assert() { sudo rm -rf / } Bach 会分别运行两个方法,去验证两个方法中执行的命令及其参数是否是一致的。第一个方法 `test-rm-rf` 是 Bach 的测试用例的执行,与之对应的测试验证方法就是 `test-rm-rf-assert` 这个方法 如果 Bach 没有找到某个测试用例的测试验证方法,Bach 会尝试用传统的一个测试方法的形式。在这种方式下,测试执行方法里面必须要有断言 API 的调用,否则改测试一定会失败。 例子: test-single-function-style() { declare i=2 @assert-equals 4 "$((i*2))" } Bach 的断言 API 有: - `@assert-equals` - `@assert-fail` - `@assert-success` 如果 Bach 没有找到对应的测试验证方法,同时在测试执行方法里面也没有断言的调用,这个测试用例就一定是失败的。 如果一个测试用例的方法名称是以 `test-ASSERT-FAIL` 开头,则意味着反转这个测试用例的执行结果。也就是说,如果测试验证成功,反而结果是失败的。如果测试验证失败,则结果是成功的。 ### 用 Bach 来模拟命令的调用 Bach 测试框架中有一系列的 Mock API,可以用于模拟命令和脚本的执行。 Mock API 有: - `@mock` - `@ignore` - `@mockall` - `@mocktrue` - `@mockfalse` - `@@mock` 但是仍然有个别内建命令是不允许 mock 的,有: - `builtin` - `declare` - `eval` - `set` - `unset` - `true` - `false` - `read` 如果在测试用例的执行中,试图去模拟这些被禁止模拟的内建命令,测试用例一定会失败的。对于这种情况,通常的解决办法是,把这些被禁止模拟的内建命令用一个方法包装起来,然后用 Bach 来模拟这个方法。 ### 在 Bach 的测试用例中调用真实的命令 在测试用例中,为了能够稳定的、重复的、随机的执行测试用例,同时也是为了写出单元测试,我们通常要避免直接调用外部命令。但 Bach 也提供了一系列的 API 用于直接执行命令。 因为 Bach 中可以模拟几乎所有的命令,而且 Bach 的实现代码里面也一定会执行这些真实的命令。如果不可避免的要在测试用例中执行真实的命令,Bach 提供了名为 `@real` 的 API 来执行真实命令。只要在希望执行的命令前面加上 `@real` 就可以跳过 Bach 的限制。 Bach 也提供了一些常用命令的 API,这些 API 对应的真实命令都是在 Bach 启动之前从系统的 PATH 环境变量中获取的,有: - `@cd` - `@command` - `@echo` - `@exec` - `@false` - `@popd` - `@pushd` - `@pwd` - `@set` - `@trap` - `@true` - `@type` - `@unset` - `@eval` - `@source` - `@cat` - `@chmod` - `@cut` - `@diff` - `@find` - `@env` - `@grep` - `@ls` - `@shasum` - `@mkdir` - `@mktemp` - `@rm` - `@rmdir` - `@sed` - `@sort` - `@tee` - `@touch` - `@which` - `@xargs` 由于 `command` 和 `xargs` 命令的特殊性,Bach 中很特别的为这两个命令做了模拟。 因为 Bach 中的模拟命令的实现都是采用创建同名函数的方式,所以用 `command` 命令是无法执行这些被模拟的命令的,`@command` 这个 API 则会根据后面的命令的类型来决定行为。 而 `@xargs` 中,则真的会调用系统中的 `xargs` 命令,但默认也不会真的执行命令。 例如: test-xargs-no-dash-dash() { @mock ls === @stdout foo bar ls | xargs -n1 rm -v } test-xargs-no-dash-dash-assert() { xargs -n1 rm -v } test-xargs() { @mock ls === @stdout foo bar ls | xargs -n1 -- rm -v } test-xargs-assert() { rm -v foo rm -v bar } test-xargs-0() { @mock ls === @stdout foo bar ls | xargs -- rm -v } test-xargs-0-assert() { rm -v foo bar } 我们还可以模拟测试命令 `[ ... ]`。但如果没有模拟的话,测试将会保持原有的行为。 例如: test-if-string-is-empty() { if [ -n "original behavior" ] # 没有模拟这个测试,将保持默认行为 then It keeps the original behavior by default # 应该看到这一行 else It should not be empty fi @mockfalse [ -n "Non-empty string" ] # 我们可以通过模拟一个测试来反转其结果 if [ -n "Non-empty string" ] then Non-empty string is not empty # 不,我们看不到这个 else Non-empty string should not be empty but we reverse its result fi } test-if-string-is-empty-assert() { It keeps the original behavior by default Non-empty string should not be empty but we reverse its result } # 模拟测试命令 `[ ... ]` 是很有用的,比如当我们想去检查一个有绝对路径的文件是否存在的时候 test-a-file-exists() { @mocktrue [ -f /etc/an-awesome-config.conf ] if [ -f /etc/an-awesome-config.conf ]; then Found this awesome config file else Even though this config file does not exist fi } test-a-file-exists-assert() { Found this awesome config file } ### 配置 Bach 有一系列的以 `BACH_` 开头的环境变量用于配置 Bach 测试框架,有 - `BACH_DEBUG` 默认为 `false`,如果为 `true` 就会启用 Bach 的 `@debug` API - `BACH_COLOR` 默认为 `auto`,会根据使用情况来决定是否启用颜色输出;设置为 `always` 始终允许颜色输出;设置为 `no` 则不允许颜色输出。 - `BACH_TESTS` 默认为空,表示执行所有测试。可以使用通配符来匹配之允许执行的测试用例。 - `BACH_DISABLED` 默认为 `false`,如果为 `true` 则会禁用 Bach。如果希望把 Bach 直接集成到程序中,通过特定的开关来决定是否禁用 Bach。 - `BACH_ASSERT_DIFF` 默认为系统中原始 PATH 环境变量中找到的第一个 `diff` 命令。用于比较测试执行方法和测试验证方法的执行结果时使用。 - `BACH_ASSERT_DIFF_OPTS` 默认为 `-u`,用于 `$BACH_ASSERT_DIFF` 命令的选项。 ## Bach 的限制 ### 不能阻止直接使用绝对路径的命令调用 在这种场景下,直接命令的调用是由系统 API 直接完成,并不会与 Bash 发生任何交互,所以 Bach 无法拦截这样的命令。我们可以在 Bash 脚本中把这些绝对路径调用的命令,用一个方法来包装起来,然后再用 `@mock`/`@@mock` 等 API 来模拟这个方法。 ### 在测试中禁止变更 PATH 环境变量 因为 Bach 要拦截所有的命令调用,而重设 `PATH` 会破坏 Bach 的行为,所以 Bach 中默认设置 PATH 环境变量为只读。 对于需要重设 PATH 的场景,建议使用 `declare` 内建命令来避免重设一个只读环境变量而导致的出错。 ### 在 Bach 的以 `-assert` 结尾的测试验证方法中不可以模拟任何命令 因为 Bach 的测试验证就是通过分别执行测试执行函数和测试验证函数来达到的。我们已经在测试执行函数中模拟了命令的调用,在测试验证函数中,就应该直接写出期待执行的命令及其参数。在测试验证中模拟命令的调用是没有道理的。 ### 在 Bach 中对 I/O 重定向的特殊处理 Bach 中被模拟的命令,已经支持了从标准输入读取命令和管道的调用。但对于使用 `>` `>>` 等等操作符的使用由于 Bash 的限制实际是无法拦截的。 解决办法一个是把重定向的命令用一个方法包装起来,比如写入文件的 I/O 重定向,改用 `tee` 命令来实现。 例如:把原始命令 `your-cmd > path/to/file` 改写为 `your-cmd | tee path/to/file >/dev/null`。 I/O 重定向的数据也可以用 API `@capture` 指示命令将会接受数据,然后在验证方法中使用 API `@assert-capture` 来验证指定的命令是否收到了期望的数据,比如下面的两个例子: test-api-capture() { @capture foobar foo bar @echo hello | foobar foo bar } test-api-capture-assert() { @assert-capture foobar foo bar <<< hello } test-api-capture-heredoc() { @capture foobar foo bar foobar foo bar <<\EOF the first line the 2nd line the 3rd line EOF } test-api-capture-heredoc-assert() { @echo "the first line the 2nd line the 3rd line" | @assert-capture foobar foo bar } ### 必须模拟管道中的每一个命令 在 Bash 中的管道命令事实上是运行在子进程中的,如果没有模拟这些命令,会导致管道命令的执行顺序并不一定是按照脚本中的调用顺序来执行的。为了确保 Bach 测试用例的执行稳定性,所有的管道命令必须按照其参数的使用来精确的模拟。 ### 使用空集符号 `∅` 来表示空字符串 因为无法在终端上显示出空字符串, Bach 选择了用红色空集符号 `∅` 来指示这是一个空字符串。 当我们在测试报告中看到这个红色符号 `∅` 的时候,表示这个参数实际上是一个空字符串。 ``` -foobar ∅ +foobar ``` ## Bach API 列表 Bach 测试框架中提供的 API 都是以 `@` 开头的。 ### @allow-real 允许指定命令在测试中真实运行,而不进行 mock 或 dryrun。调用此 API 后,指定命令会直接调用系统命令执行,并在测试结果中显示允许真实执行的提示。 用法: @allow-real [command and arguments...] 例子: test-allow-real-grep() { @allow-real grep -i hello grep -i hello <<< "Hello, Bach" grep -i hello <<< "Goodbye" } test-allow-real-grep-assert() { @stdout "Hello, Bach" @fail } 输出: ok 1 - allow real grep # [ALLOW-REAL] grep -i hello 当调用 `@allow-real grep -i hello` 后,`grep -i hello` 这一条命令将在测试时真实执行,并且在测试结果中用 `# [ALLOW-REAL] grep -i hello` 进行标记,方便追踪和调试。 ### @assert-capture 这个 API 仅仅可以用于测试验证函数,用于验证命令读取的输入是否为预期。 使用方法: 1. 在测试执行函数中,使用`@capture` API记录命令的输入。 2. 在验证函数中,使用`@assert-capture` API验证捕获的数据是否与预期输入匹配。 例子: test-capture() { @capture foobar foo # 要记录命令 `foobar foo` 读取的数据 echo hello | foobar foo # 命令 `foobar foo` 从管道读取数据 } test-capture-assert() { @assert-capture foobar foo <<< "hello" # 验证命令 `foobar foo` 读取的数据是 `hello` } ### @assert-equals 断言两个值是否相等。 例子: @assert-equals "hello world" "HELLO WORLD" @assert-equals 1 2 ### @assert-fail 断言前一个命令会执行失败 例子: false # 这个命令的返回值不为 0,则断言会成功 @assert-fail true # 这个命令的返回值为 0,则断言会失败 @assert-fail ### @assert-success 断言前一个命令要执行成功 例子: true # 这个命令的返回值为 0,则断言会成功 @assert-fail false # 这个命令的返回值不为 0,则断言会失败 @assert-fail ### @capture 记录命令读取的数据,包括通过管道、Here Document 等 I/O 重定向的方式。 例子请参考 `@assert-capture` API。 ### @cd 执行真正的内置 `cd` 命令。 ### @command 执行真正的内置 `command` 命令。 ### @comment 这个命令用于在执行的结果中输出会被 Bach 忽略的注释,对于在失败的测试结果中定位问题很很有用。 ### @debug 这个命令用于在执行测试的调试,如果执行测试的时候没有启用 `xtrace` 则这个命令不会有任何输出。 ### @die 这个命令用于立刻终止并非正常退出,显示可选的错误消息 ### @do-not-panic 为了尽可能的防止敲错这个 API,Bach 提供了以下的不同名称: - `donotpanic` - `dontpanic` - `do-not-panic` - `dont-panic` - `do_not_panic` - `dont_panic` 如果在 Bach 接管运行环境前执行了一个包含危险命令的测试,可能会带来很严重的后果。在包含危险命令的前面用上这个命令,会防止无意中调用这个危险的测试。 ### @do-nothing 什么也不做。 通常在验证函数中只有这个 API 的时候来验证在测试汉中没有执行任何命令。 例如: test-nothing() { declare i=9 if [[ "$i" -eq 0 ]]; then do-something fi } test-nothing-assert() { @do-nothing } ### @dryrun 在默认情况下,Bach 会使用 `@dryrun` 来确保任何命令都不会被运行。如果之前已经模拟了一个命令,但又不想真正运行,可以使用 `@dryrun` 来防止该命令被执行。 例子: @mock ls === @stdout file1 file2 # 做了了一个 `ls` 命令的模拟 ls # 这个命令会返回 `file1` 和 `file2` @dryrun ls # 不运行 ls ### @echo 执行真正的内置 `echo` 命令。 ### @err 显示一个错误消息 ### @exec 执行真正的内置 `exec` 命令。 ### @fail 立刻失败当前测试。 ### @false 执行真正的内置 `false` 命令。 ### @ignore 忽略被模拟的函数。 被忽略的函数,不会影响任何的测试和验证,且永远执行成功。 与 `@mocktrue` 不同的是,`@ignore` 忽略掉命令的任何参数。 例子: @ignore echo cd # 忽略掉 `echo` 和 `cd` 命令 ### @load_function 加载指定脚本中的函数,用于测试一个脚本中的指定函数 例子: @load_function path/to/script.sh func-name # 加载脚本 path/to/script.sh 里面的 func-name 定义 func-name foo bar # 加载成功后,就可以执行执行函数 `func-name` 了 ### @mock 模拟命令或脚本的执行,如果命令执行的时候需要指定不同的参数,那就需要多次模拟 API `@@mock`。 使用方法,在 `@mock` 后面是要模拟的完整命令,然后 `===` 后面是模拟的行为或输出。 例如: `@mock your-command param1 param2 === @stdout out1 out2` 如果省略了 `===`,默认的行为是调用 `@dryrun` API 输出命令,这种方式常见于模拟管道命令来防止随机顺序的验证。如果需要将管道中的输入传递到输出,请使用 API `@mockpipe`。 注意: - 如果要模拟一个脚本的执行,该脚本的路径必须是相对路径,不能模拟绝对路径的脚本。 - 多次模拟一个命令,只有最后一次的模拟生效 例子: #### 模拟命令 @mock ls file1 === @stdout file2 ls file1 # 会在控制台输出 file2,列出文件 `file1`,但显示的是 `file2`,很怪,对不对? ls foo bar # 因为没有模拟 `ls` 命令和特定的参数,所以 `ls foo bar` 需要被验证 #### 不指定行为或者输出的模拟 # 模拟了管道中的所有命令可以确保验证的时候命令的执行顺序如同管道中的顺序一致 @mock foo param1 param2 @mock bar param1 @mock foobar foo param1 param2 | bar parm1 | foobar #### 模拟命令,但希望使用复杂的实现 例子: @mock ls <<<\CMD if [[ "$var" -eq 1 ]]; then @stdout one else @stdout others fi CMD var=1 ls # 会输出 one,因为变量 `var` 的值是 `1` @unset var ls # 会输出 others,因为还没有定义变量 `var` ### @@mock 模拟命令的多次执行返回不同的值。 test-ls() { @@mock ls === @stdout file1 @@mock ls === @stdout file2 @@mock ls === @stdout file3 ls ls ls ls # 如果没有更多的命令序列,则会返回最后一次的模拟的结果 } test-ls-assert() { @cat <