Skip to content

Commit d6f8c59

Browse files
authored
Merge pull request #588 from TypedDevs/feat/improve-parallel-runner
Improve parallel runner
2 parents b60c66f + c622075 commit d6f8c59

36 files changed

+2961
-2527
lines changed

CHANGELOG.md

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

55
### Changed
66
- Lower minimum Bash version requirement from 3.2 to 3.0
7+
- Improved parallel test execution performance (30-40% faster on large test suites)
8+
- Test functions now run in parallel within each file when using `--parallel` flag
9+
- Better load balancing through internal test file reorganization
10+
- Optimized result aggregation eliminates subprocess overhead
711

812
### Added
913
- Add Claude Code configuration with custom skills, agents, and rules
@@ -21,6 +25,10 @@
2125
- Shows only the final test summary
2226
- Useful for CI/CD pipelines or log-restricted environments
2327
- Can also be set via `BASHUNIT_NO_PROGRESS=true` environment variable
28+
- Support for `# bashunit: no-parallel-tests` directive in test files
29+
- Allows test files to opt out of test-level parallelism
30+
- Useful for tests with shared state or race conditions
31+
- Add as the second line in test files (after shebang)
2432

2533
### Fixed
2634
- Data providers now work without the `function` keyword on test functions (Issue #586)
@@ -29,6 +37,10 @@
2937
- Fixes regex in `bashunit::helper::get_provider_data()` to make the `function` keyword optional
3038
- Self-test `tests/acceptance/install_test.sh` now passes when no network tools are available (Issue #582)
3139
- Tests skip gracefully with `BASHUNIT_NO_NETWORK=true` or in sandboxed environments
40+
- Parallel test execution now works correctly in strict mode environments (bash -e -o pipefail)
41+
- Fixed arithmetic operations in result aggregation to prevent exit code 1 when values are zero
42+
- Fixed spinner cleanup to handle already-terminated processes gracefully
43+
- Ensures proper exit codes in CI environments like GitHub Actions Windows runners
3244

3345
## [0.32.0](https://github.com/TypedDevs/bashunit/compare/0.31.0...0.32.0) - 2026-01-12
3446

docs/command-line.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,12 +188,32 @@ The line number syntax finds the test function that contains the specified line.
188188
189189
Run tests in parallel or sequentially. Sequential is the default.
190190

191+
In parallel mode, both test files and individual test functions run concurrently
192+
for maximum performance.
193+
191194
::: warning
192195
Parallel mode is supported on **macOS**, **Ubuntu**, and **Windows**. On other
193196
systems (like Alpine Linux) the option is automatically disabled due to
194197
inconsistent results.
195198
:::
196199

200+
::: tip Opt-out of test-level parallelism
201+
If a test file has shared state or race conditions, you can disable test-level
202+
parallelism by adding this directive as the second line:
203+
204+
```bash
205+
#!/usr/bin/env bash
206+
# bashunit: no-parallel-tests
207+
208+
function test_with_shared_state() {
209+
# This test will not run in parallel with others in this file
210+
}
211+
```
212+
213+
The file will still run in parallel with other files, but tests within it will
214+
run sequentially.
215+
:::
216+
197217
### Output Style
198218

199219
> `bashunit test -s|--simple`

release.sh

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -375,10 +375,28 @@ function release::sandbox::create() {
375375
release::log_info "Creating sandbox at: $SANDBOX_DIR"
376376

377377
# Copy repo content excluding .git, .release-state, node_modules
378-
# Using cp + rm for portability (rsync not available on all systems)
379-
cp -r . "$SANDBOX_DIR/"
380-
rm -rf "$SANDBOX_DIR/.git" "$SANDBOX_DIR/.release-state" "$SANDBOX_DIR/node_modules"
381-
release::log_verbose "Copied project files to sandbox"
378+
# Try tar pipe first (faster), fallback to cp + rm for portability
379+
# Disable errexit temporarily to allow tar fallback in strict mode
380+
local tar_status=0
381+
set +e
382+
tar --exclude='.git' \
383+
--exclude='.release-state' \
384+
--exclude='node_modules' \
385+
--exclude='.tasks' \
386+
--exclude='tmp' \
387+
-cf - . 2>/dev/null | tar -xf - -C "$SANDBOX_DIR" 2>/dev/null
388+
tar_status=$?
389+
set -e
390+
391+
if [ "$tar_status" -eq 0 ]; then
392+
release::log_verbose "Copied project files to sandbox (tar)"
393+
else
394+
# Fallback: traditional cp + rm for maximum portability
395+
cp -r . "$SANDBOX_DIR/"
396+
rm -rf "$SANDBOX_DIR/.git" "$SANDBOX_DIR/.release-state" \
397+
"$SANDBOX_DIR/node_modules" "$SANDBOX_DIR/.tasks" "$SANDBOX_DIR/tmp"
398+
release::log_verbose "Copied project files to sandbox (cp)"
399+
fi
382400
}
383401

384402
function release::sandbox::setup_git() {

src/parallel.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ function bashunit::parallel::aggregate_test_results() {
2828
local result_file=""
2929
for result_file in "${result_files[@]+"${result_files[@]}"}"; do
3030
local result_line
31-
result_line=$(tail -n 1 <"$result_file")
31+
result_line=$(<"$result_file")
32+
result_line="${result_line##*$'\n'}"
3233

3334
local failed="${result_line##*##ASSERTIONS_FAILED=}"
3435
failed="${failed%%##*}"

src/runner.sh

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ function bashunit::runner::load_test_files() {
102102
local spinner_pid=$!
103103
bashunit::parallel::aggregate_test_results "$TEMP_DIR_PARALLEL_TEST_SUITE"
104104
# Kill the spinner once the aggregation finishes
105-
disown "$spinner_pid" && kill "$spinner_pid" &>/dev/null
105+
disown "$spinner_pid" 2>/dev/null || true
106+
kill "$spinner_pid" 2>/dev/null || true
106107
printf "\r \r" # Clear the spinner output
107108
local script_id
108109
for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do
@@ -342,6 +343,12 @@ function bashunit::runner::call_test_functions() {
342343

343344
bashunit::helper::check_duplicate_functions "$script" || true
344345

346+
# Check if test file opts out of test-level parallelism
347+
local allow_test_parallel=true
348+
if grep -q "^# bashunit: no-parallel-tests" "$script" 2>/dev/null; then
349+
allow_test_parallel=false
350+
fi
351+
345352
local -a provider_data=()
346353
local provider_data_count=0
347354
local -a parsed_data=()
@@ -362,8 +369,12 @@ function bashunit::runner::call_test_functions() {
362369
done <<<"$(bashunit::helper::get_provider_data "$fn_name" "$script")"
363370

364371
# No data provider found
365-
if [[ "$provider_data_count" -eq 0 ]]; then
366-
bashunit::runner::run_test "$script" "$fn_name"
372+
if [ "$provider_data_count" -eq 0 ]; then
373+
if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then
374+
bashunit::runner::run_test "$script" "$fn_name" &
375+
else
376+
bashunit::runner::run_test "$script" "$fn_name"
377+
fi
367378
unset -v fn_name
368379
continue
369380
fi
@@ -375,14 +386,23 @@ function bashunit::runner::call_test_functions() {
375386
parsed_data_count=0
376387
local line
377388
while IFS= read -r line; do
378-
[[ -z "$line" ]] && continue
389+
[ -z "$line" ] && continue
379390
parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")"
380391
parsed_data_count=$((parsed_data_count + 1))
381392
done <<<"$(bashunit::runner::parse_data_provider_args "$data")"
382-
bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"}
393+
if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then
394+
bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"} &
395+
else
396+
bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"}
397+
fi
383398
done
384399
unset -v fn_name
385400
done
401+
402+
# Wait for all parallel tests within this file to complete
403+
if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then
404+
wait
405+
fi
386406
}
387407

388408
function bashunit::runner::call_bench_functions() {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
function set_up() {
5+
export BASHUNIT_SIMPLE_OUTPUT=false
6+
}
7+
8+
# Test basic assert subcommand functionality
9+
function test_bashunit_assert_subcommand_equals() {
10+
./bashunit assert equals "foo" "foo"
11+
assert_successful_code
12+
}
13+
14+
function test_bashunit_assert_subcommand_same() {
15+
./bashunit assert same "1" "1"
16+
assert_successful_code
17+
}
18+
19+
function test_bashunit_assert_subcommand_contains() {
20+
./bashunit assert contains "world" "hello world"
21+
assert_successful_code
22+
}
23+
24+
function test_bashunit_assert_subcommand_without_prefix() {
25+
./bashunit assert equals "bar" "bar"
26+
assert_successful_code
27+
}
28+
29+
# Test help functionality
30+
function test_bashunit_assert_subcommand_help_short() {
31+
local output
32+
output=$(./bashunit assert -h 2>&1)
33+
34+
assert_contains "Usage: bashunit assert" "$output"
35+
assert_contains "Run standalone assertion" "$output"
36+
assert_successful_code "$(./bashunit assert -h)"
37+
}
38+
39+
function test_bashunit_assert_subcommand_help_long() {
40+
local output
41+
output=$(./bashunit assert --help 2>&1)
42+
43+
assert_contains "Usage: bashunit assert" "$output"
44+
assert_contains "Single assertion:" "$output"
45+
assert_successful_code "$(./bashunit assert --help)"
46+
}
47+
48+
# Test assert subcommand is in main help
49+
function test_bashunit_main_help_includes_assert() {
50+
local output
51+
output=$(./bashunit --help 2>&1)
52+
53+
assert_contains "assert <fn> <args>" "$output"
54+
}
55+
56+
function test_multi_assert_help_shows_multi_syntax() {
57+
local output
58+
output=$(./bashunit assert --help 2>&1)
59+
assert_contains "Multiple assertions on command output" "$output"
60+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
function set_up() {
5+
export BASHUNIT_SIMPLE_OUTPUT=false
6+
}
7+
8+
# Test error cases
9+
function test_bashunit_assert_subcommand_no_function() {
10+
local output
11+
local exit_code
12+
output=$(./bashunit assert 2>&1) && exit_code=$? || exit_code=$?
13+
14+
assert_contains "Error: Assert function name or command is required" "$output"
15+
assert_general_error "" "" "$exit_code"
16+
}
17+
18+
function test_bashunit_assert_subcommand_non_existing_function() {
19+
local exit_code
20+
./bashunit assert non_existing_function 2>&1 && exit_code=$? || exit_code=$?
21+
assert_command_not_found "" "" "$exit_code"
22+
}
23+
24+
function test_bashunit_assert_subcommand_failure() {
25+
local exit_code
26+
./bashunit --no-parallel assert equals "foo" "bar" 2>&1 && exit_code=$? || exit_code=$?
27+
assert_general_error "" "" "$exit_code"
28+
}
29+
30+
# Test backward compatibility with --assert option
31+
function test_bashunit_old_assert_option_still_works() {
32+
local output
33+
output=$(./bashunit -a equals "foo" "foo" 2>&1)
34+
assert_successful_code "$output"
35+
}
36+
37+
function test_bashunit_old_assert_option_long_form() {
38+
local output
39+
output=$(./bashunit --assert equals "foo" "foo" 2>&1)
40+
assert_successful_code "$output"
41+
}
42+
43+
# Test deprecation notice in help
44+
function test_bashunit_test_help_shows_deprecation() {
45+
local output
46+
output=$(./bashunit test --help 2>&1)
47+
48+
assert_contains "deprecated" "$output"
49+
assert_contains "bashunit assert" "$output"
50+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
function set_up() {
5+
export BASHUNIT_SIMPLE_OUTPUT=false
6+
}
7+
8+
# Test multi-assertion mode
9+
function test_multi_assert_exit_code_and_contains() {
10+
./bashunit assert "echo 'some error' && exit 1" exit_code "1" contains "some error" 2>&1
11+
assert_successful_code
12+
}
13+
14+
function test_multi_assert_exit_code_zero_and_output() {
15+
./bashunit assert "echo 'success message'" exit_code "0" contains "success" 2>&1
16+
assert_successful_code
17+
}
18+
19+
function test_multi_assert_multiple_output_assertions() {
20+
./bashunit assert "echo 'hello world'" exit_code "0" contains "hello" contains "world" 2>&1
21+
assert_successful_code
22+
}
23+
24+
function test_multi_assert_fails_on_exit_code_mismatch() {
25+
local exit_code
26+
./bashunit assert "echo 'output' && exit 1" exit_code "0" 2>&1 && exit_code=$? || exit_code=$?
27+
assert_general_error "" "" "$exit_code"
28+
}
29+
30+
function test_multi_assert_fails_on_contains_mismatch() {
31+
local exit_code
32+
./bashunit assert "echo 'actual output'" exit_code "0" contains "expected" 2>&1 && exit_code=$? || exit_code=$?
33+
assert_general_error "" "" "$exit_code"
34+
}
35+
36+
function test_multi_assert_missing_assertion_arg() {
37+
local exit_code
38+
local output
39+
output=$(./bashunit assert "echo test" exit_code 2>&1) && exit_code=$? || exit_code=$?
40+
assert_contains "Missing argument for assertion" "$output"
41+
assert_general_error "" "" "$exit_code"
42+
}

0 commit comments

Comments
 (0)