Shell 脚本的单元测试

我多年来开发的几乎所有产品都涉及到一定级别的 shell 脚本(或 Windows 上的批处理文件、 PowerShell 等)。尽管我们用 Java 或 C + + 编写了大部分代码,但似乎总有一些集成或安装任务更适合用 shell 脚本来完成。

这样,shell 脚本就成为了发布的代码的一部分,因此需要像编译的代码一样进行测试。有人对现有的一些 shell 脚本单元测试框架(如 Shunit2)有经验吗?我现在主要对 Linux shell 脚本感兴趣; 我想知道测试工具如何很好地复制其他 xUnit 框架的功能和易用性,以及如何轻松地与 CruiseControl 或 Hudson 这样的连续构建系统集成。

68293 次浏览

UPDATE 2019-03-01: My preference is bats now. I have used it for a few years on small projects. I like the clean, concise syntax. I have not integrated it with CI/CD frameworks, but its exit status does reflect the overall success/failure of the suite, which is better than shunit2 as described below.


PREVIOUS ANSWER:

I'm using shunit2 for shell scripts related to a Java/Ruby web application in a Linux environment. It's been easy to use, and not a big departure from other xUnit frameworks.

I have not tried integrating with CruiseControl or Hudson/Jenkins, but in implementing continuous integration via other means I've encountered these issues:

  • Exit status: When a test suite fails, shunit2 does not use a nonzero exit status to communicate the failure. So you either have to parse the shunit2 output to determine pass/fail of a suite, or change shunit2 to behave as some continuous integration frameworks expect, communicating pass/fail via exit status.
  • XML logs: shunit2 does not produce a JUnit-style XML log of results.

Roundup by @blake-mizerany sounds great, and I should make use of it in the future, but here is my "poor-man" approach for creating unit tests:

  • Separate everything testable as a function.
  • Move functions into an external file, say functions.sh and source it into the script. You can use source `dirname $0`/functions.sh for this purpose.
  • At the end of functions.sh, embed your test cases in the below if condition:

    if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    fi
    
  • Your tests are literal calls to the functions followed by simple checks for exit codes and variable values. I like to add a simple utility function like the below to make it easy to write:

    function assertEquals()
    {
    msg=$1; shift
    expected=$1; shift
    actual=$1; shift
    if [ "$expected" != "$actual" ]; then
    echo "$msg EXPECTED=$expected ACTUAL=$actual"
    exit 2
    fi
    }
    
  • Finally, run functions.sh directly to execute the tests.

Here is a sample to show the approach:

    #!/bin/bash
function adder()
{
return $(($1+$2))
}


(
[[ "${BASH_SOURCE[0]}" == "${0}" ]] || exit 0
function assertEquals()
{
msg=$1; shift
expected=$1; shift
actual=$1; shift
/bin/echo -n "$msg: "
if [ "$expected" != "$actual" ]; then
echo "FAILED: EXPECTED=$expected ACTUAL=$actual"
else
echo PASSED
fi
}


adder 2 3
assertEquals "adding two numbers" 5 $?
)

In addition to roundup and shunit2 my overview of shell unit testing tools also included assert.sh and shelltestrunner.

I mostly agree with roundup author's critique of shunit2 (some of it subjective), so I excluded shunit2 after looking at the documentation and examples. Although, it did look familiar having some experience with jUnit.

In my opinion shelltestrunner is the most original of the tools I've looked at since it uses simple declarative syntax for test case definition. As usual, any level of abstraction gives some convenience at the cost of some flexibility. Even though, the simplicity is attractive I found the tool too limiting for the case I had, mainly because of the lack of a way to define setup/tearDown actions (for example, manipulate input files before a test, remove state files after a test, etc.).

I was at first a little confused that assert.sh only allows asserting either output or exit status, while I needed both. Long enough to write a couple of test cases using roundup. But I soon found the roundup's set -e mode inconvenient as non-zero exit status is expected in some cases as a means of communicating the result in addition to stdout, which makes the test case fail in said mode. One of the samples shows the solution:

status=$(set +e ; rup roundup-5 >/dev/null ; echo $?)

But what if I need both the non-zero exit status and the output? I could, of course, set +e before invocation and set -e after or set +e for the whole test case. But that's against the roundup's principle "Everything is an Assertion". So it felt like I'm starting to work against the tool.

By then I've realized the assert.sh's "drawback" of allowing to only assert either exit status or output is actually a non-issue as I can just pass in test with a compound expression like this

output=$($tested_script_with_args)
status=$?
expected_output="the expectation"
assert_raises "test \"$output\" = \"$expected_output\" -a $status -eq 2"

As my needs were really basic (run a suite of tests, display that all went fine or what failed), I liked the simplicity of assert.sh, so that's what I chose.

Wondering why nobody mentioned BATS. It's up-to-date and TAP-compliant.

Describe:

#!/usr/bin/env bats


@test "addition using bc" {
result="$(echo 2+2 | bc)"
[ "$result" -eq 4 ]
}

Run:

$ bats addition.bats
✓ addition using bc


1 tests, 0 failures

You should try out the assert.sh lib, very handy, easy to use

local expected actual
expected="Hello"
actual="World!"
assert_eq "$expected" "$actual" "not equivalent!"
# => x Hello == World :: not equivalent!

I recently released new testing framework called shellspec.

shellspec is BDD style testing framework. It's works on POSIX compatible shell script including bash, dash, ksh, busybox etc.

Of course, the exit status reflects the result of running of the specs and it's has TAP-compliant formatter.

The specfile is close to natural language and easy to read, and also it's shell script compatible syntax.

#shellcheck shell=sh


Describe 'sample'
Describe 'calc()'
calc() { echo "$(($*))"; }


It 'calculates the formula'
When call calc 1 + 2
The output should equal 3
End
End
End

I have recently encountered a very thorough review of existing Bash unit testing frameworks - https://github.com/dodie/testing-in-bash

Shellspec has been so far the best, however it still depends on what you would like to achieve.