shUnit2: Unit Testing for Shell Scripts

shUnit2: Unit Testing for Shell Scripts

Most shell testing frameworks assume you are running bash. Real infrastructure does not. Log processors run under POSIX sh on Alpine Linux. Deployment scripts run under ksh on AIX. Init scripts run under dash on Debian. If your tests only work with bash, you will not catch the bugs that surface in production on the shells you actually use.

shUnit2 is the answer. It is a single shell script — no dependencies, no package manager required, no bash-isms — that brings xUnit-style testing to any POSIX-compatible shell. You source it, write test functions, and it handles discovery, execution, and reporting. This guide covers everything from installation through a real-world deployment script test suite.

What is shUnit2?

shUnit2 is modeled after JUnit. Each test function name begins with test, a setUp function runs before each test, a tearDown function runs after each, and the framework collects results and prints a summary. It works identically under bash, zsh, ksh, mksh, and dash — the four shells that cover virtually every Linux and macOS server you will encounter.

It predates BATS by years and is battle-tested in environments where modern tooling cannot be installed. Its portability is its primary advantage. Its weakness is ergonomics: there is no --filter flag, no parallel execution, and assertion messages are sparse compared to bats-assert. For portable scripts in constrained environments, it is the right choice.

Installation

shUnit2 is a single file. Download and source it.

Option 1: Download directly

mkdir -p <span class="hljs-built_in">test/libs
curl -Lo <span class="hljs-built_in">test/libs/shunit2 \
  https://raw.githubusercontent.com/kward/shunit2/master/shunit2
<span class="hljs-built_in">chmod +x <span class="hljs-built_in">test/libs/shunit2

Option 2: Package manager (some distros)

# Ubuntu/Debian
apt-get install shunit2

<span class="hljs-comment"># macOS via Homebrew
brew install shunit2

Option 3: Git submodule (recommended for teams)

git submodule add \
  https://github.com/kward/shunit2.git \
  test/libs/shunit2-repo

Then source test/libs/shunit2-repo/shunit2 in your test files.

The git submodule approach pins a specific version and guarantees reproducibility across machines and CI environments.

Writing Your First Test

A shUnit2 test file is an ordinary shell script. Test functions are named with the test prefix. The last line sources shUnit2, which automatically discovers and runs all test* functions.

#!/bin/sh
<span class="hljs-comment"># test/test_math.sh

<span class="hljs-comment"># Source our script under test
. ./scripts/math.sh

<span class="hljs-function">testAddition() {
  result=$(add 2 3)
  assertEquals <span class="hljs-string">"add 2 3 should return 5" <span class="hljs-string">"5" <span class="hljs-string">"$result"
}

<span class="hljs-function">testSubtraction() {
  result=$(subtract 10 4)
  assertEquals <span class="hljs-string">"subtract 10 4 should return 6" <span class="hljs-string">"6" <span class="hljs-string">"$result"
}

<span class="hljs-function">testDivisionByZero() {
  result=$(divide 10 0 2>&1)
  assertEquals <span class="hljs-string">"division by zero should return error code 1" <span class="hljs-string">"1" <span class="hljs-string">"$?"
}

<span class="hljs-comment"># Source shUnit2 last — it runs all test* functions automatically
. ./test/libs/shunit2

Run it:

sh test/test_math.sh

Output:

testAddition
testSubtraction
testDivisionByZero

Ran 3 tests.

OK

Note the shebang uses /bin/sh, not /bin/bash. This enforces POSIX compliance from the start. If you are testing a bash-specific script, change the shebang to match.

setUp and tearDown

setUp runs before each test function. tearDown runs after each test, even if the test fails. This mirrors JUnit's @Before and @After.

#!/bin/sh

<span class="hljs-function">setUp() {
  <span class="hljs-comment"># Create a temporary working directory
  TEST_DIR=$(<span class="hljs-built_in">mktemp -d)
  CONFIG_FILE=<span class="hljs-string">"$TEST_DIR/config.env"

  <span class="hljs-comment"># Write a test configuration
  <span class="hljs-built_in">cat > <span class="hljs-string">"$CONFIG_FILE" <<<span class="hljs-string">EOF
DB_HOST=localhost
DB_PORT=5432
DB_NAME=testdb
EOF

  <span class="hljs-built_in">export TEST_DIR CONFIG_FILE
}

<span class="hljs-function">tearDown() {
  <span class="hljs-built_in">rm -rf <span class="hljs-string">"$TEST_DIR"
}

<span class="hljs-function">testConfigParsing() {
  . <span class="hljs-string">"$CONFIG_FILE"
  assertEquals <span class="hljs-string">"DB_HOST should be localhost" <span class="hljs-string">"localhost" <span class="hljs-string">"$DB_HOST"
  assertEquals <span class="hljs-string">"DB_PORT should be 5432" <span class="hljs-string">"5432" <span class="hljs-string">"$DB_PORT"
}

<span class="hljs-function">testMissingConfigFails() {
  <span class="hljs-comment"># Remove the config to test failure handling
  <span class="hljs-built_in">rm <span class="hljs-string">"$CONFIG_FILE"
  result=$(./scripts/start.sh 2>&1)
  assertNotEquals <span class="hljs-string">"Script should fail without config" <span class="hljs-string">"0" <span class="hljs-string">"$?"
}

. ./test/libs/shunit2

There is also oneTimeSetUp (runs once before all tests in the file) and oneTimeTearDown (runs once after all tests). Use these for expensive setup like building a binary or starting a background service.

oneTimeSetUp() {
  <span class="hljs-comment"># Build the binary once
  make build > /dev/null 2>&1
  BINARY=<span class="hljs-string">"./bin/myapp"
  <span class="hljs-built_in">export BINARY
}

<span class="hljs-function">oneTimeTearDown() {
  <span class="hljs-built_in">rm -f ./bin/myapp
}

The Full Assertion Library

Equality assertions:

assertEquals "message" <span class="hljs-string">"expected" <span class="hljs-string">"actual"
assertNotEquals <span class="hljs-string">"message" <span class="hljs-string">"unexpected" <span class="hljs-string">"actual"

Null checks:

assertNull "message" <span class="hljs-string">"value"       <span class="hljs-comment"># asserts value is empty string
assertNotNull <span class="hljs-string">"message" <span class="hljs-string">"value"    <span class="hljs-comment"># asserts value is non-empty

Boolean assertions:

assertTrue "message" <span class="hljs-string">"condition"   <span class="hljs-comment"># asserts condition is truthy (exit 0)
assertFalse <span class="hljs-string">"message" <span class="hljs-string">"condition"  <span class="hljs-comment"># asserts condition is falsy (exit non-0)

Container assertions:

assertContains "message" <span class="hljs-string">"substring" <span class="hljs-string">"string"    <span class="hljs-comment"># string contains substring
assertNotContains <span class="hljs-string">"message" <span class="hljs-string">"substring" <span class="hljs-string">"string"

The condition in assertTrue/assertFalse can be a command:

assertTrue "file should exist" <span class="hljs-string">"[ -f $OUTPUT_FILE ]"
assertFalse <span class="hljs-string">"directory should not exist" <span class="hljs-string">"[ -d $MISSING_DIR ]"
assertTrue <span class="hljs-string">"command should succeed" <span class="hljs-string">"grep -q 'pattern' $LOG_FILE"

Or a comparison expression:

assertTrue "count should be positive" <span class="hljs-string">"[ $count -gt 0 ]"
assertTrue <span class="hljs-string">"version should match" <span class="hljs-string">'[ "$(./app --version)" = "1.2.3" ]'

Skipping tests:

testDockerIntegration() {
  <span class="hljs-keyword">if ! <span class="hljs-built_in">command -v docker > /dev/null 2>&1; <span class="hljs-keyword">then
    startSkipping
    <span class="hljs-built_in">return
  <span class="hljs-keyword">fi
  <span class="hljs-comment"># ... docker tests ...
}

Testing a Real Deployment Script

Here is a realistic deployment script and a complete shUnit2 test suite for it:

# scripts/deploy.sh
<span class="hljs-comment">#!/bin/sh
<span class="hljs-built_in">set -e

ENVIRONMENT=<span class="hljs-string">"${1:-}"
VERSION=<span class="hljs-string">"${2:-}"
CONFIG_DIR=<span class="hljs-string">"${CONFIG_DIR:-/etc/myapp}"

<span class="hljs-function">usage() {
  <span class="hljs-built_in">echo <span class="hljs-string">"Usage: $0 <environment> <version>" >&2
  <span class="hljs-built_in">echo <span class="hljs-string">"  Environments: staging, production" >&2
  <span class="hljs-built_in">exit 1
}

<span class="hljs-function">validate_version() {
  <span class="hljs-built_in">echo <span class="hljs-string">"$1" <span class="hljs-pipe">| grep -qE <span class="hljs-string">'^[0-9]+\.[0-9]+\.[0-9]+$'
}

<span class="hljs-function">deploy() {
  [ -z <span class="hljs-string">"$ENVIRONMENT" ] && usage
  [ -z <span class="hljs-string">"$VERSION" ] && usage

  <span class="hljs-keyword">case <span class="hljs-string">"$ENVIRONMENT" <span class="hljs-keyword">in
    staging|production) <span class="hljs-pipe">;;
    *) <span class="hljs-built_in">echo <span class="hljs-string">"Error: Unknown environment: $ENVIRONMENT" >&2; <span class="hljs-built_in">exit 2 <span class="hljs-pipe">;;
  <span class="hljs-keyword">esac

  validate_version <span class="hljs-string">"$VERSION" <span class="hljs-pipe">|| {
    <span class="hljs-built_in">echo <span class="hljs-string">"Error: Invalid version format: $VERSION" >&2
    <span class="hljs-built_in">exit 3
  }

  [ -f <span class="hljs-string">"$CONFIG_DIR/deploy.conf" ] <span class="hljs-pipe">|| {
    <span class="hljs-built_in">echo <span class="hljs-string">"Error: Config not found: $CONFIG_DIR/deploy.conf" >&2
    <span class="hljs-built_in">exit 4
  }

  <span class="hljs-built_in">echo <span class="hljs-string">"Deploying version $VERSION to <span class="hljs-variable">$ENVIRONMENT..."
  <span class="hljs-comment"># ... actual deploy logic ...
  <span class="hljs-built_in">echo <span class="hljs-string">"Deploy complete."
}

deploy

The test suite:

#!/bin/sh
<span class="hljs-comment"># test/test_deploy.sh

SCRIPT=<span class="hljs-string">"./scripts/deploy.sh"

<span class="hljs-function">setUp() {
  TEST_DIR=$(<span class="hljs-built_in">mktemp -d)
  CONFIG_DIR=<span class="hljs-string">"$TEST_DIR/config"
  <span class="hljs-built_in">mkdir -p <span class="hljs-string">"$CONFIG_DIR"
  <span class="hljs-built_in">touch <span class="hljs-string">"$CONFIG_DIR/deploy.conf"
  <span class="hljs-built_in">export CONFIG_DIR TEST_DIR
}

<span class="hljs-function">tearDown() {
  <span class="hljs-built_in">rm -rf <span class="hljs-string">"$TEST_DIR"
}

<span class="hljs-function">testFailsWithNoArguments() {
  output=$(<span class="hljs-string">"$SCRIPT" 2>&1)
  assertFalse <span class="hljs-string">"Should fail with no arguments" <span class="hljs-string">"[ $? -eq 0 ]"
  assertContains <span class="hljs-string">"Should print usage" <span class="hljs-string">"$output" <span class="hljs-string">"Usage:"
}

<span class="hljs-function">testFailsWithMissingVersion() {
  output=$(<span class="hljs-string">"$SCRIPT" staging 2>&1)
  assertFalse <span class="hljs-string">"Should fail with missing version" <span class="hljs-string">"[ $? -eq 0 ]"
}

<span class="hljs-function">testFailsWithInvalidEnvironment() {
  output=$(<span class="hljs-string">"$SCRIPT" production_typo 1.2.3 2>&1)
  exitcode=$?
  assertEquals <span class="hljs-string">"Should exit with code 2" <span class="hljs-string">"2" <span class="hljs-string">"$exitcode"
  assertContains <span class="hljs-string">"Should mention unknown environment" <span class="hljs-string">"$output" <span class="hljs-string">"Unknown environment"
}

<span class="hljs-function">testFailsWithInvalidVersionFormat() {
  output=$(<span class="hljs-string">"$SCRIPT" staging 1.2 2>&1)
  exitcode=$?
  assertEquals <span class="hljs-string">"Should exit with code 3" <span class="hljs-string">"3" <span class="hljs-string">"$exitcode"
  assertContains <span class="hljs-string">"Should mention invalid version" <span class="hljs-string">"$output" <span class="hljs-string">"Invalid version format"
}

<span class="hljs-function">testFailsWhenConfigMissing() {
  <span class="hljs-built_in">rm <span class="hljs-string">"$CONFIG_DIR/deploy.conf"
  output=$(<span class="hljs-string">"$SCRIPT" staging 1.2.3 2>&1)
  exitcode=$?
  assertEquals <span class="hljs-string">"Should exit with code 4" <span class="hljs-string">"4" <span class="hljs-string">"$exitcode"
  assertContains <span class="hljs-string">"Should mention missing config" <span class="hljs-string">"$output" <span class="hljs-string">"Config not found"
}

<span class="hljs-function">testSucceedsWithValidArguments() {
  output=$(<span class="hljs-string">"$SCRIPT" staging 1.2.3 2>&1)
  exitcode=$?
  assertEquals <span class="hljs-string">"Should exit with code 0" <span class="hljs-string">"0" <span class="hljs-string">"$exitcode"
  assertContains <span class="hljs-string">"Should print deploy complete" <span class="hljs-string">"$output" <span class="hljs-string">"Deploy complete"
}

<span class="hljs-function">testSucceedsWithProductionEnvironment() {
  output=$(<span class="hljs-string">"$SCRIPT" production 2.0.1 2>&1)
  exitcode=$?
  assertEquals <span class="hljs-string">"Should succeed for production too" <span class="hljs-string">"0" <span class="hljs-string">"$exitcode"
  assertContains <span class="hljs-string">"Should mention version" <span class="hljs-string">"$output" <span class="hljs-string">"2.0.1"
  assertContains <span class="hljs-string">"Should mention environment" <span class="hljs-string">"$output" <span class="hljs-string">"production"
}

. ./test/libs/shunit2

Running Multiple Test Files as a Suite

shUnit2 has no built-in test discovery across files. The idiomatic approach is a runner script:

#!/bin/sh
<span class="hljs-comment"># test/run_all.sh

FAILED=0
TOTAL=0

<span class="hljs-keyword">for test_file <span class="hljs-keyword">in <span class="hljs-built_in">test/test_*.sh; <span class="hljs-keyword">do
  <span class="hljs-built_in">echo <span class="hljs-string">"=== Running: $test_file ==="
  sh <span class="hljs-string">"$test_file"
  <span class="hljs-keyword">if [ $? -ne 0 ]; <span class="hljs-keyword">then
    FAILED=$((FAILED + <span class="hljs-number">1))
  <span class="hljs-keyword">fi
  TOTAL=$((TOTAL + <span class="hljs-number">1))
<span class="hljs-keyword">done

<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"=== Results: $((TOTAL - FAILED)) passed, <span class="hljs-variable">$FAILED failed of <span class="hljs-variable">$TOTAL test files ==="

[ <span class="hljs-string">"$FAILED" -eq 0 ]

Run with sh test/run_all.sh. The exit code is non-zero if any file had failures, which is what CI systems need.

CI Integration

# .github/workflows/test-shell.yml
name: Shell Script Tests

on: [push, pull_request]

jobs:
  test-bash:
    name: Test under bash
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Run tests with bash
        run: bash test/run_all.sh

  test-dash:
    name: Test under dash (POSIX sh)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Install dash
        run: sudo apt-get install -y dash

      - name: Run tests with dash
        run: dash test/run_all.sh

  test-zsh:
    name: Test under zsh
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Install zsh
        run: sudo apt-get install -y zsh

      - name: Run tests with zsh
        run: zsh test/run_all.sh

Running under multiple shells in CI is the core value proposition. A test that passes under bash but fails under dash tells you exactly where your portability assumptions are wrong.

shUnit2 vs BATS: Choosing the Right Tool

Concern shUnit2 BATS
Shell portability Any POSIX shell Bash only
Installation Single file Requires git/npm/brew
Assertion richness Adequate Rich (with bats-assert)
Output readability Basic Excellent
Parallel tests Manual Built-in (--jobs)
Test filtering None --filter flag
CI support TAP-compatible TAP-native
Active community Maintenance mode Active development

Use shUnit2 when:

  • Your scripts must run on multiple shells (ksh, dash, zsh, POSIX sh)
  • You are working in constrained environments (no package managers, no git)
  • The scripts themselves are POSIX-portable and you want to enforce that in tests

Use BATS when:

  • You are testing bash-specific scripts
  • You want the richer assertion library and better failure output
  • You want parallel test execution built in

Many teams use both: BATS for new bash-specific work, shUnit2 for legacy scripts that must remain POSIX-portable.

Common Pitfalls

Forgetting export — variables set in setUp are not visible in test functions unless exported. Use export VAR=value consistently.

Subshell isolation — the output=$( ... ) pattern runs the command in a subshell, so environment changes inside (like cd) do not affect the test. This is usually what you want, but can surprise when testing scripts that modify the environment.

Quoting assertion arguments — always quote all three arguments to assertEquals:

# Wrong — will break if result contains spaces
assertEquals expected <span class="hljs-variable">$result

<span class="hljs-comment"># Right
assertEquals <span class="hljs-string">"message" <span class="hljs-string">"expected" <span class="hljs-string">"$result"

Testing scripts with source vs subshell — sourcing a script (. ./scripts/deploy.sh) runs it in the current shell, which means exit calls will terminate your test file. Use a subshell ("$SCRIPT" args) for scripts that call exit.

shUnit2 is not the most ergonomic testing tool, but it is the most portable. For infrastructure teams who run scripts on anything other than bash, it is the most honest test of whether the scripts will actually work in production.

HelpMeTest complements shell testing with production monitoring and AI-powered test generation — start free at helpmetest.com

Read more