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
3 changes: 2 additions & 1 deletion .claude/rules/architecture-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
| `runner/parallel.sh` | job-slot waiting (`wait -n` or poll), running-job count, spinner |
| `runner/hooks.sh` | set_up/tear_down (test + script scope), hook failure records, mock clearing, EXIT cleanup |
| `runner/provider.sh` | `@data_provider` argument parsing |
| `runner/exec.sh` | `run_test`, the capture-subshell body, retry, timeout watchdog, per-file dispatch |
| `runner/exec.sh` | `run_test`, the capture-subshell body, retry, timeout watchdog, per-file dispatch, `order_functions_for_script` (the run order a seed produces) |
| `runner/list.sh` | `--list`/`--dry-run`: emits selected tests instead of running them; hooks in after all filtering, before any hook |
| `runner/discovery.sh` | `load_test_files` (the per-file loop), `functions_for_script` |
| `runner/bench.sh` | benchmark file loop and bench function dispatch |
| `helper/index.sh` | aggregator only — sources the `src/helper/` module below |
Expand Down
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ BASHUNIT_NO_DIFF= # Default: false (disable unified diff on mu
BASHUNIT_PARALLEL_RUN= # Default: false
BASHUNIT_STOP_ON_FAILURE= # Default: false (stop suite on first failure)
BASHUNIT_RERUN_FAILED= # Default: false (replay only last run's failing tests)
BASHUNIT_LIST_TESTS= # Default: false (print the tests that would run, run none)
BASHUNIT_LIST_FORMAT= # Default: text (--list rendering: text or json)
BASHUNIT_STOP_ON_ASSERTION_FAILURE= # Default: true (stop test on first assertion fail)
BASHUNIT_STRICT_MODE= # Default: false (enable set -euo pipefail)
BASHUNIT_LOGIN_SHELL= # Default: false (source login shell profiles)
Expand Down
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
- `--list` (alias `--dry-run`) prints the tests a run would execute, without running them; `--list-format json` emits file, function, name, line and tags. Honours every selection flag, including `--shard` and `--random-order --seed` ordering (#1007)
- The coverage engine in use is reported by `--verbose`, and an explicit `BASHUNIT_COVERAGE_ENGINE=xtrace` that the running Bash cannot honour now warns instead of being silently ignored (#1005)

### Changed
Expand Down
2 changes: 1 addition & 1 deletion adrs/adr-011-source-layout-and-build-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Seventeen, in load order. The order is the dependency layering: leaves first.
| 11 | `assert/` | 11 | 2326 | every assertion |
| 12 | `doubles/` | 4 | 505 | spies and mocks — **sourced by `assert/index.sh`, not the entrypoint** |
| 13 | `reports/` | 7 | 465 | JUnit, TAP, JSON, GHA and HTML writers |
| 14 | `runner/` | 11 | 2190 | the file loop, per-test execution, retry, result parsing |
| 14 | `runner/` | 12 | 2434 | the file loop, per-test execution, retry, result parsing, `--list` |
| 15 | `benchmark/` | 4 | 219 | the bench implementation (`runner/bench.sh` is its loop) |
| 16 | `learn/` | 14 | 1296 | the interactive tutorial (9 of those files are `learn/lessons/`) |
| 17 | `main/` | 8 | 1473 | flag parsing per subcommand and the run lifecycle |
Expand Down
2 changes: 2 additions & 0 deletions completions/_bashunit
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ _bashunit() {
'--seed[Seed for random order]:seed:' \
'--shard[Run shard i of n]:shard:' \
'--rerun-failed[Replay only the tests that failed on the last run]' \
'(--list --dry-run)'{--list,--dry-run}'[Print the tests that would run, then exit]' \
'--list-format[Rendering for --list]:format:(text json)' \
'--snapshot-update[Rewrite existing snapshots from the actual value]' \
'--no-snapshot-create[Fail instead of recording a missing snapshot]' \
'--snapshot-report-unused[List snapshot files no test resolved]' \
Expand Down
8 changes: 6 additions & 2 deletions completions/bashunit.bash
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ _BASHUNIT_COMPLETIONS_DOC_OPTS="--custom -e --env --boot -h --help"

_BASHUNIT_COMPLETIONS_TEST_OPTS="--assert --boot --coverage --coverage-exclude \
--coverage-min --coverage-paths --coverage-report --coverage-report-html \
--debug --detailed --env --exclude-tag --fail-on-risky --failures-only \
--filter --help --jobs --log-gha --log-junit --login --no-color \
--debug --detailed --dry-run --env --exclude-tag --fail-on-risky --failures-only \
--filter --help --jobs --list --list-format --log-gha --log-junit --login --no-color \
--no-coverage-report --no-output --no-output-on-failure --no-parallel \
--no-progress --no-snapshot-create --output --parallel --profile \
--random-order --report-html \
Expand Down Expand Up @@ -76,6 +76,10 @@ _bashunit_completions() {
COMPREPLY=($(compgen -f -- "$cur"))
return 0
;;
--list-format)
COMPREPLY=($(compgen -W "text json" -- "$cur"))
return 0
;;
-f | --filter | --tag | --exclude-tag | --retry | --seed | --shard | --test-timeout)
return 0
;;
Expand Down
69 changes: 69 additions & 0 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ bashunit test tests/ --parallel --simple
| `--seed <n>` | Seed for `--random-order` (reproducible shuffle) |
| `--shard <i>/<n>` | Run shard i of n (split suite across runners) |
| `--rerun-failed` | Replay only the tests that failed on the last run |
| `--list`, `--dry-run` | Print the tests that would run, then exit |
| `--list-format <fmt>` | Rendering for `--list`: `text` (default) or `json` |
| `--snapshot-update` | Rewrite existing snapshots from the actual value |
| `--no-snapshot-create` | Fail on a missing snapshot instead of recording it |
| `--snapshot-report-unused` | List snapshot files no test resolved (deletes nothing) |
Expand Down Expand Up @@ -647,6 +649,73 @@ locally, commit the file, and let CI run with this flag.
The two snapshot flags are opposites and pair up: `--snapshot-update` records
deliberately, `--no-snapshot-create` forbids recording by accident.

### List

> `bashunit test --list` · `bashunit test --dry-run`

Print the tests that *would* run, then exit without running any of them.
`--dry-run` is an alias, for anyone arriving from shellspec.

```bash
./bashunit --list tests/
# tests/unit/assert_test.sh::test_assert_equals
# tests/unit/assert_test.sh::test_assert_contains
# ...
# 412 tests
```

Test ids go to **stdout**, one `path::function` per line; the count goes to
**stderr**, so the list pipes cleanly into `grep`, `fzf` or a CI matrix.

Every selection mechanism applies exactly as it would in a real run —
`--filter`, `--tag`, `--exclude-tag`, `--shard`, `--rerun-failed`,
`--random-order --seed`, and `file::fn` / `file:LINE`. That makes it the way to
answer questions that previously needed a full run per answer:

```bash
# Are the shards balanced?
for i in 1 2 3 4; do
printf '%s: ' "$i"; ./bashunit --list --shard "$i/4" tests/ | wc -l
done

# Which tests does this filter actually select?
./bashunit --list --filter "snapshot" tests/

# What order will seed 42 use?
./bashunit --list --random-order --seed 42 tests/
```

An empty selection prints nothing and exits **0** — this is a query, not a run,
so a filter matching nothing is an empty answer rather than the "No tests found"
error a real run reports.

Test files are still *sourced* (that is how their functions are discovered), but
no test body and no lifecycle hook runs, and no report file is written.

A test using a `@data_provider` is listed **once**, by function: the id is the
thing you can pass back to `--filter`, while the number of executions it expands
to is a property of the run.

#### JSON output

> `bashunit test --list --list-format json`

```bash
./bashunit --list --list-format json tests/ | jq '.tests[] | select(.tags[]? == "slow")'
```

```json
{
"count": 2,
"tests": [
{ "file": "tests/unit/example_test.sh", "function": "test_slow_path",
"name": "Slow path", "line": 12, "tags": ["slow"] }
]
}
```

An unsupported format is rejected rather than silently falling back to `text`.

### Rerun failed

> `bashunit test --rerun-failed`
Expand Down
12 changes: 12 additions & 0 deletions src/config/env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@ _BASHUNIT_DEFAULT_SHARD_INDEX=""
_BASHUNIT_DEFAULT_SHARD_TOTAL=""
# Replay only the tests recorded as failing by the previous run
_BASHUNIT_DEFAULT_RERUN_FAILED="false"
# Print the tests that would run and exit, without running any of them
_BASHUNIT_DEFAULT_LIST_TESTS="false"
# Rendering for --list: text (one id per line) or json
_BASHUNIT_DEFAULT_LIST_FORMAT="text"
# Rewrite existing snapshots from the actual value instead of comparing
_BASHUNIT_DEFAULT_SNAPSHOT_UPDATE="false"
# Record a snapshot the first time it is asserted (false = a missing one fails)
Expand Down Expand Up @@ -301,6 +305,10 @@ _BASHUNIT_DEFAULT_SNAPSHOT_REPORT_UNUSED="false"
# lives here rather than inline in rerun.sh so every BASHUNIT_* default has one
# home; bashunit::rerun::is_enabled keeps its :- guard for callers that unset it.
: "${BASHUNIT_RERUN_FAILED:=$_BASHUNIT_DEFAULT_RERUN_FAILED}"
# No bare LIST/LIST_FORMAT aliases: `LIST` is far too generic a name to let the
# environment turn a real run into a no-op query.
: "${BASHUNIT_LIST_TESTS:=$_BASHUNIT_DEFAULT_LIST_TESTS}"
: "${BASHUNIT_LIST_FORMAT:=$_BASHUNIT_DEFAULT_LIST_FORMAT}"
# No bare SNAPSHOT_UPDATE alias either: rewriting files on disk is the last
# setting that should be reachable by a generic name from the environment.
: "${BASHUNIT_SNAPSHOT_UPDATE:=$_BASHUNIT_DEFAULT_SNAPSHOT_UPDATE}"
Expand Down Expand Up @@ -565,6 +573,10 @@ function bashunit::env::is_snapshot_update_enabled() {
[ "$BASHUNIT_SNAPSHOT_UPDATE" = "true" ]
}

function bashunit::env::is_list_enabled() {
[ "$BASHUNIT_LIST_TESTS" = "true" ]
}

function bashunit::env::is_fail_on_risky_enabled() {
[ "$BASHUNIT_FAIL_ON_RISKY" = "true" ]
}
Expand Down
2 changes: 2 additions & 0 deletions src/console/header.sh
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ Options:
--seed <n> Seed for --random-order (reproducible shuffle)
--shard <i>/<n> Run shard i of n (split the suite across runners)
--rerun-failed Replay only the tests that failed on the last run (.bashunit/last-failed)
--list, --dry-run Print the tests that would run, then exit without running them
--list-format <fmt> Rendering for --list: text (default) or json
--snapshot-update Rewrite existing snapshots from the actual value (combine with --filter)
--no-snapshot-create Fail on a missing snapshot instead of recording it (for CI)
--snapshot-report-unused List snapshot files no test resolved (full runs only, deletes nothing)
Expand Down
17 changes: 15 additions & 2 deletions src/main/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ function bashunit::main::exec_tests() {
bashunit::parallel::init
fi

if bashunit::env::is_tap_output_enabled; then
# --list is a query: stdout must be nothing but test ids, so the banner and
# the seed line are suppressed and the run header never prints (#1007).
if bashunit::env::is_list_enabled; then
:
elif bashunit::env::is_tap_output_enabled; then
printf "TAP version 13\n"
else
bashunit::console_header::print_version_with_env "$filter" "${test_files[@]}"
Expand All @@ -79,7 +83,7 @@ function bashunit::main::exec_tests() {
BASHUNIT_SEED=$RANDOM
export -n BASHUNIT_SEED
fi
if ! bashunit::env::is_tap_output_enabled; then
if ! bashunit::env::is_tap_output_enabled && ! bashunit::env::is_list_enabled; then
bashunit::console_header::print_random_order_seed "$BASHUNIT_SEED"
fi
fi
Expand All @@ -100,6 +104,15 @@ function bashunit::main::exec_tests() {

bashunit::runner::load_test_files "$filter" "$tag_filter" "$exclude_tag_filter" "${test_files[@]}"

# Nothing ran, so there are no results to render, no reports to write and no
# rerun cache to update. An empty selection is a valid answer to a query, so
# this exits 0 where a real run would exit 1 with "No tests found".
if bashunit::env::is_list_enabled; then
bashunit::runner::list_render_summary
bashunit::env::cleanup_run_output_dir
exit 0
fi

if bashunit::parallel::is_enabled; then
wait
fi
Expand Down
9 changes: 9 additions & 0 deletions src/main/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ function bashunit::main::cmd_test() {
BASHUNIT_RERUN_FAILED=true
export -n BASHUNIT_RERUN_FAILED
;;
--list | --dry-run)
BASHUNIT_LIST_TESTS=true
export -n BASHUNIT_LIST_TESTS
;;
--list-format)
BASHUNIT_LIST_FORMAT="$2"
export -n BASHUNIT_LIST_FORMAT
shift
;;
--snapshot-update)
BASHUNIT_SNAPSHOT_UPDATE=true
export -n BASHUNIT_SNAPSHOT_UPDATE
Expand Down
11 changes: 11 additions & 0 deletions src/main/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,17 @@ function bashunit::main::validate_config_or_exit() {
exit 1
;;
esac

# Same shape as --output above: an unrecognised name would otherwise fall
# through to the default renderer and look like it worked.
case "${BASHUNIT_LIST_FORMAT:-}" in
text | json) ;;
*)
printf "%sError: unsupported list format '%s' for --list-format. Supported: text, json.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "${BASHUNIT_LIST_FORMAT}" "${_BASHUNIT_COLOR_DEFAULT}" >&2
exit 1
;;
esac
}

##
Expand Down
9 changes: 9 additions & 0 deletions src/runner/discovery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ function bashunit::runner::load_test_files() {
bashunit::runner::restore_workdir
continue
fi
# --list stops here: every selection step has been applied, and nothing
# below this point can run without producing side effects (#1007).
if bashunit::env::is_list_enabled; then
bashunit::runner::list_functions "$test_file" "$functions_for_script"
bashunit::runner::clean_script_test_functions "$_script_fns_to_clean"
bashunit::runner::clean_set_up_and_tear_down_after_script
bashunit::runner::restore_workdir
continue
fi
# Render header BEFORE set_up_before_script so user sees activity immediately
bashunit::runner::render_running_file_header "$test_file"
# Call hook directly (not with `if !`) to preserve errexit behavior inside the hook
Expand Down
66 changes: 45 additions & 21 deletions src/runner/exec.sh
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
#!/usr/bin/env bash

_BASHUNIT_RUNNER_ORDERED_FNS_OUT=""

##
# Runs the given test functions of a script (sequentially, or one background
# worker per test under --parallel).
# Arguments: $1 script path, $2 space-separated test function names, already
# filter/tag/rerun-filtered by load_test_files (never empty: the caller skips
# the file when no function survives filtering).
# Puts a file's already-filtered test functions in the order they will run,
# into _BASHUNIT_RUNNER_ORDERED_FNS_OUT.
#
# Under --random-order the seed is mixed with a stable per-file value (cksum of
# the path) so different files get different orders while staying reproducible
# for the resolved seed. Extracted from call_test_functions so `--list` reports
# the order a run would actually use instead of re-deriving it (#1007).
#
# Arguments: $1 script path, $2 space-separated test function names
##
function bashunit::runner::call_test_functions() {
function bashunit::runner::order_functions_for_script() {
local script="$1"
local cached_functions="${2:-}"
local fns="${2:-}"
local IFS=$' \t\n'
local -a functions_to_run=()
local functions_to_run_count=0

local _fn
for _fn in $cached_functions; do
[ -z "$_fn" ] && continue
functions_to_run[functions_to_run_count]="$_fn"
functions_to_run_count=$((functions_to_run_count + 1))
local -a ordered=()
local fn
for fn in $fns; do
[ -z "$fn" ] && continue
ordered[${#ordered[@]}]="$fn"
done

# Randomize function order within this file. The seed is mixed with a stable
# per-file value (cksum of the path) so different files get different orders
# while staying reproducible for the resolved seed.
if bashunit::env::is_random_order_enabled && [ "$functions_to_run_count" -gt 1 ]; then
if bashunit::env::is_random_order_enabled && [ "${#ordered[@]}" -gt 1 ]; then
local _base _crc _fn_seed
_base=$(bashunit::env::seed)
_crc=$(printf '%s' "$script" | cksum | cut -d' ' -f1)
Expand All @@ -33,11 +34,34 @@ function bashunit::runner::call_test_functions() {
local _sfn
while IFS= read -r _sfn; do
[ -n "$_sfn" ] && _shuffled_fns[${#_shuffled_fns[@]}]=$_sfn
done < <(printf '%s\n' "${functions_to_run[@]+"${functions_to_run[@]}"}" | bashunit::math::shuffle "$_fn_seed")
functions_to_run=("${_shuffled_fns[@]+"${_shuffled_fns[@]}"}")
functions_to_run_count=${#functions_to_run[@]}
done < <(printf '%s\n' "${ordered[@]+"${ordered[@]}"}" | bashunit::math::shuffle "$_fn_seed")
ordered=("${_shuffled_fns[@]+"${_shuffled_fns[@]}"}")
fi

_BASHUNIT_RUNNER_ORDERED_FNS_OUT="${ordered[*]+${ordered[*]}}"
}

##
# Runs the given test functions of a script (sequentially, or one background
# worker per test under --parallel).
# Arguments: $1 script path, $2 space-separated test function names, already
# filter/tag/rerun-filtered by load_test_files (never empty: the caller skips
# the file when no function survives filtering).
##
function bashunit::runner::call_test_functions() {
local script="$1"
local cached_functions="${2:-}"
local IFS=$' \t\n'
local -a functions_to_run=()
local functions_to_run_count=0

bashunit::runner::order_functions_for_script "$script" "$cached_functions"
local _ofn
for _ofn in $_BASHUNIT_RUNNER_ORDERED_FNS_OUT; do
functions_to_run[functions_to_run_count]="$_ofn"
functions_to_run_count=$((functions_to_run_count + 1))
done

if [ "$functions_to_run_count" -le 0 ]; then
return
fi
Expand Down
4 changes: 3 additions & 1 deletion src/runner/index.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
# for the reason recorded in adrs/adr-011-source-layout-and-build-pipeline.md.
#
# Sourced in dependency layers, leaves first:
# context · payload · diagnostics → parallel · hooks · result → provider · exec → discovery · bench
# context · payload · diagnostics → parallel · hooks · result → provider · exec
# → list → discovery · bench
source "$BASHUNIT_ROOT_DIR/src/runner/context.sh"
source "$BASHUNIT_ROOT_DIR/src/runner/payload.sh"
source "$BASHUNIT_ROOT_DIR/src/runner/diagnostics.sh"
Expand All @@ -13,5 +14,6 @@ source "$BASHUNIT_ROOT_DIR/src/runner/hooks.sh"
source "$BASHUNIT_ROOT_DIR/src/runner/result.sh"
source "$BASHUNIT_ROOT_DIR/src/runner/provider.sh"
source "$BASHUNIT_ROOT_DIR/src/runner/exec.sh"
source "$BASHUNIT_ROOT_DIR/src/runner/list.sh"
source "$BASHUNIT_ROOT_DIR/src/runner/discovery.sh"
source "$BASHUNIT_ROOT_DIR/src/runner/bench.sh"
Loading
Loading