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