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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

### Added
- `--shard <index>/<total>` to run a deterministic, round-robin subset of the test files so a suite can be split across parallel CI runners (union of all shards is the full suite, no overlap); validates input and composes with `--parallel` (#739)
- `--random-order` flag with `--seed <n>` / `BASHUNIT_SEED` to randomize test file and function execution order (surfaces inter-test coupling); the seed is printed for replay and the shuffle is reproducible and works with `--parallel`. Disabled by default (#738)
- `--retry <n>` flag and `BASHUNIT_RETRY` env var to re-run a failed test up to N extra times (flaky-test mitigation); passes if any attempt passes, annotates tests that only passed on retry, and works with `--parallel` and `--stop-on-failure`. Disabled by default (#737)
- `--report-tap <file>` writes a TAP version 13 report to a file (complements the streaming `--output tap`) (#740)
Expand Down
27 changes: 27 additions & 0 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ bashunit test tests/ --parallel --simple
| `--retry <n>` | Re-run a failed test up to N extra times |
| `--random-order` | Randomize test execution order |
| `--seed <n>` | Seed for `--random-order` (reproducible shuffle) |
| `--shard <i>/<n>` | Run shard i of n (split suite across runners) |
| `--show-skipped` | Show skipped tests summary at end |
| `--show-incomplete` | Show incomplete tests summary at end |
| `-vvv, --verbose` | Show execution details |
Expand Down Expand Up @@ -520,6 +521,32 @@ bashunit test tests/ --random-order --seed 12345
It can also be set via the `BASHUNIT_SEED` environment variable (see
[configuration](/configuration#random-order)).

### Shard

> `bashunit test --shard <index>/<total>`

Run a deterministic subset (shard) of the test files, so a large suite can be
split across parallel CI machines. `index` is 1-based (`1 <= index <= total`);
invalid input exits non-zero with an error. Files are assigned round-robin, so
the union of all shards is the full suite with no overlap. Composes with
`--parallel` (shard first on each runner, then parallelize the slice).

::: code-group
```bash [Split across 4 runners]
bashunit test tests/ --shard 1/4
bashunit test tests/ --shard 2/4
bashunit test tests/ --shard 3/4
bashunit test tests/ --shard 4/4
```
```yaml [GitHub Actions matrix]
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: ./bashunit tests/ --shard ${{ matrix.shard }}/4
```
:::

### No Progress

> `bashunit test --no-progress`
Expand Down
1 change: 1 addition & 0 deletions src/console_header.sh
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ Options:
--retry <n> Re-run a failed test up to N extra times (0 = off)
--random-order Randomize test execution order
--seed <n> Seed for --random-order (reproducible shuffle)
--shard <i>/<n> Run shard i of n (split the suite across runners)
-vvv, --verbose Show execution details
--debug [file] Enable shell debug mode
--no-output Suppress all output
Expand Down
17 changes: 17 additions & 0 deletions src/env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ _BASHUNIT_DEFAULT_RETRY="0"
_BASHUNIT_DEFAULT_RANDOM_ORDER="false"
# Seed for --random-order (empty = generate one and print it)
_BASHUNIT_DEFAULT_SEED=""
# Shard <index>/<total> to split the suite across runners (empty = disabled)
_BASHUNIT_DEFAULT_SHARD_INDEX=""
_BASHUNIT_DEFAULT_SHARD_TOTAL=""

: "${BASHUNIT_PARALLEL_RUN:=${PARALLEL_RUN:=$_BASHUNIT_DEFAULT_PARALLEL_RUN}}"
: "${BASHUNIT_PARALLEL_JOBS:=0}"
Expand Down Expand Up @@ -158,6 +161,8 @@ _BASHUNIT_DEFAULT_SEED=""
# up unrelated environment values.
: "${BASHUNIT_RANDOM_ORDER:=$_BASHUNIT_DEFAULT_RANDOM_ORDER}"
: "${BASHUNIT_SEED:=$_BASHUNIT_DEFAULT_SEED}"
: "${BASHUNIT_SHARD_INDEX:=$_BASHUNIT_DEFAULT_SHARD_INDEX}"
: "${BASHUNIT_SHARD_TOTAL:=$_BASHUNIT_DEFAULT_SHARD_TOTAL}"
# Support NO_COLOR standard (https://no-color.org)
if [ -n "${NO_COLOR:-}" ]; then
BASHUNIT_NO_COLOR="true"
Expand Down Expand Up @@ -212,6 +217,18 @@ function bashunit::env::seed() {
printf '%s' "${BASHUNIT_SEED:-}"
}

function bashunit::env::is_shard_enabled() {
[ -n "${BASHUNIT_SHARD_INDEX:-}" ] && [ -n "${BASHUNIT_SHARD_TOTAL:-}" ]
}

function bashunit::env::shard_index() {
printf '%s' "${BASHUNIT_SHARD_INDEX:-}"
}

function bashunit::env::shard_total() {
printf '%s' "${BASHUNIT_SHARD_TOTAL:-}"
}

function bashunit::env::is_show_header_enabled() {
[ "$BASHUNIT_SHOW_HEADER" = "true" ]
}
Expand Down
55 changes: 55 additions & 0 deletions src/main.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
#!/usr/bin/env bash

##
# Validates a `--shard <index>/<total>` spec and exports the parts, or prints an
# error and exits non-zero. Requires numeric index/total with 1 <= index <= total.
##
function bashunit::main::set_shard_or_exit() {
local spec="${1:-}"
local index total
case "$spec" in
*/*)
index="${spec%%/*}"
total="${spec##*/}"
;;
*)
index=""
total=""
;;
esac
case "$index" in '' | *[!0-9]*) index="" ;; esac
case "$total" in '' | *[!0-9]*) total="" ;; esac

if [ -z "$index" ] || [ -z "$total" ] ||
[ "$total" -lt 1 ] || [ "$index" -lt 1 ] || [ "$index" -gt "$total" ]; then
printf "%sError: --shard must be <index>/<total> with 1 <= index <= total (e.g. 1/4).%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" >&2
exit 1
fi

export BASHUNIT_SHARD_INDEX="$index"
export BASHUNIT_SHARD_TOTAL="$total"
}

#############################
# Subcommand: test
#############################
Expand Down Expand Up @@ -89,6 +120,10 @@ function bashunit::main::cmd_test() {
export BASHUNIT_SEED="$2"
shift
;;
--shard)
bashunit::main::set_shard_or_exit "$2"
shift
;;
-w | --watch)
export BASHUNIT_WATCH_MODE=true
;;
Expand Down Expand Up @@ -682,6 +717,26 @@ function bashunit::main::exec_tests() {
exit 1
fi

# Split the suite across runners: keep the files whose position matches this
# shard (round-robin), so all shards together cover the whole suite with no
# overlap. An empty shard (more shards than files) is valid and runs nothing.
if bashunit::env::is_shard_enabled; then
local _shard_index _shard_total
_shard_index=$(bashunit::env::shard_index)
_shard_total=$(bashunit::env::shard_total)
local -a _sharded=()
local _i=0
while [ "$_i" -lt "$test_files_count" ]; do
if [ "$((_i % _shard_total))" -eq "$((_shard_index - 1))" ]; then
_sharded[${#_sharded[@]}]="${test_files[_i]}"
fi
_i=$((_i + 1))
done
test_files=("${_sharded[@]+"${_sharded[@]}"}")
test_files_count=${#test_files[@]}
bashunit::internal_log "shard" "index:$_shard_index" "total:$_shard_total" "files:$test_files_count"
fi

# Trap SIGINT (Ctrl-C) and call the cleanup function
trap 'bashunit::main::cleanup' SIGINT
trap '[ $? -eq $EXIT_CODE_STOP_ON_FAILURE ] && bashunit::main::handle_stop_on_failure_sync' EXIT
Expand Down
58 changes: 58 additions & 0 deletions tests/acceptance/bashunit_shard_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash

function set_up_before_script() {
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
D="tests/acceptance/fixtures"
FILES="$D/test_shard_a.sh $D/test_shard_b.sh $D/test_shard_c.sh $D/test_shard_d.sh"
}

# Runs the four shard fixtures through a shard and prints which ran, sorted.
function shard_run() {
local order_file
order_file="$(mktemp)"
# shellcheck disable=SC2086
BASHUNIT_TEST_ORDER_FILE="$order_file" \
./bashunit --no-parallel --env "$TEST_ENV_FILE" "$@" $FILES >/dev/null 2>&1
sort "$order_file" | tr '\n' ' ' | sed 's/ *$//'
rm -f "$order_file"
}

function test_shard_1_of_2_runs_its_slice() {
assert_same "a c" "$(shard_run --shard 1/2)"
}

function test_shard_2_of_2_runs_its_slice() {
assert_same "b d" "$(shard_run --shard 2/2)"
}

function test_shards_are_disjoint_and_cover_the_whole_suite() {
local union
union="$(printf '%s %s' "$(shard_run --shard 1/2)" "$(shard_run --shard 2/2)" |
tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ *$//')"

assert_same "a b c d" "$union"
}

function test_shard_rejects_index_greater_than_total() {
# shellcheck disable=SC2086
assert_general_error \
"$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --shard 3/2 $FILES 2>&1)"
}

function test_shard_rejects_non_numeric_input() {
# shellcheck disable=SC2086
assert_general_error \
"$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --shard x/2 $FILES 2>&1)"
}

function test_shard_composes_with_parallel() {
local order_file count
order_file="$(mktemp)"
# shellcheck disable=SC2086
BASHUNIT_TEST_ORDER_FILE="$order_file" \
./bashunit --parallel --env "$TEST_ENV_FILE" --shard 1/2 $FILES >/dev/null 2>&1
count=$(tr -cd 'a-d' <"$order_file" | wc -c | tr -d ' ')
rm -f "$order_file"

assert_same "2" "$count"
}
6 changes: 6 additions & 0 deletions tests/acceptance/fixtures/test_shard_a.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash

function test_shard_a() {
printf '%s\n' a >>"${BASHUNIT_TEST_ORDER_FILE:?order file required}"
assert_same 1 1
}
6 changes: 6 additions & 0 deletions tests/acceptance/fixtures/test_shard_b.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash

function test_shard_b() {
printf '%s\n' b >>"${BASHUNIT_TEST_ORDER_FILE:?order file required}"
assert_same 1 1
}
6 changes: 6 additions & 0 deletions tests/acceptance/fixtures/test_shard_c.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash

function test_shard_c() {
printf '%s\n' c >>"${BASHUNIT_TEST_ORDER_FILE:?order file required}"
assert_same 1 1
}
6 changes: 6 additions & 0 deletions tests/acceptance/fixtures/test_shard_d.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash

function test_shard_d() {
printf '%s\n' d >>"${BASHUNIT_TEST_ORDER_FILE:?order file required}"
assert_same 1 1
}
Loading