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 | 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 records which lines ran, then classifies and reports them:
- Capture: every executed line's file path and line number is recorded, either from a
DEBUGtrap or fromxtrace— see Tracing engine - Filtering: a recorded file is tracked only when it matches your coverage paths and no exclude pattern
- Aggregation: after tests complete, hit data is aggregated
- Seeding: every file under your coverage paths joins the report, whether or not a test reached it
- Reporting: each source line is classified as executable or not, and the executable ones are matched against the hits
A file no test touched is reported at 0%
The denominator is every non-excluded file under BASHUNIT_COVERAGE_PATHS, not only the files that happened to run — so untested code lowers the percentage instead of being invisible. Before #1053 this repo reported 11 files of its own 121 and a denominator of 2,200 against a real 9,285.
Seeding happens once, when the report is built, so a capture-only run pays nothing for it. A file created after that point is not seeded, though it is still tracked if it executes.
Performance
Coverage costs somewhere between 2x and 6x wall-clock time — how much depends on how much tracked code the suite actually executes, not on how many tests there are. Running this repo's own tests/unit/assert/basic_test.sh against all 128 files of src sits at the top of that range: 0.6s to 3.4s on Bash 3.2, and 0.3s to 2.0s on Bash 5.
Capture dominates: the report phase is a handful of awk passes for the whole run, while capture pays per executed line, through a DEBUG trap that fires for every command. If it is too slow to keep on while you work, narrow BASHUNIT_COVERAGE_PATHS — the cost follows the number of tracked source lines that run.
Seeing which engine ran
--verbose prints the engine that was actually used:
./bashunit --coverage --verbose tests/
# Coverage engine: xtraceAn explicit BASHUNIT_COVERAGE_ENGINE=xtrace that the running Bash cannot honour is reported rather than silently ignored:
Warning: coverage engine 'xtrace' needs Bash 4.1+ (running 3.2); using 'trap'.auto falling back to trap is normal and is not warned about.
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) |
--coverage-diff <ref> | Report only the lines changed since <ref>, see Diff coverage |
--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
Every coverage flag has a matching setting. The full list, with defaults, lives in Configuration > Coverage. The short version:
BASHUNIT_COVERAGE=true # enable tracking
BASHUNIT_COVERAGE_PATHS=src/,lib/ # paths to track
BASHUNIT_COVERAGE_REPORT=coverage/lcov.info
BASHUNIT_COVERAGE_REPORT_HTML=coverage/html
BASHUNIT_COVERAGE_MIN=80 # fail below this percentage
BASHUNIT_COVERAGE_ENGINE=auto # auto, xtrace or trapThree opt-in blocks add detail to the console report: BASHUNIT_COVERAGE_SHOW_FUNCTIONS (per-function table), BASHUNIT_COVERAGE_SHOW_UNCOVERED (missed line ranges), BASHUNIT_COVERAGE_SHOW_LINE_HITS (per-line execution counts).
Tracing engine
Coverage can capture executed lines two ways, selected with BASHUNIT_COVERAGE_ENGINE:
| Value | Behaviour |
|---|---|
auto (default) | Use xtrace where it is available, trap otherwise |
xtrace | Redirect set -x to a private descriptor and parse it after the run |
trap | Record each line from a DEBUG trap as it executes |
The xtrace engine needs BASH_XTRACEFD, which is Bash 4.1+; below that it would have to write the trace to stderr, where it would mix with the output of the code under test. auto therefore falls back to trap on Bash 3.0–4.0, as does an explicit xtrace request that the running Bash cannot honour. Capturing with xtrace costs roughly a quarter of what the DEBUG trap costs per executed line.
The two engines are held to producing identical LCOV output by tests/acceptance/coverage_engine_test.sh. They used to disagree on subshell-heavy code, because the trap engine buffered hits in a shell variable and anything recorded inside a $(...) died with that subshell; hits are written as they happen now, so both engines keep them.
On Bash 3.x the DEBUG trap does not reach a subshell at all, even under set -T, so lines executed inside ( … ), $( … ) or <( … ) are not recorded there. --verbose says so next to the engine it reports, since the percentage alone gives no hint. That is a limitation of the shell, not of the engine, and it means the same project can report a slightly lower percentage on Bash 3.x than on Bash 4+. xtrace, where available, does not have this blind spot.
Per-line execution counts are always written to the LCOV report as the count field of each DA:<line>,<count> record (consumable by genhtml, Codecov and Coveralls). BASHUNIT_COVERAGE_SHOW_LINE_HITS=true additionally surfaces them in the text report as a Line Hits block listing each covered line as <lineno>:<count> per file.
Examples
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,execution_count | BRDA:12,0,1,7 |
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, and the BRDA count is how many times it ran — the execution count of the arm's first executable line.
That line is the one that answers "how often was this branch taken": entering an arm runs it exactly once per entry, while a later line can run more often (a loop inside the arm) or fewer times (an early return). BRF and BRH are unchanged by this: BRH still counts the arms whose count is non-zero.
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 once. An arm entered 12 times readsBRDA:3,0,0,12.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.
This repo's own coverage
bashunit dogfoods its own coverage engine. The coverage.yml workflow runs nightly (and on manual dispatch), measuring --coverage over the unit suite, uploading the coverage/lcov.info report as a build artifact, and publishing the total percentage as a shields.io endpoint badge (shown in the README). It is an optional, non-blocking job — it never runs on push or pull requests, so it is not a status check on any commit. A real-world example of wiring the coverage engine into CI without a third-party coverage service.
Note the workflow excludes the engine's own meta-tests (tests/unit/coverage_*_test.sh) from the measured run: executing them under --coverage double-instruments src/coverage.sh and corrupts their assertions.
Diff coverage
bashunit test --coverage --coverage-diff <base-ref>
Answers the question a pull request actually asks — are the lines I touched covered? — instead of reporting a whole-file percentage that moves for reasons unrelated to the change under review.
bashunit test tests/ --coverage --coverage-diff mainDiff Coverage (vs main)
---------------
src/parser.sh 7/ 9 lines ( 77%)
---------------
Total: 7/9 (77%)Only lines added or modified since the ref are counted, from three sources merged together: commits since the merge base, staged and unstaged edits, and untracked files (counted in full). A pure deletion contributes nothing — there is no line left to hold an opinion about — and changed lines that are not executable (comments, fi, blank) are ignored, so a comment-only commit is not penalised.
The base ref is required. It is not defaulted, because an optional value would make --coverage-diff tests/ swallow the path as a ref.
With --coverage-min, the gate follows the report: the threshold applies to the diff percentage, so a change that fully covers itself passes even inside a poorly covered file.
bashunit test tests/ --coverage --coverage-diff origin/main --coverage-min 90A change with no executable lines scores 100%, not 0% — otherwise a docs-only commit would fail the gate.
--coverage-diff restricts the console report only. LCOV and HTML stay whole-file, because their consumers (genhtml, Codecov) do their own diffing and expect complete records.
Shallow clones
This needs git and a ref that resolves locally. CI checkouts are often shallow and have no base ref, which would otherwise report "no changed lines" and pass a threshold while measuring nothing — so an unresolvable ref is a hard error instead. Fetch it first:
- uses: actions/checkout@v4
with:
fetch-depth: 0Related
- Command-Line — full reference for CLI flags and options
- Configuration — environment variables and config files
- Benchmarks — measure performance alongside coverage
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.