Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

### Added
- Better code coverage HTML report
- Auto-discover coverage paths from test file names when `BASHUNIT_COVERAGE_PATHS` is not set
- `tests/unit/assert_test.sh` automatically tracks `src/assert.sh`
- Removes need for manual `--coverage-paths` configuration in most cases

### Fixed
- Coverage now excludes control flow keywords (`then`, `else`, `fi`, `do`, `done`, `esac`, `;;`, case patterns) from line tracking

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

Expand Down
4 changes: 2 additions & 2 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ bashunit test tests/ --parallel --simple
| `-l, --login` | Run tests in login shell context |
| `--no-color` | Disable colored output |
| `--coverage` | Enable code coverage tracking |
| `--coverage-paths <paths>` | Paths to track (default: `src/`) |
| `--coverage-paths <paths>` | Paths to track (default: auto-discover) |
| `--coverage-exclude <pat>` | Exclusion patterns |
| `--coverage-report <file>` | LCOV output path (default: `coverage/lcov.info`) |
| `--coverage-report-html <dir>` | Generate HTML report with line highlighting |
Expand Down Expand Up @@ -311,7 +311,7 @@ bashunit test tests/ --coverage --coverage-paths src/,lib/ --coverage-min 80
| Option | Description |
|---------------------------------|-----------------------------------------------------------------------------|
| `--coverage` | Enable coverage tracking |
| `--coverage-paths <paths>` | Comma-separated paths to track (default: `src/`) |
| `--coverage-paths <paths>` | Comma-separated paths to track (default: auto-discover from test files) |
| `--coverage-exclude <patterns>` | Comma-separated patterns to exclude (default: `tests/*,vendor/*,*_test.sh`) |
| `--coverage-report <file>` | LCOV output file path (default: `coverage/lcov.info`) |
| `--coverage-report-html <dir>` | Generate HTML coverage report with line-by-line highlighting |
Expand Down
8 changes: 5 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,14 +450,16 @@ BASHUNIT_COVERAGE=true

> `BASHUNIT_COVERAGE_PATHS=paths`

Comma-separated list of paths to track for coverage. `src/` by default.
Comma-separated list of paths to track for coverage.

By default, paths are auto-discovered from test file names (e.g., `tests/unit/assert_test.sh` discovers `src/assert.sh`).

::: code-group
```bash [.env]
# Single path
# Single path (explicit)
BASHUNIT_COVERAGE_PATHS=src/

# Multiple paths
# Multiple paths (explicit)
BASHUNIT_COVERAGE_PATHS=src/,lib/,bin/
```
:::
Expand Down
19 changes: 18 additions & 1 deletion docs/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The DEBUG trap adds overhead to test execution. For large test suites, consider
| Option | Description |
|--------|-------------|
| `--coverage` | Enable code coverage tracking |
| `--coverage-paths <paths>` | Comma-separated paths to track (default: `src/`) |
| `--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 coverage report with line-by-line details |
Expand All @@ -65,6 +65,21 @@ The DEBUG trap adds overhead to test execution. For large test suites, consider
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.

::: tip 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:
Expand Down Expand Up @@ -329,6 +344,8 @@ These lines are not counted toward coverage:
- 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 (`;;`, `;&`, `;;&`)

## Limitations

Expand Down
67 changes: 49 additions & 18 deletions src/coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,40 @@ _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE:-
# File to store which tests hit each line (for detailed coverage tooltips)
_BASHUNIT_COVERAGE_TEST_HITS_FILE="${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}"

# Store the subshell level when coverage trap is enabled
# Used to skip recording in nested subshells (command substitution)
# Uses $BASH_SUBSHELL which is Bash 3.2 compatible (unlike $BASHPID)
_BASHUNIT_COVERAGE_SUBSHELL_LEVEL="${_BASHUNIT_COVERAGE_SUBSHELL_LEVEL:-}"
# Auto-discover coverage paths from test file names
# When no explicit coverage paths are set, find source files matching test file base names
# Example: tests/unit/assert_test.sh -> finds src/assert.sh, src/assert_*.sh
function bashunit::coverage::auto_discover_paths() {
local project_root
project_root="$(pwd)"
local -a discovered_paths=()

for test_file in "$@"; do
# Extract base name: tests/unit/assert_test.sh -> assert_test.sh
local file_basename
file_basename=$(basename "$test_file")

# Remove test suffixes to get source name: assert_test.sh -> assert
local source_name="${file_basename%_test.sh}"
[[ "$source_name" == "$file_basename" ]] && source_name="${file_basename%Test.sh}"
[[ "$source_name" == "$file_basename" ]] && continue # Not a test file pattern

# Find matching source files recursively
while IFS= read -r -d '' found_file; do
# Skip test files and vendor directories
[[ "$found_file" == *test* ]] && continue
[[ "$found_file" == *Test* ]] && continue
[[ "$found_file" == *vendor* ]] && continue
[[ "$found_file" == *node_modules* ]] && continue
discovered_paths+=("$found_file")
done < <(find "$project_root" -name "${source_name}*.sh" -type f -print0 2>/dev/null)
done

# Return unique paths, comma-separated
if [[ ${#discovered_paths[@]} -gt 0 ]]; then
printf '%s\n' "${discovered_paths[@]}" | sort -u | tr '\n' ',' | sed 's/,$//'
fi
}

function bashunit::coverage::init() {
if ! bashunit::env::is_coverage_enabled; then
Expand Down Expand Up @@ -58,11 +88,6 @@ function bashunit::coverage::enable_trap() {
return 0
fi

# Store the subshell level for nested subshell detection
# $BASH_SUBSHELL increments in each nested subshell (Bash 3.2 compatible)
_BASHUNIT_COVERAGE_SUBSHELL_LEVEL="$BASH_SUBSHELL"
export _BASHUNIT_COVERAGE_SUBSHELL_LEVEL

# Enable trap inheritance into functions
set -T

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

# Skip recording in nested subshells (command substitution like $(...))
# $BASH_SUBSHELL increments in each nested subshell
# This prevents interference with tests that capture output
[[ -n "$_BASHUNIT_COVERAGE_SUBSHELL_LEVEL" && "$BASH_SUBSHELL" -gt "$_BASHUNIT_COVERAGE_SUBSHELL_LEVEL" ]] && return 0

# Skip if not tracking this file (uses cache internally)
bashunit::coverage::should_track "$file" || return 0

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

# Skip control flow keywords (then, else, fi, do, done, esac, in, ;;, ;&, ;;&)
[[ "$line" =~ ^[[:space:]]*(then|else|fi|do|done|esac|in|;;|;;&|;&)[[:space:]]*(#.*)?$ ]] && return 1

# Skip case patterns like "--option)" or "*)"
[[ "$line" =~ ^[[:space:]]*[^\)]+\)[[:space:]]*$ ]] && return 1

# Skip standalone ) for arrays/subshells
[[ "$line" =~ ^[[:space:]]*\)[[:space:]]*(#.*)?$ ]] && return 1

return 0
}

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

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

# Calculate uncovered lines and file count
local total_uncovered=$((total_executable - total_hit))
local file_count=${#file_data[@]}
local file_count=0
[[ ${#file_data[@]} -gt 0 ]] && file_count=${#file_data[@]}

# Calculate gauge stroke offset (440 is full circle circumference)
local gauge_offset=$((440 - (440 * total_pct / 100)))
Expand Down Expand Up @@ -1082,7 +1113,7 @@ EOF
<tbody>
EOF

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

local class="low"
Expand Down
2 changes: 1 addition & 1 deletion src/env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ _BASHUNIT_DEFAULT_REPORT_HTML=""

# Coverage defaults (following kcov, bashcov, SimpleCov conventions)
_BASHUNIT_DEFAULT_COVERAGE="false"
_BASHUNIT_DEFAULT_COVERAGE_PATHS="src/"
_BASHUNIT_DEFAULT_COVERAGE_PATHS=""
_BASHUNIT_DEFAULT_COVERAGE_EXCLUDE="tests/*,vendor/*,*_test.sh,*Test.sh"
_BASHUNIT_DEFAULT_COVERAGE_REPORT="coverage/lcov.info"
_BASHUNIT_DEFAULT_COVERAGE_REPORT_HTML=""
Expand Down
4 changes: 4 additions & 0 deletions src/runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ function bashunit::runner::load_test_files() {

# Initialize coverage tracking if enabled
if bashunit::env::is_coverage_enabled; then
# Auto-discover coverage paths if not explicitly set
if [[ -z "$BASHUNIT_COVERAGE_PATHS" ]]; then
BASHUNIT_COVERAGE_PATHS=$(bashunit::coverage::auto_discover_paths "${files[@]}")
fi
bashunit::coverage::init
fi

Expand Down
82 changes: 80 additions & 2 deletions tests/unit/coverage_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,8 @@ function test_coverage_cleanup_removes_temp_files() {
assert_directory_not_exists "$coverage_dir"
}

function test_coverage_default_paths_is_src() {
assert_equals "src/" "$_BASHUNIT_DEFAULT_COVERAGE_PATHS"
function test_coverage_default_paths_is_empty_for_auto_discovery() {
assert_equals "" "$_BASHUNIT_DEFAULT_COVERAGE_PATHS"
}

function test_coverage_default_report_is_lcov() {
Expand Down Expand Up @@ -273,6 +273,84 @@ function test_coverage_is_executable_line_returns_false_for_brace_only() {
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_then() {
local result
result=$(bashunit::coverage::is_executable_line ' then' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_else() {
local result
result=$(bashunit::coverage::is_executable_line ' else' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_fi() {
local result
result=$(bashunit::coverage::is_executable_line ' fi' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_do() {
local result
result=$(bashunit::coverage::is_executable_line ' do' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_done() {
local result
result=$(bashunit::coverage::is_executable_line ' done' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_esac() {
local result
result=$(bashunit::coverage::is_executable_line ' esac' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_case_terminator() {
local result
result=$(bashunit::coverage::is_executable_line ' ;;' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_case_pattern() {
local result
result=$(bashunit::coverage::is_executable_line ' --exit)' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_wildcard_case() {
local result
result=$(bashunit::coverage::is_executable_line ' *)' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_case_fallthrough() {
local result
result=$(bashunit::coverage::is_executable_line ' ;&' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_case_continue() {
local result
result=$(bashunit::coverage::is_executable_line ' ;;&' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_in_keyword() {
local result
result=$(bashunit::coverage::is_executable_line ' in' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_is_executable_line_returns_false_for_standalone_paren() {
local result
result=$(bashunit::coverage::is_executable_line ' )' 2 && echo "yes" || echo "no")
assert_equals "no" "$result"
}

function test_coverage_check_threshold_fails_when_below_minimum() {
BASHUNIT_COVERAGE="true"
BASHUNIT_COVERAGE_MIN="80"
Expand Down
Loading