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 test/libs
curl -Lo test/libs/shunit2 \
https://raw.githubusercontent.com/kward/shunit2/master/shunit2
chmod +x test/libs/shunit2Option 2: Package manager (some distros)
# Ubuntu/Debian
apt-get install shunit2
# macOS via Homebrew
brew install shunit2Option 3: Git submodule (recommended for teams)
git submodule add \
https://github.com/kward/shunit2.git \
test/libs/shunit2-repoThen 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
# test/test_math.sh
# Source our script under test
. ./scripts/math.sh
testAddition() {
result=$(add 2 3)
assertEquals "add 2 3 should return 5" "5" "$result"
}
testSubtraction() {
result=$(subtract 10 4)
assertEquals "subtract 10 4 should return 6" "6" "$result"
}
testDivisionByZero() {
result=$(divide 10 0 2>&1)
assertEquals "division by zero should return error code 1" "1" "$?"
}
# Source shUnit2 last — it runs all test* functions automatically
. ./test/libs/shunit2Run it:
sh test/test_math.shOutput:
testAddition
testSubtraction
testDivisionByZero
Ran 3 tests.
OKNote 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
setUp() {
# Create a temporary working directory
TEST_DIR=$(mktemp -d)
CONFIG_FILE="$TEST_DIR/config.env"
# Write a test configuration
cat > "$CONFIG_FILE" <<EOF
DB_HOST=localhost
DB_PORT=5432
DB_NAME=testdb
EOF
export TEST_DIR CONFIG_FILE
}
tearDown() {
rm -rf "$TEST_DIR"
}
testConfigParsing() {
. "$CONFIG_FILE"
assertEquals "DB_HOST should be localhost" "localhost" "$DB_HOST"
assertEquals "DB_PORT should be 5432" "5432" "$DB_PORT"
}
testMissingConfigFails() {
# Remove the config to test failure handling
rm "$CONFIG_FILE"
result=$(./scripts/start.sh 2>&1)
assertNotEquals "Script should fail without config" "0" "$?"
}
. ./test/libs/shunit2There 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() {
# Build the binary once
make build > /dev/null 2>&1
BINARY="./bin/myapp"
export BINARY
}
oneTimeTearDown() {
rm -f ./bin/myapp
}The Full Assertion Library
Equality assertions:
assertEquals "message" "expected" "actual"
assertNotEquals "message" "unexpected" "actual"Null checks:
assertNull "message" "value" # asserts value is empty string
assertNotNull "message" "value" # asserts value is non-emptyBoolean assertions:
assertTrue "message" "condition" # asserts condition is truthy (exit 0)
assertFalse "message" "condition" # asserts condition is falsy (exit non-0)Container assertions:
assertContains "message" "substring" "string" # string contains substring
assertNotContains "message" "substring" "string"The condition in assertTrue/assertFalse can be a command:
assertTrue "file should exist" "[ -f $OUTPUT_FILE ]"
assertFalse "directory should not exist" "[ -d $MISSING_DIR ]"
assertTrue "command should succeed" "grep -q 'pattern' $LOG_FILE"Or a comparison expression:
assertTrue "count should be positive" "[ $count -gt 0 ]"
assertTrue "version should match" '[ "$(./app --version)" = "1.2.3" ]'Skipping tests:
testDockerIntegration() {
if ! command -v docker > /dev/null 2>&1; then
startSkipping
return
fi
# ... docker tests ...
}Testing a Real Deployment Script
Here is a realistic deployment script and a complete shUnit2 test suite for it:
# scripts/deploy.sh
#!/bin/sh
set -e
ENVIRONMENT="${1:-}"
VERSION="${2:-}"
CONFIG_DIR="${CONFIG_DIR:-/etc/myapp}"
usage() {
echo "Usage: $0 <environment> <version>" >&2
echo " Environments: staging, production" >&2
exit 1
}
validate_version() {
echo "$1" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'
}
deploy() {
[ -z "$ENVIRONMENT" ] && usage
[ -z "$VERSION" ] && usage
case "$ENVIRONMENT" in
staging|production) ;;
*) echo "Error: Unknown environment: $ENVIRONMENT" >&2; exit 2 ;;
esac
validate_version "$VERSION" || {
echo "Error: Invalid version format: $VERSION" >&2
exit 3
}
[ -f "$CONFIG_DIR/deploy.conf" ] || {
echo "Error: Config not found: $CONFIG_DIR/deploy.conf" >&2
exit 4
}
echo "Deploying version $VERSION to $ENVIRONMENT..."
# ... actual deploy logic ...
echo "Deploy complete."
}
deployThe test suite:
#!/bin/sh
# test/test_deploy.sh
SCRIPT="./scripts/deploy.sh"
setUp() {
TEST_DIR=$(mktemp -d)
CONFIG_DIR="$TEST_DIR/config"
mkdir -p "$CONFIG_DIR"
touch "$CONFIG_DIR/deploy.conf"
export CONFIG_DIR TEST_DIR
}
tearDown() {
rm -rf "$TEST_DIR"
}
testFailsWithNoArguments() {
output=$("$SCRIPT" 2>&1)
assertFalse "Should fail with no arguments" "[ $? -eq 0 ]"
assertContains "Should print usage" "$output" "Usage:"
}
testFailsWithMissingVersion() {
output=$("$SCRIPT" staging 2>&1)
assertFalse "Should fail with missing version" "[ $? -eq 0 ]"
}
testFailsWithInvalidEnvironment() {
output=$("$SCRIPT" production_typo 1.2.3 2>&1)
exitcode=$?
assertEquals "Should exit with code 2" "2" "$exitcode"
assertContains "Should mention unknown environment" "$output" "Unknown environment"
}
testFailsWithInvalidVersionFormat() {
output=$("$SCRIPT" staging 1.2 2>&1)
exitcode=$?
assertEquals "Should exit with code 3" "3" "$exitcode"
assertContains "Should mention invalid version" "$output" "Invalid version format"
}
testFailsWhenConfigMissing() {
rm "$CONFIG_DIR/deploy.conf"
output=$("$SCRIPT" staging 1.2.3 2>&1)
exitcode=$?
assertEquals "Should exit with code 4" "4" "$exitcode"
assertContains "Should mention missing config" "$output" "Config not found"
}
testSucceedsWithValidArguments() {
output=$("$SCRIPT" staging 1.2.3 2>&1)
exitcode=$?
assertEquals "Should exit with code 0" "0" "$exitcode"
assertContains "Should print deploy complete" "$output" "Deploy complete"
}
testSucceedsWithProductionEnvironment() {
output=$("$SCRIPT" production 2.0.1 2>&1)
exitcode=$?
assertEquals "Should succeed for production too" "0" "$exitcode"
assertContains "Should mention version" "$output" "2.0.1"
assertContains "Should mention environment" "$output" "production"
}
. ./test/libs/shunit2Running Multiple Test Files as a Suite
shUnit2 has no built-in test discovery across files. The idiomatic approach is a runner script:
#!/bin/sh
# test/run_all.sh
FAILED=0
TOTAL=0
for test_file in test/test_*.sh; do
echo "=== Running: $test_file ==="
sh "$test_file"
if [ $? -ne 0 ]; then
FAILED=$((FAILED + 1))
fi
TOTAL=$((TOTAL + 1))
done
echo ""
echo "=== Results: $((TOTAL - FAILED)) passed, $FAILED failed of $TOTAL test files ==="
[ "$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.shRunning 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 $result
# Right
assertEquals "message" "expected" "$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