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 @@ -2,8 +2,14 @@

## Unreleased

### Added
- Add risky test detection for tests with zero assertions (shown as warning, does not fail)

### Fixed
- Fix `source` of non-existent file in `set_up()` silently passing all tests (#611)
- Fix `set_up` running before strict mode — unbound variables in hooks now detected with `--strict`
- Fix `source` failure in `tear_down()`, `set_up_before_script()`, and `tear_down_after_script()` silently passing
- Add missing runtime error patterns: ambiguous redirect, integer expression expected, too many arguments, value too great, not a valid identifier, unexpected EOF

## [0.34.0](https://github.com/TypedDevs/bashunit/compare/0.33.0...0.34.0) - 2026-03-17

Expand Down
20 changes: 19 additions & 1 deletion src/assert_dates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,31 @@ function bashunit::date::to_epoch() {
;;
esac

# Normalize ISO 8601: replace T with space, strip Z suffix, strip tz offset
local normalized="$input"
normalized="${normalized/T/ }"
normalized="${normalized%Z}"
# Strip timezone offset (+HHMM or -HHMM) at end for initial parsing
case "$normalized" in
*[+-][0-9][0-9][0-9][0-9])
normalized="${normalized%[+-][0-9][0-9][0-9][0-9]}"
;;
esac

# Format conversion (GNU vs BSD date)
local epoch
# Try GNU date first (-d flag)
# Try GNU date first (-d flag) with original input
epoch=$(date -d "$input" +%s 2>/dev/null) && {
echo "$epoch"
return 0
}
# Try GNU date with normalized (space-separated) input
if [[ "$normalized" != "$input" ]]; then
epoch=$(date -d "$normalized" +%s 2>/dev/null) && {
echo "$epoch"
return 0
}
fi
# Try BSD date (-j -f flag) with ISO 8601 datetime + timezone offset
epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$input" +%s 2>/dev/null) && {
echo "$epoch"
Expand Down
4 changes: 4 additions & 0 deletions src/colors.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ if bashunit::env::is_no_color_enabled; then
_BASHUNIT_COLOR_SKIPPED=""
_BASHUNIT_COLOR_INCOMPLETE=""
_BASHUNIT_COLOR_SNAPSHOT=""
_BASHUNIT_COLOR_RISKY=""
_BASHUNIT_COLOR_RETURN_ERROR=""
_BASHUNIT_COLOR_RETURN_SUCCESS=""
_BASHUNIT_COLOR_RETURN_SKIPPED=""
_BASHUNIT_COLOR_RETURN_INCOMPLETE=""
_BASHUNIT_COLOR_RETURN_SNAPSHOT=""
_BASHUNIT_COLOR_RETURN_RISKY=""
_BASHUNIT_COLOR_DEFAULT=""
else
_BASHUNIT_COLOR_BOLD="$(bashunit::sgr 1)"
Expand All @@ -42,10 +44,12 @@ else
_BASHUNIT_COLOR_SKIPPED="$(bashunit::sgr 33)"
_BASHUNIT_COLOR_INCOMPLETE="$(bashunit::sgr 36)"
_BASHUNIT_COLOR_SNAPSHOT="$(bashunit::sgr 34)"
_BASHUNIT_COLOR_RISKY="$(bashunit::sgr 35)"
_BASHUNIT_COLOR_RETURN_ERROR="$(bashunit::sgr 41)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_RETURN_SUCCESS="$(bashunit::sgr 42)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_RETURN_SKIPPED="$(bashunit::sgr 43)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_RETURN_INCOMPLETE="$(bashunit::sgr 46)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_RETURN_SNAPSHOT="$(bashunit::sgr 44)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_RETURN_RISKY="$(bashunit::sgr 45)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_DEFAULT="$(bashunit::sgr 0)"
fi
50 changes: 50 additions & 0 deletions src/console_results.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function bashunit::console_results::render_result() {
local tests_incomplete=$_BASHUNIT_TESTS_INCOMPLETE
local tests_snapshot=$_BASHUNIT_TESTS_SNAPSHOT
local tests_failed=$_BASHUNIT_TESTS_FAILED
local tests_risky=$_BASHUNIT_TESTS_RISKY
local assertions_passed=$_BASHUNIT_ASSERTIONS_PASSED
local assertions_skipped=$_BASHUNIT_ASSERTIONS_SKIPPED
local assertions_incomplete=$_BASHUNIT_ASSERTIONS_INCOMPLETE
Expand All @@ -42,6 +43,7 @@ function bashunit::console_results::render_result() {
total_tests=$((total_tests + tests_incomplete))
total_tests=$((total_tests + tests_snapshot))
total_tests=$((total_tests + tests_failed))
total_tests=$((total_tests + tests_risky))

local total_assertions=0
total_assertions=$((total_assertions + assertions_passed))
Expand All @@ -66,6 +68,9 @@ function bashunit::console_results::render_result() {
if [[ "$tests_failed" -gt 0 ]] || [[ "$assertions_failed" -gt 0 ]]; then
printf " %s%s failed%s," "$_BASHUNIT_COLOR_FAILED" "$tests_failed" "$_BASHUNIT_COLOR_DEFAULT"
fi
if [[ "$tests_risky" -gt 0 ]]; then
printf " %s%s risky%s," "$_BASHUNIT_COLOR_RISKY" "$tests_risky" "$_BASHUNIT_COLOR_DEFAULT"
fi
printf " %s total\n" "$total_tests"

printf "%sAssertions:%s" "$_BASHUNIT_COLOR_FAINT" "$_BASHUNIT_COLOR_DEFAULT"
Expand All @@ -92,6 +97,12 @@ function bashunit::console_results::render_result() {
return 1
fi

if [[ "$tests_risky" -gt 0 ]]; then
printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_RISKY" " Some tests risky (no assertions) " "$_BASHUNIT_COLOR_DEFAULT"
bashunit::console_results::print_execution_time
return 0
fi

if [[ "$tests_incomplete" -gt 0 ]]; then
printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_INCOMPLETE" " Some tests incomplete " "$_BASHUNIT_COLOR_DEFAULT"
bashunit::console_results::print_execution_time
Expand Down Expand Up @@ -359,6 +370,23 @@ function bashunit::console_results::print_snapshot_test() {
bashunit::state::print_line "snapshot" "$line"
}

function bashunit::console_results::print_risky_test() {
local test_name=$1
local duration=${2:-"0"}

local line
line=$(printf "%s⚠ Risky%s: %s" "$_BASHUNIT_COLOR_RISKY" "$_BASHUNIT_COLOR_DEFAULT" "$test_name")

local full_line=$line
if bashunit::env::is_show_execution_time_enabled; then
local time_display
time_display=$(bashunit::console_results::format_duration "$duration")
full_line="$(printf "%s\n" "$(bashunit::str::rpad "$line" "$time_display")")"
fi

bashunit::state::print_line "risky" "$full_line"
}

function bashunit::console_results::print_error_test() {
local function_name=$1
local error="$2"
Expand Down Expand Up @@ -447,3 +475,25 @@ function bashunit::console_results::print_incomplete_tests_and_reset() {
echo ""
fi
}

function bashunit::console_results::print_risky_tests_and_reset() {
if [[ -s "$RISKY_OUTPUT_PATH" ]]; then
local total_risky
total_risky=$(bashunit::state::get_tests_risky)

if bashunit::env::is_simple_output_enabled; then
printf "\n"
fi

if [[ "$total_risky" -eq 1 ]]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 risky test:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_risky risky tests:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

tr -d '\r' <"$RISKY_OUTPUT_PATH" | sed '/^[[:space:]]*$/d' | sed 's/^/|/'
rm "$RISKY_OUTPUT_PATH"

echo ""
fi
}
1 change: 1 addition & 0 deletions src/env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ MKTEMP="$(command -v mktemp)"
FAILURES_OUTPUT_PATH=$("$MKTEMP")
SKIPPED_OUTPUT_PATH=$("$MKTEMP")
INCOMPLETE_OUTPUT_PATH=$("$MKTEMP")
RISKY_OUTPUT_PATH=$("$MKTEMP")

# Initialize temp directory once at startup for performance
BASHUNIT_TEMP_DIR="${TMPDIR:-/tmp}/bashunit/tmp"
Expand Down
2 changes: 2 additions & 0 deletions src/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ function bashunit::main::exec_tests() {

if ! bashunit::env::is_tap_output_enabled; then
bashunit::console_results::print_failing_tests_and_reset
bashunit::console_results::print_risky_tests_and_reset
bashunit::console_results::print_incomplete_tests_and_reset
bashunit::console_results::print_skipped_tests_and_reset
fi
Expand Down Expand Up @@ -750,6 +751,7 @@ function bashunit::main::cleanup() {
function bashunit::main::handle_stop_on_failure_sync() {
printf "\n%sStop on failure enabled...%s\n" "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}"
bashunit::console_results::print_failing_tests_and_reset
bashunit::console_results::print_risky_tests_and_reset
bashunit::console_results::print_incomplete_tests_and_reset
bashunit::console_results::print_skipped_tests_and_reset
bashunit::console_results::render_result
Expand Down
7 changes: 7 additions & 0 deletions src/parallel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ function bashunit::parallel::aggregate_test_results() {
continue
fi

# Check for risky test (zero assertions, no error)
local total_for_test=$((failed + passed + skipped + incomplete + snapshot))
if [ "$total_for_test" -eq 0 ] && [ "${exit_code:-0}" -eq 0 ]; then
bashunit::state::add_tests_risky
continue
fi

bashunit::state::add_tests_passed
done
done
Expand Down
7 changes: 7 additions & 0 deletions src/reports.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ function bashunit::reports::add_test_passed() {
bashunit::reports::add_test "$1" "$2" "$3" "$4" "passed"
}

function bashunit::reports::add_test_risky() {
bashunit::reports::add_test "$1" "$2" "$3" "$4" "risky"
}

function bashunit::reports::add_test_failed() {
bashunit::reports::add_test "$1" "$2" "$3" "$4" "failed" "$5"
}
Expand Down Expand Up @@ -94,6 +98,8 @@ function bashunit::reports::generate_junit_xml() {
local escaped_message
escaped_message=$(bashunit::reports::__xml_escape "$failure_message")
echo " <failure message=\"Test failed\">$escaped_message</failure>"
elif [[ "$status" == "risky" ]]; then
echo " <skipped message=\"Test has no assertions (risky)\"/>"
elif [[ "$status" == "skipped" ]]; then
echo " <skipped/>"
elif [[ "$status" == "incomplete" ]]; then
Expand Down Expand Up @@ -151,6 +157,7 @@ function bashunit::reports::generate_report_html() {
echo " .skipped { background-color: #fcf8e3; }"
echo " .incomplete { background-color: #d9edf7; }"
echo " .snapshot { background-color: #dfe6e9; }"
echo " .risky { background-color: #f5e6f5; }"
echo " </style>"
echo "</head>"
echo "<body>"
Expand Down
72 changes: 55 additions & 17 deletions src/runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,10 @@ function bashunit::runner::run_test() {
"division by 0" "cannot allocate memory" "bad file descriptor" \
"segmentation fault" "illegal option" "argument list too long" \
"readonly variable" "missing keyword" "killed" \
"cannot execute binary file" "invalid arithmetic operator"; do
"cannot execute binary file" "invalid arithmetic operator" \
"ambiguous redirect" "integer expression expected" \
"too many arguments" "value too great" \
"not a valid identifier" "unexpected EOF"; do
if [[ "$runtime_output" == *"$error"* ]]; then
runtime_error="${runtime_output#*: }" # Remove everything up to and including ": "
runtime_error=${runtime_error//$'\n'/} # Remove all newlines using parameter expansion
Expand Down Expand Up @@ -814,6 +817,18 @@ function bashunit::runner::run_test() {
return
fi

# Check for risky test (zero assertions)
if [[ "$total_assertions" -eq 0 ]]; then
bashunit::state::add_tests_risky
if ! bashunit::env::is_failures_only_enabled; then
bashunit::console_results::print_risky_test "${label}" "$duration"
fi
bashunit::reports::add_test_risky "$test_file" "$label" "$duration" "$total_assertions"
bashunit::runner::write_risky_result_output "$test_file" "$fn_name"
bashunit::internal_log "Test risky" "$label"
return
fi

# In failures-only mode, suppress successful test output
if ! bashunit::env::is_failures_only_enabled; then
if [[ "$fn_name" == "$interpolated_fn_name" ]]; then
Expand Down Expand Up @@ -1066,6 +1081,21 @@ function bashunit::runner::write_incomplete_result_output() {
echo -e "$test_nr) $test_file:$line_number\n$output_msg" >>"$INCOMPLETE_OUTPUT_PATH"
}

function bashunit::runner::write_risky_result_output() {
local test_file=$1
local fn_name=$2

local line_number
line_number=$(bashunit::helper::get_function_line_number "$fn_name")

local test_nr="*"
if ! bashunit::parallel::is_enabled; then
test_nr=$(bashunit::state::get_tests_risky)
fi

echo -e "$test_nr) $test_file:$line_number\nTest has no assertions (risky)" >>"$RISKY_OUTPUT_PATH"
}

function bashunit::runner::record_file_hook_failure() {
local hook_name="$1"
local test_file="$2"
Expand Down Expand Up @@ -1103,15 +1133,19 @@ function bashunit::runner::execute_file_hook() {
local hook_output_file
hook_output_file=$(bashunit::temp_file "${hook_name}_output")

# Enable errexit and errtrace to catch any failing command in the hook.
# The ERR trap saves the exit status to a global variable (since return value
# from trap doesn't propagate properly), disables errexit (to prevent caller
# from exiting) and returns from the hook function, preventing subsequent
# commands from executing.
# Enable errtrace to catch any failing command in the hook.
# Using -E (errtrace) without -e (errexit) prevents the main process from
# exiting on source failures (Bash 3.2 doesn't trigger ERR trap with -eE).
# The ERR trap saves the exit status to a global variable, cleans up shell
# options, and returns from the hook function to prevent subsequent commands
# from executing.
# Variables set before the failure are preserved since we don't use a subshell.
_BASHUNIT_HOOK_ERR_STATUS=0
set -eE
trap '_BASHUNIT_HOOK_ERR_STATUS=$?; set +eE; trap - ERR; return $_BASHUNIT_HOOK_ERR_STATUS' ERR
set -E
if bashunit::env::is_strict_mode_enabled; then
set -uo pipefail
fi
trap '_BASHUNIT_HOOK_ERR_STATUS=$?; set +Eu +o pipefail; trap - ERR; return $_BASHUNIT_HOOK_ERR_STATUS' ERR

{
"$hook_name"
Expand All @@ -1120,7 +1154,7 @@ function bashunit::runner::execute_file_hook() {
# Capture exit status from global variable and clean up
status=$_BASHUNIT_HOOK_ERR_STATUS
trap - ERR
set +eE
set +Eu +o pipefail

if [[ -f "$hook_output_file" ]]; then
hook_output=""
Expand Down Expand Up @@ -1204,15 +1238,19 @@ function bashunit::runner::execute_test_hook() {
local hook_output_file
hook_output_file=$(bashunit::temp_file "${hook_name}_output")

# Enable errexit and errtrace to catch any failing command in the hook.
# The ERR trap saves the exit status to a global variable (since return value
# from trap doesn't propagate properly), disables errexit (to prevent caller
# from exiting) and returns from the hook function, preventing subsequent
# commands from executing.
# Enable errtrace to catch any failing command in the hook.
# Using -E (errtrace) without -e (errexit) prevents the subshell from
# exiting on source failures (Bash 3.2 doesn't trigger ERR trap with -eE).
# The ERR trap saves the exit status to a global variable, cleans up shell
# options, and returns from the hook function to prevent subsequent commands
# from executing.
# Variables set before the failure are preserved since we don't use a subshell.
_BASHUNIT_HOOK_ERR_STATUS=0
set -eE
trap '_BASHUNIT_HOOK_ERR_STATUS=$?; set +eE; trap - ERR; return $_BASHUNIT_HOOK_ERR_STATUS' ERR
set -E
if bashunit::env::is_strict_mode_enabled; then
set -uo pipefail
fi
trap '_BASHUNIT_HOOK_ERR_STATUS=$?; set +Eu +o pipefail; trap - ERR; return $_BASHUNIT_HOOK_ERR_STATUS' ERR

{
"$hook_name"
Expand All @@ -1221,7 +1259,7 @@ function bashunit::runner::execute_test_hook() {
# Capture exit status from global variable and clean up
status=$_BASHUNIT_HOOK_ERR_STATUS
trap - ERR
set +eE
set +Eu +o pipefail

if [[ -f "$hook_output_file" ]]; then
hook_output=""
Expand Down
14 changes: 14 additions & 0 deletions src/state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ _BASHUNIT_TESTS_FAILED=0
_BASHUNIT_TESTS_SKIPPED=0
_BASHUNIT_TESTS_INCOMPLETE=0
_BASHUNIT_TESTS_SNAPSHOT=0
_BASHUNIT_TESTS_RISKY=0
_BASHUNIT_ASSERTIONS_PASSED=0
_BASHUNIT_ASSERTIONS_FAILED=0
_BASHUNIT_ASSERTIONS_SKIPPED=0
Expand Down Expand Up @@ -68,6 +69,14 @@ function bashunit::state::add_tests_snapshot() {
((_BASHUNIT_TESTS_SNAPSHOT++)) || true
}

function bashunit::state::get_tests_risky() {
echo "$_BASHUNIT_TESTS_RISKY"
}

function bashunit::state::add_tests_risky() {
((_BASHUNIT_TESTS_RISKY++)) || true
}

function bashunit::state::get_assertions_passed() {
echo "$_BASHUNIT_ASSERTIONS_PASSED"
}
Expand Down Expand Up @@ -298,6 +307,7 @@ function bashunit::state::print_line() {
skipped) char="${_BASHUNIT_COLOR_SKIPPED}S${_BASHUNIT_COLOR_DEFAULT}" ;;
incomplete) char="${_BASHUNIT_COLOR_INCOMPLETE}I${_BASHUNIT_COLOR_DEFAULT}" ;;
snapshot) char="${_BASHUNIT_COLOR_SNAPSHOT}N${_BASHUNIT_COLOR_DEFAULT}" ;;
risky) char="${_BASHUNIT_COLOR_RISKY}R${_BASHUNIT_COLOR_DEFAULT}" ;;
error) char="${_BASHUNIT_COLOR_FAILED}E${_BASHUNIT_COLOR_DEFAULT}" ;;
*) char="?" && bashunit::log "warning" "unknown test type '$type'" ;;
esac
Expand Down Expand Up @@ -364,6 +374,10 @@ function bashunit::state::print_tap_line() {
printf "ok %d - %s # snapshot\n" \
"$_BASHUNIT_TOTAL_TESTS_COUNT" "$test_name"
;;
risky)
printf "ok %d - %s # RISKY no assertions\n" \
"$_BASHUNIT_TOTAL_TESTS_COUNT" "$test_name"
;;
*)
printf "not ok %d - %s\n" \
"$_BASHUNIT_TOTAL_TESTS_COUNT" "$test_name"
Expand Down
Loading
Loading