Skip to content

Commit 50272bd

Browse files
authored
Merge pull request #571 from TypedDevs/feat/auto-discover-coverage-paths-from-test-file-names
Auto discover coverage paths from test file names
2 parents 28a8c18 + bda9cbf commit 50272bd

File tree

8 files changed

+165
-27
lines changed

8 files changed

+165
-27
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
### Added
66
- Better code coverage HTML report
7+
- Auto-discover coverage paths from test file names when `BASHUNIT_COVERAGE_PATHS` is not set
8+
- `tests/unit/assert_test.sh` automatically tracks `src/assert.sh`
9+
- Removes need for manual `--coverage-paths` configuration in most cases
10+
11+
### Fixed
12+
- Coverage now excludes control flow keywords (`then`, `else`, `fi`, `do`, `done`, `esac`, `;;`, case patterns) from line tracking
713

814
## [0.31.0](https://github.com/TypedDevs/bashunit/compare/0.30.0...0.31.0) - 2025-12-19
915

docs/command-line.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ bashunit test tests/ --parallel --simple
6767
| `-l, --login` | Run tests in login shell context |
6868
| `--no-color` | Disable colored output |
6969
| `--coverage` | Enable code coverage tracking |
70-
| `--coverage-paths <paths>` | Paths to track (default: `src/`) |
70+
| `--coverage-paths <paths>` | Paths to track (default: auto-discover) |
7171
| `--coverage-exclude <pat>` | Exclusion patterns |
7272
| `--coverage-report <file>` | LCOV output path (default: `coverage/lcov.info`) |
7373
| `--coverage-report-html <dir>` | Generate HTML report with line highlighting |
@@ -311,7 +311,7 @@ bashunit test tests/ --coverage --coverage-paths src/,lib/ --coverage-min 80
311311
| Option | Description |
312312
|---------------------------------|-----------------------------------------------------------------------------|
313313
| `--coverage` | Enable coverage tracking |
314-
| `--coverage-paths <paths>` | Comma-separated paths to track (default: `src/`) |
314+
| `--coverage-paths <paths>` | Comma-separated paths to track (default: auto-discover from test files) |
315315
| `--coverage-exclude <patterns>` | Comma-separated patterns to exclude (default: `tests/*,vendor/*,*_test.sh`) |
316316
| `--coverage-report <file>` | LCOV output file path (default: `coverage/lcov.info`) |
317317
| `--coverage-report-html <dir>` | Generate HTML coverage report with line-by-line highlighting |

docs/configuration.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -450,14 +450,16 @@ BASHUNIT_COVERAGE=true
450450

451451
> `BASHUNIT_COVERAGE_PATHS=paths`
452452
453-
Comma-separated list of paths to track for coverage. `src/` by default.
453+
Comma-separated list of paths to track for coverage.
454+
455+
By default, paths are auto-discovered from test file names (e.g., `tests/unit/assert_test.sh` discovers `src/assert.sh`).
454456

455457
::: code-group
456458
```bash [.env]
457-
# Single path
459+
# Single path (explicit)
458460
BASHUNIT_COVERAGE_PATHS=src/
459461

460-
# Multiple paths
462+
# Multiple paths (explicit)
461463
BASHUNIT_COVERAGE_PATHS=src/,lib/,bin/
462464
```
463465
:::

docs/coverage.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ The DEBUG trap adds overhead to test execution. For large test suites, consider
5454
| Option | Description |
5555
|--------|-------------|
5656
| `--coverage` | Enable code coverage tracking |
57-
| `--coverage-paths <paths>` | Comma-separated paths to track (default: `src/`) |
57+
| `--coverage-paths <paths>` | Comma-separated paths to track (default: auto-discover from test files) |
5858
| `--coverage-exclude <patterns>` | Comma-separated exclusion patterns |
5959
| `--coverage-report <file>` | LCOV report output path (default: `coverage/lcov.info`) |
6060
| `--coverage-report-html <dir>` | Generate HTML coverage report with line-by-line details |
@@ -65,6 +65,21 @@ The DEBUG trap adds overhead to test execution. For large test suites, consider
6565
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.
6666
:::
6767

68+
### Auto-Discovery
69+
70+
When `BASHUNIT_COVERAGE_PATHS` is not set, bashunit automatically discovers source files based on your test file names:
71+
72+
| Test File | Discovers |
73+
|-----------|-----------|
74+
| `tests/unit/assert_test.sh` | `src/assert.sh`, `src/assert_*.sh` |
75+
| `tests/unit/helperTest.sh` | `src/helper.sh`, `src/helper*.sh` |
76+
77+
This convention follows the common pattern of naming test files after their source files with a `_test.sh` or `Test.sh` suffix.
78+
79+
::: tip Zero Configuration
80+
For most projects following standard naming conventions, you can simply run `bashunit tests/ --coverage` without any path configuration.
81+
:::
82+
6883
### Environment Variables
6984

7085
You can also configure coverage via environment variables in your `.env` file:
@@ -329,6 +344,8 @@ These lines are not counted toward coverage:
329344
- Comment lines (including shebang `#!/usr/bin/env bash`)
330345
- Function declaration lines (`function foo() {`)
331346
- Lines with only braces (`{` or `}`)
347+
- Control flow keywords (`then`, `else`, `fi`, `do`, `done`, `esac`, `in`)
348+
- Case statement patterns (`--option)`, `*)`) and terminators (`;;`, `;&`, `;;&`)
332349

333350
## Limitations
334351

src/coverage.sh

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,40 @@ _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE:-
1313
# File to store which tests hit each line (for detailed coverage tooltips)
1414
_BASHUNIT_COVERAGE_TEST_HITS_FILE="${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}"
1515

16-
# Store the subshell level when coverage trap is enabled
17-
# Used to skip recording in nested subshells (command substitution)
18-
# Uses $BASH_SUBSHELL which is Bash 3.2 compatible (unlike $BASHPID)
19-
_BASHUNIT_COVERAGE_SUBSHELL_LEVEL="${_BASHUNIT_COVERAGE_SUBSHELL_LEVEL:-}"
16+
# Auto-discover coverage paths from test file names
17+
# When no explicit coverage paths are set, find source files matching test file base names
18+
# Example: tests/unit/assert_test.sh -> finds src/assert.sh, src/assert_*.sh
19+
function bashunit::coverage::auto_discover_paths() {
20+
local project_root
21+
project_root="$(pwd)"
22+
local -a discovered_paths=()
23+
24+
for test_file in "$@"; do
25+
# Extract base name: tests/unit/assert_test.sh -> assert_test.sh
26+
local file_basename
27+
file_basename=$(basename "$test_file")
28+
29+
# Remove test suffixes to get source name: assert_test.sh -> assert
30+
local source_name="${file_basename%_test.sh}"
31+
[[ "$source_name" == "$file_basename" ]] && source_name="${file_basename%Test.sh}"
32+
[[ "$source_name" == "$file_basename" ]] && continue # Not a test file pattern
33+
34+
# Find matching source files recursively
35+
while IFS= read -r -d '' found_file; do
36+
# Skip test files and vendor directories
37+
[[ "$found_file" == *test* ]] && continue
38+
[[ "$found_file" == *Test* ]] && continue
39+
[[ "$found_file" == *vendor* ]] && continue
40+
[[ "$found_file" == *node_modules* ]] && continue
41+
discovered_paths+=("$found_file")
42+
done < <(find "$project_root" -name "${source_name}*.sh" -type f -print0 2>/dev/null)
43+
done
44+
45+
# Return unique paths, comma-separated
46+
if [[ ${#discovered_paths[@]} -gt 0 ]]; then
47+
printf '%s\n' "${discovered_paths[@]}" | sort -u | tr '\n' ',' | sed 's/,$//'
48+
fi
49+
}
2050

2151
function bashunit::coverage::init() {
2252
if ! bashunit::env::is_coverage_enabled; then
@@ -58,11 +88,6 @@ function bashunit::coverage::enable_trap() {
5888
return 0
5989
fi
6090

61-
# Store the subshell level for nested subshell detection
62-
# $BASH_SUBSHELL increments in each nested subshell (Bash 3.2 compatible)
63-
_BASHUNIT_COVERAGE_SUBSHELL_LEVEL="$BASH_SUBSHELL"
64-
export _BASHUNIT_COVERAGE_SUBSHELL_LEVEL
65-
6691
# Enable trap inheritance into functions
6792
set -T
6893

@@ -99,11 +124,6 @@ function bashunit::coverage::record_line() {
99124
# Skip if coverage data file doesn't exist (trap inherited by child process)
100125
[[ -z "$_BASHUNIT_COVERAGE_DATA_FILE" ]] && return 0
101126

102-
# Skip recording in nested subshells (command substitution like $(...))
103-
# $BASH_SUBSHELL increments in each nested subshell
104-
# This prevents interference with tests that capture output
105-
[[ -n "$_BASHUNIT_COVERAGE_SUBSHELL_LEVEL" && "$BASH_SUBSHELL" -gt "$_BASHUNIT_COVERAGE_SUBSHELL_LEVEL" ]] && return 0
106-
107127
# Skip if not tracking this file (uses cache internally)
108128
bashunit::coverage::should_track "$file" || return 0
109129

@@ -299,6 +319,15 @@ function bashunit::coverage::is_executable_line() {
299319
# Skip lines with only braces
300320
[[ "$line" =~ ^[[:space:]]*[\{\}][[:space:]]*$ ]] && return 1
301321

322+
# Skip control flow keywords (then, else, fi, do, done, esac, in, ;;, ;&, ;;&)
323+
[[ "$line" =~ ^[[:space:]]*(then|else|fi|do|done|esac|in|;;|;;&|;&)[[:space:]]*(#.*)?$ ]] && return 1
324+
325+
# Skip case patterns like "--option)" or "*)"
326+
[[ "$line" =~ ^[[:space:]]*[^\)]+\)[[:space:]]*$ ]] && return 1
327+
328+
# Skip standalone ) for arrays/subshells
329+
[[ "$line" =~ ^[[:space:]]*\)[[:space:]]*(#.*)?$ ]] && return 1
330+
302331
return 0
303332
}
304333

@@ -775,7 +804,7 @@ function bashunit::coverage::report_html() {
775804
# Generate index.html
776805
bashunit::coverage::generate_index_html \
777806
"$output_dir/index.html" "$total_hit" "$total_executable" "$total_pct" \
778-
"$tests_total" "$tests_passed" "$tests_failed" "${file_data[@]}"
807+
"$tests_total" "$tests_passed" "$tests_failed" ${file_data[@]+"${file_data[@]}"}
779808

780809
echo "Coverage HTML report written to: $output_dir/index.html"
781810
}
@@ -789,11 +818,13 @@ function bashunit::coverage::generate_index_html() {
789818
local tests_passed="$6"
790819
local tests_failed="$7"
791820
shift 7
792-
local file_data=("$@")
821+
local file_data=()
822+
[[ $# -gt 0 ]] && file_data=("$@")
793823

794824
# Calculate uncovered lines and file count
795825
local total_uncovered=$((total_executable - total_hit))
796-
local file_count=${#file_data[@]}
826+
local file_count=0
827+
[[ ${#file_data[@]} -gt 0 ]] && file_count=${#file_data[@]}
797828

798829
# Calculate gauge stroke offset (440 is full circle circumference)
799830
local gauge_offset=$((440 - (440 * total_pct / 100)))
@@ -1082,7 +1113,7 @@ EOF
10821113
<tbody>
10831114
EOF
10841115

1085-
for data in "${file_data[@]}"; do
1116+
for data in ${file_data[@]+"${file_data[@]}"}; do
10861117
IFS='|' read -r display_file hit executable pct safe_filename <<< "$data"
10871118

10881119
local class="low"

src/env.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ _BASHUNIT_DEFAULT_REPORT_HTML=""
1818

1919
# Coverage defaults (following kcov, bashcov, SimpleCov conventions)
2020
_BASHUNIT_DEFAULT_COVERAGE="false"
21-
_BASHUNIT_DEFAULT_COVERAGE_PATHS="src/"
21+
_BASHUNIT_DEFAULT_COVERAGE_PATHS=""
2222
_BASHUNIT_DEFAULT_COVERAGE_EXCLUDE="tests/*,vendor/*,*_test.sh,*Test.sh"
2323
_BASHUNIT_DEFAULT_COVERAGE_REPORT="coverage/lcov.info"
2424
_BASHUNIT_DEFAULT_COVERAGE_REPORT_HTML=""

src/runner.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ function bashunit::runner::load_test_files() {
2020

2121
# Initialize coverage tracking if enabled
2222
if bashunit::env::is_coverage_enabled; then
23+
# Auto-discover coverage paths if not explicitly set
24+
if [[ -z "$BASHUNIT_COVERAGE_PATHS" ]]; then
25+
BASHUNIT_COVERAGE_PATHS=$(bashunit::coverage::auto_discover_paths "${files[@]}")
26+
fi
2327
bashunit::coverage::init
2428
fi
2529

tests/unit/coverage_test.sh

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,8 @@ function test_coverage_cleanup_removes_temp_files() {
220220
assert_directory_not_exists "$coverage_dir"
221221
}
222222

223-
function test_coverage_default_paths_is_src() {
224-
assert_equals "src/" "$_BASHUNIT_DEFAULT_COVERAGE_PATHS"
223+
function test_coverage_default_paths_is_empty_for_auto_discovery() {
224+
assert_equals "" "$_BASHUNIT_DEFAULT_COVERAGE_PATHS"
225225
}
226226

227227
function test_coverage_default_report_is_lcov() {
@@ -273,6 +273,84 @@ function test_coverage_is_executable_line_returns_false_for_brace_only() {
273273
assert_equals "no" "$result"
274274
}
275275

276+
function test_coverage_is_executable_line_returns_false_for_then() {
277+
local result
278+
result=$(bashunit::coverage::is_executable_line ' then' 2 && echo "yes" || echo "no")
279+
assert_equals "no" "$result"
280+
}
281+
282+
function test_coverage_is_executable_line_returns_false_for_else() {
283+
local result
284+
result=$(bashunit::coverage::is_executable_line ' else' 2 && echo "yes" || echo "no")
285+
assert_equals "no" "$result"
286+
}
287+
288+
function test_coverage_is_executable_line_returns_false_for_fi() {
289+
local result
290+
result=$(bashunit::coverage::is_executable_line ' fi' 2 && echo "yes" || echo "no")
291+
assert_equals "no" "$result"
292+
}
293+
294+
function test_coverage_is_executable_line_returns_false_for_do() {
295+
local result
296+
result=$(bashunit::coverage::is_executable_line ' do' 2 && echo "yes" || echo "no")
297+
assert_equals "no" "$result"
298+
}
299+
300+
function test_coverage_is_executable_line_returns_false_for_done() {
301+
local result
302+
result=$(bashunit::coverage::is_executable_line ' done' 2 && echo "yes" || echo "no")
303+
assert_equals "no" "$result"
304+
}
305+
306+
function test_coverage_is_executable_line_returns_false_for_esac() {
307+
local result
308+
result=$(bashunit::coverage::is_executable_line ' esac' 2 && echo "yes" || echo "no")
309+
assert_equals "no" "$result"
310+
}
311+
312+
function test_coverage_is_executable_line_returns_false_for_case_terminator() {
313+
local result
314+
result=$(bashunit::coverage::is_executable_line ' ;;' 2 && echo "yes" || echo "no")
315+
assert_equals "no" "$result"
316+
}
317+
318+
function test_coverage_is_executable_line_returns_false_for_case_pattern() {
319+
local result
320+
result=$(bashunit::coverage::is_executable_line ' --exit)' 2 && echo "yes" || echo "no")
321+
assert_equals "no" "$result"
322+
}
323+
324+
function test_coverage_is_executable_line_returns_false_for_wildcard_case() {
325+
local result
326+
result=$(bashunit::coverage::is_executable_line ' *)' 2 && echo "yes" || echo "no")
327+
assert_equals "no" "$result"
328+
}
329+
330+
function test_coverage_is_executable_line_returns_false_for_case_fallthrough() {
331+
local result
332+
result=$(bashunit::coverage::is_executable_line ' ;&' 2 && echo "yes" || echo "no")
333+
assert_equals "no" "$result"
334+
}
335+
336+
function test_coverage_is_executable_line_returns_false_for_case_continue() {
337+
local result
338+
result=$(bashunit::coverage::is_executable_line ' ;;&' 2 && echo "yes" || echo "no")
339+
assert_equals "no" "$result"
340+
}
341+
342+
function test_coverage_is_executable_line_returns_false_for_in_keyword() {
343+
local result
344+
result=$(bashunit::coverage::is_executable_line ' in' 2 && echo "yes" || echo "no")
345+
assert_equals "no" "$result"
346+
}
347+
348+
function test_coverage_is_executable_line_returns_false_for_standalone_paren() {
349+
local result
350+
result=$(bashunit::coverage::is_executable_line ' )' 2 && echo "yes" || echo "no")
351+
assert_equals "no" "$result"
352+
}
353+
276354
function test_coverage_check_threshold_fails_when_below_minimum() {
277355
BASHUNIT_COVERAGE="true"
278356
BASHUNIT_COVERAGE_MIN="80"

0 commit comments

Comments
 (0)