Testing a Bash script in production: 38 tests with no framework

The context: a Bash script to display Claude Code rate limits in a terminal status bar. At first it seems trivial — a few functions, reading a JSON file, some formatting. But the script quickly ends up handling: JSON parsing with jq, time formatting with timezone, percentage calculations, Unicode progress bar generation, countdown logic. And above all: several edge cases (missing data, corrupted JSON, quotas at 0, unknown timezone).

Without tests, every change becomes a lottery. You change the countdown formatting, you don't know if you broke the percentage calculation. You add active model handling, you don't know if the corrupted JSON fixtures still pass. With 38 unit tests on a Bash script, you modify with confidence — and bash tests/test_statusline.sh in 2 seconds confirms nothing is broken.

Why no Bash testing framework

Bash testing frameworks exist: bats-core, shunit2, shellspec. They're good. For this project, the choice was not to add one — zero external dependencies in the repo, one-liner install. A test framework is a dependency. The constraint was simple: tests run with native bash, nothing to install.

Result: ~100 lines of homemade test helpers. No magic, no DSL, no plugin to update — just functions and conventions. For a 300-line script, that's the right level of engineering. A testing framework to test a Bash script is often more complexity than the script itself.

The basic structure: assertions and counters

The test file starts with two global counters and two assertion functions:

#!/usr/bin/env bash
# tests/test_statusline.sh

PASS=0
FAIL=0

assert_eq() {
    local description="$1"
    local expected="$2"
    local actual="$3"

    if [[ "$expected" == "$actual" ]]; then
        echo "  ✓ $description"
        ((PASS++))
    else
        echo "  ✗ $description"
        echo "    expected: $(echo "$expected" | cat -A)"
        echo "    actual:   $(echo "$actual" | cat -A)"
        ((FAIL++))
    fi
}

assert_contains() {
    local description="$1"
    local needle="$2"
    local haystack="$3"

    if [[ "$haystack" == *"$needle"* ]]; then
        echo "  ✓ $description"
        ((PASS++))
    else
        echo "  ✗ $description"
        echo "    expected to contain: $needle"
        echo "    actual: $haystack"
        ((FAIL++))
    fi
}

# At the end of the test file:
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1

The cat -A in the error message displays invisible characters (spaces, tabs, \n, $ at end of line) — essential for debugging tests that fail on an extra space in a formatted string. Without it, you spend 20 minutes comparing two strings that look identical visually but aren't equal.

Testing Bash functions: the isolation problem

The main script (statusline.sh) isn't designed to be sourced in tests — it executes top-level code as soon as it's launched. The pattern to isolate functions without modifying production behavior:

# In statusline.sh, wrap the executable code:
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    # Executed only when the script is run directly
    main "$@"
fi

# Functions defined in the file remain available when sourced

In the test file, source the functions without triggering main:

# Source functions without executing main
source "$(dirname "$0")/../statusline.sh"

# Now we can test functions directly
result=$(format_percentage 67)
assert_eq "67% gives yellow" "🟡" "$result"

BASH_SOURCE[0] holds the path of the currently executing script. $0 holds the path of the script launched by the user. When you source a file, these two values differ — the if doesn't execute.

Mocking external dependencies

The script calls jq, reads JSON files, uses date with timezone. For unit tests on Bash scripts, you mock by redefining commands in the test scope — Bash resolves functions before binaries in PATH:

# Mock jq to return controlled data
jq() {
    echo "42"
}
export -f jq

result=$(get_context_percentage)
assert_eq "context at 42%" "42" "$result"

# Clean up the mock after the test
unset -f jq

For more complex JSON data, create fixtures in tests/fixtures/ and override the path variable via the environment:

USAGE_FILE="tests/fixtures/usage_normal.json" \
    result=$(render_statusline)
assert_contains "displays the model" "Sonnet 4.6" "$result"

USAGE_FILE="tests/fixtures/usage_corrupted.json" \
    result=$(render_statusline)
assert_contains "corrupted JSON → fallback" "N/A" "$result"

The advantage: fixtures are real readable JSON files, versionable, easy to update when the format changes. No need to mock jq for each case — you provide the input data directly.

A concrete example: testing the Unicode progress bar

The progress bar (▓▓▓░░░░░) is one of the most tested functions — it must correctly handle edge cases of Bash integer division:

test_progress_bar() {
    echo "--- Progress bar ---"
    assert_eq "0%  → 8 empty blocks"     "░░░░░░░░" "$(make_bar 0)"
    assert_eq "50% → 4 full 4 empty"     "▓▓▓▓░░░░" "$(make_bar 50)"
    assert_eq "100%→ 8 full blocks"      "▓▓▓▓▓▓▓▓" "$(make_bar 100)"
    assert_eq "34% → rounded down"       "▓▓░░░░░░" "$(make_bar 34)"
    assert_eq "99% → 7 full"             "▓▓▓▓▓▓▓░" "$(make_bar 99)"
}

5 tests, 5 edge cases. The 34% case is the most important: 34 * 8 / 100 = 2.72 → rounded to 2 by Bash integer division (no bc, no awk). Without an explicit test, this behavior is discovered in production when the bar displays one too many blocks. The 99% case verifies that the last block stays empty — 7 full, not 8.

The result: 38 tests, 0 external framework

Final organization of tests by category:

  • Progress bar (5 tests) — boundary values, integer division rounding
  • Color coding (6 tests) — green/yellow/red thresholds by percentage
  • Percentage formatting (4 tests) — display with %, zero, 100
  • Time formatting (8 tests) — countdown, timezone, reset times, midnight
  • JSON parsing (7 tests) — missing data, null values, corrupted JSON
  • Full statusline render (8 tests) — complete output with real data fixtures

Run: bash tests/test_statusline.sh. Output:

--- Progress bar ---
  ✓ 0% → 8 empty blocks
  ✓ 50% → 4 full 4 empty
  ✓ 100% → 8 full blocks
  ✓ 34% → rounded down
  ✓ 99% → 7 full

--- Color coding ---
  ✓ 0% → green
  ✓ 74% → green
  ✓ 75% → yellow
  ✓ 89% → yellow
  ✓ 90% → red
  ✓ 100% → red

[...]

Results: 38 passed, 0 failed

What it changes in practice

The statusline evolved 12+ times in a single day of development: adding the active model, overhauling the countdown, adding git dirty status, handling exhausted quotas. With each change, bash tests/test_statusline.sh in 2 seconds. Regressions are caught immediately — not after restarting Claude Code to see that the bar displays N/A instead of the percentage.

Bash has no typing, no strict linter, no IDE that highlights logic errors. Tests compensate. This isn't a substitute for a real language — it's the minimal safety net that makes the script modifiable without anxiety.

Conclusion

100 lines of helpers. 38 Bash unit tests. Zero external dependencies. For a production Bash script that needs to be modifiable safely, that's the right investment. The pattern is portable to any Bash project: assert_eq, assert_contains, mock by function redefinition, fixtures as JSON files. No need for a testing framework to test a 300-line script.

The full project is on GitHub: github.com/ohugonnot/claude-code-statusline

Comments (0)