Code Coverage
Code coverage measures how much of your source code is executed when running tests. It helps identify untested code paths and ensures your test suite exercises the important parts of your application.
Quick Start
Enable coverage tracking with the --coverage flag:
bashunit tests/ --coveragebashunit tests/ --coverage-paths src/bashunit - 0.30.0 | Tests: 5
.....
Tests: 5 passed, 5 total
Assertions: 12 passed, 12 total
All tests passed
Time taken: 1 s
Coverage Report
---------------
src/math.sh 2/ 3 lines ( 66%)
src/utils.sh 8/ 10 lines ( 80%)
---------------
Total: 10/13 (76%)
Coverage report written to: coverage/lcov.infoHow It Works
bashunit uses Bash's built-in DEBUG trap mechanism to track line execution:
- Trap Setup: When coverage is enabled, a DEBUG trap is set that fires before every command execution
- Line Recording: Each executed line's file path and line number are recorded
- Filtering: Only files matching your coverage paths (and not excluded) are tracked
- Aggregation: After tests complete, hit data is aggregated and reported
Performance
The DEBUG trap adds overhead to test execution. For large test suites, consider running coverage periodically rather than on every test run.
Configuration
Command Line Options
| Option | Description |
|---|---|
--coverage | Enable code coverage tracking |
--coverage-paths <paths> | Comma-separated paths to track (default: auto-discover from test files) |
--coverage-exclude <patterns> | Comma-separated exclusion patterns |
--coverage-report <file> | LCOV report output path (default: coverage/lcov.info) |
--coverage-report-html [dir] | Generate HTML report (default: coverage/html) |
--coverage-min <percent> | Minimum coverage threshold (fails if below) |
--no-coverage-report | Disable LCOV file generation (console only) |
Auto-enable
Coverage is automatically enabled when using --coverage-report, --coverage-report-html, or --coverage-min. You don't need to specify --coverage explicitly with these options.
Auto-Discovery
When BASHUNIT_COVERAGE_PATHS is not set, bashunit automatically discovers source files based on your test file names:
| Test File | Discovers |
|---|---|
tests/unit/assert_test.sh | src/assert.sh, src/assert_*.sh |
tests/unit/helperTest.sh | src/helper.sh, src/helper*.sh |
This convention follows the common pattern of naming test files after their source files with a _test.sh or Test.sh suffix.
Zero Configuration
For most projects following standard naming conventions, you can simply run bashunit tests/ --coverage without any path configuration.
Environment Variables
You can also configure coverage via environment variables in your .env file:
# Enable coverage
BASHUNIT_COVERAGE=true
# Paths to track (comma-separated)
BASHUNIT_COVERAGE_PATHS=src/,lib/
# Patterns to exclude (comma-separated)
BASHUNIT_COVERAGE_EXCLUDE=tests/*,vendor/*,*_test.sh
# LCOV report output path
BASHUNIT_COVERAGE_REPORT=coverage/lcov.info
# HTML report output directory (generates line-by-line coverage view)
BASHUNIT_COVERAGE_REPORT_HTML=coverage/html
# Minimum coverage percentage (optional)
BASHUNIT_COVERAGE_MIN=80
# Color thresholds for console output
BASHUNIT_COVERAGE_THRESHOLD_LOW=50 # Red below this
BASHUNIT_COVERAGE_THRESHOLD_HIGH=80 # Green above this, yellow between
# Optional text-report blocks (off by default, opt-in for verbose runs)
BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true # Print per-function coverage
BASHUNIT_COVERAGE_SHOW_UNCOVERED=true # Print missed line ranges per fileExamples
Basic Coverage
Track coverage for the default src/ directory:
bashunit tests/ --coverageCustom Source Paths
Track multiple directories:
bashunit tests/ --coverage --coverage-paths "src/,lib/,bin/"BASHUNIT_COVERAGE_PATHS=src/,lib/,bin/Exclusion Patterns
Exclude specific files or directories:
bashunit tests/ --coverage --coverage-exclude "vendor/*,*_mock.sh,deprecated/"BASHUNIT_COVERAGE_EXCLUDE=vendor/*,*_mock.sh,deprecated/Setting Minimum Threshold
Fail the test run if coverage drops below a threshold:
bashunit tests/ --coverage-min 80Coverage Report
---------------
src/math.sh 10/ 12 lines ( 83%)
---------------
Total: 10/12 (83%)Coverage Report
---------------
src/math.sh 5/ 12 lines ( 41%)
---------------
Total: 5/12 (41%)
Coverage 41% is below minimum 80%Console-Only Output
Skip generating the LCOV file:
bashunit tests/ --coverage --no-coverage-reportHTML Coverage Report
Generate a detailed HTML report showing line-by-line coverage:
bashunit tests/ --coverage-report-html coverage/htmlBASHUNIT_COVERAGE_REPORT_HTML=coverage/htmlThis creates a directory with:
index.html- Summary page with per-file coverage percentagesfiles/*.html- Individual source file views with line highlighting
Line highlighting:
- Green background: Lines executed during tests (covered)
- Red background: Executable lines not executed (uncovered)
- No background: Non-executable lines (comments, function declarations, etc.)
Each line also shows the number of times it was executed, helping identify hot paths and dead code.
CI/CD Integration
Generate coverage for CI tools like Codecov or Coveralls:
- name: Run tests with coverage
run: bashunit tests/ --coverage-min 80
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
fail_ci_if_error: truetest:
script:
- bashunit tests/ --coverage
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/lcov.infoUnderstanding the Console Report
The console report shows coverage per file with color coding:
Coverage Report
---------------
src/math.sh 10/ 12 lines ( 83%) # Green (>= 80%)
src/parser.sh 7/ 10 lines ( 70%) # Yellow (50-79%)
src/legacy.sh 2/ 15 lines ( 13%) # Red (< 50%)
---------------
Total: 19/37 (51%)Color thresholds (configurable via environment variables):
- Green: Coverage >= 80% (
BASHUNIT_COVERAGE_THRESHOLD_HIGH) - Yellow: Coverage 50-79%
- Red: Coverage < 50% (
BASHUNIT_COVERAGE_THRESHOLD_LOW)
Understanding LCOV Format
The coverage/lcov.info file uses the industry-standard LCOV format, compatible with most CI coverage tools.
File Structure
TN:
SF:/path/to/source/file.sh
DA:2,5
DA:3,0
LF:2
LH:1
end_of_recordField Reference
| Field | Description | Example |
|---|---|---|
TN: | Test Name (usually empty) | TN: |
SF: | Source File path | SF:/home/user/project/src/math.sh |
FN: | Function: start_line,name | FN:5,multiply |
FNDA: | Function call data: count,name (1 if any line in body was hit, else 0) | FNDA:1,add |
FNF: | Functions Found | FNF:2 |
FNH: | Functions Hit | FNH:1 |
BRDA: | Branch data: decision_line,block,arm,taken | BRDA:12,0,1,1 |
BRF: | Branches Found | BRF:6 |
BRH: | Branches Hit | BRH:4 |
DA: | Line Data: line_number,hit_count | DA:15,3 (line 15 hit 3 times) |
LF: | Lines Found (total executable lines) | LF:25 |
LH: | Lines Hit (lines with hits > 0) | LH:20 |
end_of_record | Marks end of file entry | end_of_record |
Example Breakdown
Given this source file src/math.sh:
#!/usr/bin/env bash # Line 1 - not executable (comment/shebang)
function add() { # Line 2 - not executable (function declaration)
echo $(($1 + $2)) # Line 3 - executable
} # Line 4 - not executable (closing brace)
function multiply() { # Line 5 - not executable (function declaration)
echo $(($1 * $2)) # Line 6 - executable
} # Line 7 - not executable (closing brace)If tests call add twice but never call multiply, the LCOV output would be:
TN:
SF:/path/to/src/math.sh
DA:3,2
DA:6,0
LF:2
LH:1
end_of_recordInterpretation:
- Line 3 (
addbody): 2 hits - Line 6 (
multiplybody): 0 hits - 2 executable lines found, 1 line was hit (50% coverage)
Parallel Execution
Coverage works seamlessly with parallel test execution (-p flag):
bashunit tests/ --coverage -pHow it works:
- Each parallel worker writes to its own coverage file
- After all tests complete, coverage data is aggregated
- The final report combines hits from all workers
TIP
Coverage percentages should be identical whether running in parallel or sequential mode.
What Gets Tracked
Executable Lines
bashunit counts these as executable lines:
- Commands and statements
- Single-line function bodies (
function foo() { echo "hi"; })
Non-Executable Lines (Skipped)
These lines are not counted toward coverage:
- Empty lines
- Comment lines (including shebang
#!/usr/bin/env bash) - Function declaration lines (
function foo() {) - Lines with only braces (
{or}) - Control flow keywords (
then,else,fi,do,done,esac,in) - Case statement patterns (
--option),*)) and terminators (;;,;&,;;&)
Branch Coverage
Beyond line and function coverage, bashunit emits branch coverage records in the LCOV report so reviewers can see whether each else/elif arm and each case pattern was exercised. Branch records are produced automatically; no extra flags are needed.
What Counts as a Branch
| Construct | Arms |
|---|---|
if X; then ... fi | 1 (the then body) |
if X; then ... else ... fi | 2 (then + else) |
if X; then ... elif Y; then ... else ... fi | 3 (one per arm) |
case X in a) ... ;; b) ... ;; *) ... ;; esac | one per pattern |
An arm is reported as taken iff at least one executable line inside its range was hit by tests.
Verbose Output Helpers
Two opt-in environment variables enrich the text report when investigating coverage gaps:
BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true bashunit tests/ --coverageBASHUNIT_COVERAGE_SHOW_UNCOVERED=true bashunit tests/ --coverageBASHUNIT_COVERAGE_SHOW_FUNCTIONS=true \
BASHUNIT_COVERAGE_SHOW_UNCOVERED=true \
bashunit tests/ --coverageThe default text report stays compact; opt in only when triaging.
Worked Example
Given src/route.sh:
#!/usr/bin/env bash
function route() {
if [ "$1" = "GET" ]; then
echo "fetch"
elif [ "$1" = "POST" ]; then
echo "create"
else
echo "405"
fi
}If tests only call route GET, the LCOV record looks like:
TN:
SF:/path/to/src/route.sh
FN:2,route
FNDA:1,route
FNF:1
FNH:1
BRDA:3,0,0,1
BRDA:3,0,1,0
BRDA:3,0,2,0
BRF:3
BRH:1
DA:3,1
DA:4,1
DA:5,0
DA:6,0
DA:7,0
DA:8,0
LF:6
LH:2
end_of_recordReading the branch records:
BRDA:3,0,0,1: decision on line 3, block 0, arm 0 (then/GET), taken.BRDA:3,0,1,0: same decision, arm 1 (elif/POST), not taken.BRDA:3,0,2,0: same decision, arm 2 (else/405), not taken.BRF:3BRH:1: 3 branches found, 1 taken.
Visualizing with genhtml
LCOV's genhtml renders branch coverage alongside line and function coverage:
bashunit tests/ --coverage
genhtml --branch-coverage coverage/lcov.info -o coverage/htmlThe resulting site shows a red/green diamond next to each branch decision, mirroring gcov's C/C++ output.
CI Integration
Codecov and Coveralls pick up the new records without configuration. To require branch coverage in PR gates:
coverage:
status:
project:
default:
target: 80%
patch:
default:
target: 80%
threshold: 0%
flags:
- branchLimitations
- An arm whose body has no executable lines (only comments or braces) registers as not-taken even when the conditional fired.
- Implicit
else(anif/elifchain without an explicitelse) reports only the explicit arms; the synthetic fall-through outcome is omitted. - Compound conditionals (
if A && B) are reported as a single binary decision, not per sub-expression. &&/||short-circuit branches outsideifand loop-entry decisions (while/until) are not tracked.
See adrs/adr-007-branch-coverage-mvp.md for the design rationale and the rejected alternatives.
Limitations
External Commands
Coverage only tracks Bash code. External commands (like grep, sed, etc.) are not tracked, though the lines that call them are.
Subshell Behavior
Due to Bash's process model, hits produced inside a subshell are written to the subshell's in-memory buffer, which is discarded when the subshell exits. The pinned behavior is:
$( ... )command substitution: the outer line is recorded; commands inside the substitution are not.( ... )explicit subshells: the same applies; only the outer line is tracked.- Pipelines (
a | b): each stage is recorded as a single hit on its source line. - Process substitution
< <( ... ): the consumer side is fully tracked; producer lines are not. - Functions invoked from
$( ... ): the call site and surrounding lines are hit, but the function body lines are lost when called inside a subshell.
These contracts are pinned by tests/unit/coverage_subshell_test.sh.