Skip to content

Commit f8329c7

Browse files
committed
fix(runner): detect set_up failure when source file does not exist (#611)
When `source` of a non-existent file fails under `set -eE` in a hook, the ERR trap does not fire and bash exits the subshell with a leaked stdout redirect, causing `export_subshell_context` output to be lost. This made all tests silently pass. Fix by saving the subshell's stdout to FD 5 and restoring it in the EXIT trap, plus using a sentinel variable to detect unexpected exit during set_up when $? is incorrectly 0.
1 parent a3f2198 commit f8329c7

File tree

4 files changed

+147
-86
lines changed

4 files changed

+147
-86
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
### Fixed
6+
- Fix `source` of non-existent file in `set_up()` silently passing all tests (#611)
7+
58
## [0.34.0](https://github.com/TypedDevs/bashunit/compare/0.33.0...0.34.0) - 2026-03-17
69

710
### Added

src/runner.sh

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,11 @@ function bashunit::runner::run_test() {
590590
exec 3>&1
591591

592592
local test_execution_result=$(
593+
# Save subshell stdout (the $() capture pipe) to FD 5 so the EXIT trap can
594+
# restore it. When set -e kills the subshell during a redirected block
595+
# (e.g., execute_test_hook's { hook } >file 2>&1), the redirect leaks into
596+
# the EXIT trap, causing export_subshell_context output to be lost.
597+
exec 5>&1
593598
# shellcheck disable=SC2064
594599
trap "exit_code=\$?; bashunit::runner::cleanup_on_exit \"$test_file\" \"\$exit_code\"" EXIT
595600
bashunit::state::initialize_assertions_count
@@ -612,9 +617,12 @@ function bashunit::runner::run_test() {
612617
fi
613618
614619
# Run set_up and capture exit code without || to preserve errexit behavior
620+
# shellcheck disable=SC2030
621+
_BASHUNIT_SETUP_COMPLETED=false
615622
local setup_exit_code=0
616623
bashunit::runner::run_set_up "$test_file"
617624
setup_exit_code=$?
625+
_BASHUNIT_SETUP_COMPLETED=true
618626
if [[ $setup_exit_code -ne 0 ]]; then
619627
exit $setup_exit_code
620628
fi
@@ -707,10 +715,9 @@ function bashunit::runner::run_test() {
707715
_te_incomplete="${_te_incomplete%%##*}"
708716
local _te_snapshot="${test_execution_result##*##ASSERTIONS_SNAPSHOT=}"
709717
_te_snapshot="${_te_snapshot%%##*}"
710-
local total_assertions=$(( \
718+
local total_assertions=$((\
711719
${_te_failed:-0} + ${_te_passed:-0} + ${_te_skipped:-0} + \
712-
${_te_incomplete:-0} + ${_te_snapshot:-0} \
713-
))
720+
${_te_incomplete:-0} + ${_te_snapshot:-0}))
714721

715722
local encoded_test_title
716723
encoded_test_title="${test_execution_result##*##TEST_TITLE=}"
@@ -820,6 +827,12 @@ function bashunit::runner::run_test() {
820827
}
821828

822829
function bashunit::runner::cleanup_on_exit() {
830+
# Restore stdout to the $() capture pipe from saved FD 5 (Issue #611).
831+
# When set -e kills the subshell during execute_test_hook's redirected block,
832+
# the redirect leaks into the EXIT trap. Restoring FD 1 from the saved FD 5
833+
# ensures export_subshell_context output reaches test_execution_result.
834+
exec 1>&5
835+
823836
local test_file="$1"
824837
local exit_code="$2"
825838

@@ -829,6 +842,18 @@ function bashunit::runner::cleanup_on_exit() {
829842
fi
830843

831844
set +e
845+
846+
# Detect unexpected subshell exit during set_up (Issue #611).
847+
# When 'source' of a non-existent file fails under set -eE, the ERR trap
848+
# does not fire and $? is 0 in the EXIT trap. Use a sentinel variable
849+
# to detect this case and force a failure.
850+
# shellcheck disable=SC2031
851+
if [[ "$exit_code" -eq 0 && "${_BASHUNIT_SETUP_COMPLETED:-true}" != "true" ]]; then
852+
exit_code=1
853+
bashunit::state::set_test_hook_failure "set_up"
854+
bashunit::state::set_test_hook_message "Hook 'set_up' failed unexpectedly (e.g., source of non-existent file)"
855+
fi
856+
832857
# Don't use || here - it disables ERR trap in the entire call chain
833858
bashunit::runner::run_tear_down "$test_file"
834859
local teardown_status=$?

tests/acceptance/bashunit_setup_error_test.sh

Lines changed: 106 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2,107 +2,130 @@
22
set -euo pipefail
33

44
function set_up_before_script() {
5-
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
5+
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
66
}
77

88
function strip_ansi() {
9-
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
9+
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
1010
}
1111

1212
function test_bashunit_when_set_up_errors() {
13-
local test_file=./tests/acceptance/fixtures/test_bashunit_when_setup_errors.sh
14-
local fixture=$test_file
15-
16-
local header_line="Running $fixture"
17-
local error_line="✗ Error: Set up"
18-
local message_line=" $fixture: line 4: invalid_function_name: command not found"
19-
local tests_summary="Tests: 1 failed, 1 total"
20-
local assertions_summary="Assertions: 0 failed, 0 total"
21-
22-
local actual_raw
23-
set +e
24-
actual_raw="$(./bashunit --no-parallel --detailed --env "$TEST_ENV_FILE" "$test_file")"
25-
set -e
26-
27-
local actual
28-
actual="$(printf "%s" "$actual_raw" | strip_ansi)"
29-
30-
assert_contains "$header_line" "$actual"
31-
assert_contains "$error_line" "$actual"
32-
assert_contains "$message_line" "$actual"
33-
assert_contains "$tests_summary" "$actual"
34-
assert_contains "$assertions_summary" "$actual"
35-
assert_general_error "$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file")"
13+
local test_file=./tests/acceptance/fixtures/test_bashunit_when_setup_errors.sh
14+
local fixture=$test_file
15+
16+
local header_line="Running $fixture"
17+
local error_line="✗ Error: Set up"
18+
local message_line=" $fixture: line 4: invalid_function_name: command not found"
19+
local tests_summary="Tests: 1 failed, 1 total"
20+
local assertions_summary="Assertions: 0 failed, 0 total"
21+
22+
local actual_raw
23+
set +e
24+
actual_raw="$(./bashunit --no-parallel --detailed --env "$TEST_ENV_FILE" "$test_file")"
25+
set -e
26+
27+
local actual
28+
actual="$(printf "%s" "$actual_raw" | strip_ansi)"
29+
30+
assert_contains "$header_line" "$actual"
31+
assert_contains "$error_line" "$actual"
32+
assert_contains "$message_line" "$actual"
33+
assert_contains "$tests_summary" "$actual"
34+
assert_contains "$assertions_summary" "$actual"
35+
assert_general_error "$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file")"
3636
}
3737

3838
function test_bashunit_when_set_up_with_failing_command() {
39-
local test_file=./tests/acceptance/fixtures/test_bashunit_when_setup_with_failing_command.sh
40-
local fixture=$test_file
41-
42-
local header_line="Running $fixture"
43-
local error_line="✗ Error: Set up"
44-
local message_line=" Hook 'set_up' failed with exit code 1"
45-
local tests_summary="Tests: 1 failed, 1 total"
46-
local assertions_summary="Assertions: 0 failed, 0 total"
47-
48-
local actual_raw
49-
set +e
50-
actual_raw="$(./bashunit --no-parallel --detailed --env "$TEST_ENV_FILE" "$test_file")"
51-
set -e
52-
53-
local actual
54-
actual="$(printf "%s" "$actual_raw" | strip_ansi)"
55-
56-
assert_contains "$header_line" "$actual"
57-
assert_contains "$error_line" "$actual"
58-
assert_contains "$message_line" "$actual"
59-
assert_contains "$tests_summary" "$actual"
60-
assert_contains "$assertions_summary" "$actual"
61-
assert_general_error "$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file")"
39+
local test_file=./tests/acceptance/fixtures/test_bashunit_when_setup_with_failing_command.sh
40+
local fixture=$test_file
41+
42+
local header_line="Running $fixture"
43+
local error_line="✗ Error: Set up"
44+
local message_line=" Hook 'set_up' failed with exit code 1"
45+
local tests_summary="Tests: 1 failed, 1 total"
46+
local assertions_summary="Assertions: 0 failed, 0 total"
47+
48+
local actual_raw
49+
set +e
50+
actual_raw="$(./bashunit --no-parallel --detailed --env "$TEST_ENV_FILE" "$test_file")"
51+
set -e
52+
53+
local actual
54+
actual="$(printf "%s" "$actual_raw" | strip_ansi)"
55+
56+
assert_contains "$header_line" "$actual"
57+
assert_contains "$error_line" "$actual"
58+
assert_contains "$message_line" "$actual"
59+
assert_contains "$tests_summary" "$actual"
60+
assert_contains "$assertions_summary" "$actual"
61+
assert_general_error "$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file")"
6262
}
6363

6464
function test_bashunit_when_set_up_with_intermediate_failing_command() {
65-
local test_file=./tests/acceptance/fixtures/test_bashunit_when_setup_with_intermediate_failing_command.sh
66-
local fixture=$test_file
67-
68-
local header_line="Running $fixture"
69-
local error_line="✗ Error: Set up"
70-
local message_line=" Hook 'set_up' failed with exit code 1"
71-
local tests_summary="Tests: 1 failed, 1 total"
72-
local assertions_summary="Assertions: 0 failed, 0 total"
73-
74-
local actual_raw
75-
set +e
76-
actual_raw="$(./bashunit --no-parallel --detailed --env "$TEST_ENV_FILE" "$test_file")"
77-
set -e
78-
79-
local actual
80-
actual="$(printf "%s" "$actual_raw" | strip_ansi)"
81-
82-
assert_contains "$header_line" "$actual"
83-
assert_contains "$error_line" "$actual"
84-
assert_contains "$message_line" "$actual"
85-
assert_contains "$tests_summary" "$actual"
86-
assert_contains "$assertions_summary" "$actual"
87-
assert_general_error "$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file")"
65+
local test_file=./tests/acceptance/fixtures/test_bashunit_when_setup_with_intermediate_failing_command.sh
66+
local fixture=$test_file
67+
68+
local header_line="Running $fixture"
69+
local error_line="✗ Error: Set up"
70+
local message_line=" Hook 'set_up' failed with exit code 1"
71+
local tests_summary="Tests: 1 failed, 1 total"
72+
local assertions_summary="Assertions: 0 failed, 0 total"
73+
74+
local actual_raw
75+
set +e
76+
actual_raw="$(./bashunit --no-parallel --detailed --env "$TEST_ENV_FILE" "$test_file")"
77+
set -e
78+
79+
local actual
80+
actual="$(printf "%s" "$actual_raw" | strip_ansi)"
81+
82+
assert_contains "$header_line" "$actual"
83+
assert_contains "$error_line" "$actual"
84+
assert_contains "$message_line" "$actual"
85+
assert_contains "$tests_summary" "$actual"
86+
assert_contains "$assertions_summary" "$actual"
87+
assert_general_error "$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file")"
8888
}
8989

9090
# Issue #517: When set_up fails, remaining commands should not execute
9191
function test_bashunit_set_up_stops_on_first_failure() {
92-
local test_file=./tests/acceptance/fixtures/test_bashunit_setup_stops_on_failure.sh
93-
local marker_file="/tmp/bashunit_setup_marker_test"
92+
local test_file=./tests/acceptance/fixtures/test_bashunit_setup_stops_on_failure.sh
93+
local marker_file="/tmp/bashunit_setup_marker_test"
9494

95-
# Clean up any existing marker file
96-
rm -f "$marker_file"
95+
# Clean up any existing marker file
96+
rm -f "$marker_file"
9797

98-
set +e
99-
./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file" >/dev/null 2>&1
100-
set -e
98+
set +e
99+
./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file" >/dev/null 2>&1
100+
set -e
101101

102-
# The marker file should NOT exist because the touch command
103-
# should not have executed after the failing command
104-
assert_file_not_exists "$marker_file"
102+
# The marker file should NOT exist because the touch command
103+
# should not have executed after the failing command
104+
assert_file_not_exists "$marker_file"
105105

106-
# Clean up
107-
rm -f "$marker_file"
106+
# Clean up
107+
rm -f "$marker_file"
108+
}
109+
110+
# Issue #611: Sourcing a non-existent file in set_up should fail the test
111+
function test_bashunit_when_set_up_sources_nonexistent_file() {
112+
local test_file=./tests/acceptance/fixtures/test_bashunit_when_setup_sources_nonexistent_file.sh
113+
local fixture=$test_file
114+
115+
local error_line="✗ Error: Set up"
116+
local tests_summary="Tests: 1 failed, 1 total"
117+
local assertions_summary="Assertions: 0 failed, 0 total"
118+
119+
local actual_raw
120+
set +e
121+
actual_raw="$(./bashunit --no-parallel --detailed --env "$TEST_ENV_FILE" "$test_file")"
122+
set -e
123+
124+
local actual
125+
actual="$(printf "%s" "$actual_raw" | strip_ansi)"
126+
127+
assert_contains "$error_line" "$actual"
128+
assert_contains "$tests_summary" "$actual"
129+
assert_contains "$assertions_summary" "$actual"
130+
assert_general_error "$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file")"
108131
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env bash
2+
3+
function set_up() {
4+
# shellcheck disable=SC1091
5+
source ./this_file_does_not_exist.sh
6+
}
7+
8+
function test_dummy() {
9+
assert_same "foo" "foo"
10+
}

0 commit comments

Comments
 (0)