Skip to content
shellhacks.dev
Testing

Bats vs shunit2 vs shellspec for Shell Testing With Examples

Compare Bats, shunit2 and ShellSpec for shell testing: syntax, assertions, mocking, parallel runs, portability and which to choose for your project.

6 min read
Summarize in AI
Illustration for "Bats vs shunit2 vs shellspec for Shell Testing With Examples": a shell script in an editor, test results, a loop arrow.

Three frameworks cover shell testing, and they differ more in style than in power. The right choice depends on which shells your scripts target and how much structure you want. Each of the examples below tests the same function, add, which prints the sum of two numbers. They extend shunit2 and ShellSpec basics and use Bats 1.11, shunit2 2.1 and ShellSpec 0.28.

The function under test

Shell
# lib.sh
add() {
  if [ "$#" -ne 2 ]; then echo "usage: add A B" >&2; return 2; fi
  echo $(( $1 + $2 ))
}

Bats

Shell
#!/usr/bin/env bats
setup() { source "$BATS_TEST_DIRNAME/lib.sh"; }

@test "adds two numbers" {
  run add 2 3
  [ "$status" -eq 0 ]
  [ "$output" = "5" ]
}

@test "needs two arguments" {
  run add 1
  [ "$status" -eq 2 ]
  [[ $output == *usage* ]]
}
Text
$ bats lib.bats
 ✓ adds two numbers
 ✓ needs two arguments

2 tests, 0 failures

Test files are Bash with a small preprocessor for @test. run captures status and output into variables, and plain [ ] tests are the assertions; the helper libraries bats-assert and bats-support add readable ones (bats-assert). Tests run in separate subshells, so state does not leak. Bats requires Bash and is the most widely used, which means the most examples, a CI action and answers online.

shunit2

Shell
#!/bin/sh
. ./lib.sh

testAdd() {
  assertEquals "5" "$(add 2 3)"
}

testUsage() {
  add 1 >/dev/null 2>&1
  assertEquals 2 $?
}

. shunit2
Text
testAdd
testUsage

Ran 2 tests.

OK

shunit2 is a single file that you source at the end of the test script. Functions whose names start with test are discovered and run, with setUp and tearDown hooks, in the xUnit style. Its strength is portability: it runs under sh, dash, ksh and zsh, so you can test POSIX scripts with the shell they target, as described in testing under multiple shells. It has no mocking and fewer niceties; tests share one shell unless you isolate them.

ShellSpec

Shell
Describe 'add()'
  Include ./lib.sh

  It 'adds two numbers'
    When call add 2 3
    The output should eq 5
    The status should be success
  End

  It 'needs two arguments'
    When call add 1
    The status should eq 2
    The stderr should include 'usage'
  End

  Parameters
    1 1 2
    10 -4 6
  End
  It "adds $1 and $2"
    When call add "$1" "$2"
    The output should eq "$3"
  End
End
Text
$ shellspec
Running: /bin/sh [dash]
add()
  adds two numbers
  needs two arguments
  adds 1 and 1
  adds 10 and -4

Finished in 0.31 seconds
4 examples, 0 failures

ShellSpec reads like specifications, with a DSL for expectations on output, status and stderr, parameterised examples, mocks and stubs (Mock) and a coverage report through kcov. It can run the same spec under many shells with --shell. The DSL has to be learned and spec files are not valid shell, so editors and linters understand them less. It is the most capable of the three.

Choosing

Text
                    Bats          shunit2        ShellSpec
Style               test blocks   xUnit          BDD / specs
Shells              bash          sh/bash/ksh/zsh  most POSIX shells
Mocking             helper lib    none           built in
Parametrised tests  loops         loops          built in
Parallel runs       --jobs        no             --jobs
Learning curve      low           low            medium

For a Bash codebase, choose Bats: low effort, many examples and a mature bats-action for CI (Bats tests in CI). For sh scripts that must work on dash and BusyBox, shunit2 or ShellSpec under those shells. For large, long-lived projects with a lot of external commands to fake, ShellSpec's built-in mocking pays back its learning cost. You can mix: use Bats for end-to-end CLI checks and ShellSpec for unit specs, though one framework per repository keeps contributors sane.

Setup cost and the learning curve

All three frameworks let you test shell functions, but they differ in how much a newcomer must learn. Bats has the lowest barrier: a test is a function with a name in a plain file, assertions are ordinary shell commands, and the output follows the Test Anything Protocol that CI systems read. It is installed with a package manager or as a git submodule. shunit2 resembles xUnit libraries from other languages, with setUp, tearDown and assertion functions, and it is a single file sourced by the test script, so it needs nothing installed. ShellSpec has a domain-specific language with Describe, It and When call keywords, which reads well but takes longer to learn and needs its own runner.

Consider who will write and read the tests. A team that already knows xUnit will be at home with shunit2, and a team that wants tests to look like shell will prefer Bats.

Mocking and isolation

Real scripts call other programs, and tests should not run them for real. In Bats, the usual approach is to put a stub script earlier in PATH that records its arguments and prints canned output, a technique that works in any framework. ShellSpec has built-in support for mocking functions and commands, and for stubbing, which removes the boilerplate. With shunit2, you define replacement functions in the test file, which shadow the commands the code under test calls. Choose the approach that keeps the test readable, and keep each stub as small as it can be: a stub that grows its own logic needs tests of its own.

Isolation also covers the file system. Run each test in a temporary directory, create inputs inside it and clean up afterwards. Bats provides BATS_TEST_TMPDIR for exactly this.

Reporting, speed and CI integration

Output format matters in CI. Bats prints TAP by default and has JUnit and other formatters through options, which most systems can display as test reports. ShellSpec supports several formats including JUnit and a built-in code coverage report using kcov. shunit2 prints plain text, and converting to a report format needs extra work. For speed, all three start a shell per test file, and Bats can run files in parallel with GNU parallel. For a project with hundreds of tests, the difference shows, and parallel execution cuts the total time substantially if tests are independent.

Run the suite on the shells you support. A Bash-only test on a script that claims to be POSIX proves little, so run the same tests under Dash and BusyBox as well, which catches bashisms.

A decision rule

For most teams, start with Bats: it is the most widely documented, the helper libraries bats-assert and bats-support give readable assertions, and people can read tests with no training. Pick shunit2 when you cannot install anything and want a single file in the repository, or when you test a plain POSIX script and need the framework itself to run under Dash. Pick ShellSpec for a larger project that needs mocking, coverage and a specification style, and accept the learning cost. Whatever you choose, the greater value is in the habit: write a failing test for each bug, test functions separately from the code that runs them, and keep the suite fast enough that people run it before committing. A modest suite that runs on every push beats an ambitious one that nobody runs.

Plan the test layout before the first test. Keep the code under test in functions in a file that can be sourced without side effects, with a small main section guarded by a check that the file is being executed. Tests then source the file and call the functions directly. That structure matters more than the framework, because a script that runs everything at load time cannot be tested piece by piece in any of the three. The refactor is usually small and pays for itself in the first bug it prevents.

Where this does not apply

For very small scripts, a plain shell file that runs commands and compares output, with set -e, is a valid test. If the code is moving to Python or Go, use that language's framework. See the Bats documentation, the shunit2 repository and the ShellSpec site.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.