From b6511190805c9f4782f9852ac97cdf80d12fefa0 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 9 Aug 2026 22:20:07 +0200 Subject: [PATCH 1/2] feat(cli): --exclude-filter to skip tests by name Selection by name was one-directional: --filter includes, with no way to exclude. Tags had both, names did not, so dropping a handful of tests meant tagging them first. Matching mirrors --filter (including the leading test_ strip), the flag is repeatable and OR'd, and exclusion wins when a name matches both. The value is read from BASHUNIT_EXCLUDE_FILTER inside get_functions_to_run rather than threaded through as an argument: the header count reaches that function from a subshell and the runner from the main shell, and passing it to only one of them would print a count the run then contradicts. Excluded tests are therefore never selected, not reported as skipped. Closes #1009 --- .env.example | 1 + CHANGELOG.md | 1 + completions/_bashunit | 1 + completions/bashunit.bash | 4 +- docs/command-line.md | 22 ++++++ src/config/env.sh | 5 ++ src/console/header.sh | 1 + src/helper/discovery.sh | 19 +++++ src/main/test.sh | 12 ++++ .../bashunit_exclude_filter_test.sh | 71 +++++++++++++++++++ .../fixtures/exclude_filter_fixture.sh | 13 ++++ tests/unit/helper/helper_test.sh | 65 +++++++++++++++++ 12 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 tests/acceptance/bashunit_exclude_filter_test.sh create mode 100644 tests/acceptance/fixtures/exclude_filter_fixture.sh diff --git a/.env.example b/.env.example index f030d4fb..59c4c2b2 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,7 @@ 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_EXCLUDE_FILTER= # Default: empty (skip tests whose name matches) 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d89f7a9..86915b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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) +- `--exclude-filter ` skips tests by name, the counterpart of `--exclude-tag`. Repeatable, OR'd, and wins over `--filter` (#1009) - `# @tags a b` above any top-level line applies those tags to every test in the file, unioned with per-function `# @tag` (#1008) - `--tag` accepts expressions: `'a&&b'` (AND) and `'!a'` (NOT), combinable as `'a&&!b'`. Repeated `--tag` flags keep OR semantics, and `--exclude-tag` still wins (#1008) - 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) diff --git a/completions/_bashunit b/completions/_bashunit index 8202c39f..734bc3cb 100644 --- a/completions/_bashunit +++ b/completions/_bashunit @@ -66,6 +66,7 @@ _bashunit() { '(-e --env --boot)'{-e,--env,--boot}'[Load a custom env/bootstrap file]:file:_files' \ '(-f --filter)'{-f,--filter}'[Only run tests matching the name]:name:' \ '--tag[Only run tests with matching @tag; supports a&&b and !a]:tag:' \ + '--exclude-filter[Skip tests whose name matches]:name:' \ '--exclude-tag[Skip tests with matching @tag]:tag:' \ '--log-junit[Write JUnit XML report]:file:_files' \ '--report-junit[Write JUnit XML report]:file:_files' \ diff --git a/completions/bashunit.bash b/completions/bashunit.bash index 2836bb4f..2cd01b3c 100644 --- a/completions/bashunit.bash +++ b/completions/bashunit.bash @@ -16,7 +16,7 @@ _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 --dry-run --env --exclude-tag --fail-on-risky --failures-only \ +--debug --detailed --dry-run --env --exclude-filter --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 \ @@ -80,7 +80,7 @@ _bashunit_completions() { COMPREPLY=($(compgen -W "text json" -- "$cur")) return 0 ;; - -f | --filter | --tag | --exclude-tag | --retry | --seed | --shard | --test-timeout) + -f | --filter | --exclude-filter | --tag | --exclude-tag | --retry | --seed | --shard | --test-timeout) return 0 ;; esac diff --git a/docs/command-line.md b/docs/command-line.md index 854cb87a..53598784 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -61,6 +61,7 @@ bashunit test tests/ --parallel --simple | `-a, --assert ` | Run a standalone assert function | | `-e, --env, --boot ` | Load custom env/bootstrap file (supports args) | | `-f, --filter ` | Only run tests matching name | +| `--exclude-filter ` | Skip tests whose name matches (repeatable) | | `--tag ` | Only run tests with matching `@tag`; supports `a&&b` and `!a` | | `--exclude-tag ` | Skip tests with matching `@tag` (repeatable) | | `--output ` | Output format (`tap` for TAP version 13) | @@ -140,6 +141,27 @@ bashunit test tests/ --filter "user_login" ``` ::: +### Exclude filter + +> `bashunit test --exclude-filter "name"` + +Skip tests whose name matches — the name-based counterpart of +[`--exclude-tag`](#tags), for when you want to drop a handful of tests without +tagging them first. + +```bash +bashunit test tests/ --exclude-filter "slow_network" +bashunit test tests/ --exclude-filter "slow" --exclude-filter "flaky" # OR +bashunit test tests/ --filter user --exclude-filter admin # user, not admin +``` + +Matching is identical to `--filter`, the flag is repeatable (a test is skipped +if it matches **any** value), and exclusion wins when a name matches both — the +same precedence `--exclude-tag` has over `--tag`. + +Excluded tests are **not** reported as skipped: they are never selected, so they +do not appear in the header count either. + ### Tags > `bashunit test --tag ` diff --git a/src/config/env.sh b/src/config/env.sh index 765362e0..6df279b9 100644 --- a/src/config/env.sh +++ b/src/config/env.sh @@ -255,6 +255,8 @@ _BASHUNIT_DEFAULT_SHARD_INDEX="" _BASHUNIT_DEFAULT_SHARD_TOTAL="" # Replay only the tests recorded as failing by the previous run _BASHUNIT_DEFAULT_RERUN_FAILED="false" +# Skip tests whose name matches (comma-separated; the counterpart of --filter) +_BASHUNIT_DEFAULT_EXCLUDE_FILTER="" # 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 @@ -307,6 +309,9 @@ _BASHUNIT_DEFAULT_SNAPSHOT_REPORT_UNUSED="false" : "${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. +# No bare EXCLUDE_FILTER alias: a generic name silently dropping tests from a +# run is exactly the kind of surprise the unprefixed forms caused (#866). +: "${BASHUNIT_EXCLUDE_FILTER:=$_BASHUNIT_DEFAULT_EXCLUDE_FILTER}" : "${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 diff --git a/src/console/header.sh b/src/console/header.sh index 5ab98a45..99d021ab 100644 --- a/src/console/header.sh +++ b/src/console/header.sh @@ -120,6 +120,7 @@ Options: -a, --assert Run a standalone assert function (deprecated: use 'bashunit assert') -e, --env, --boot Load a custom env/bootstrap file (supports args) -f, --filter Only run tests matching the name + --exclude-filter Skip tests whose name matches (repeatable) --tag Only run tests with matching @tag (repeatable, OR logic). Supports 'a&&b' (AND) and '!a' (NOT) --exclude-tag Skip tests with matching @tag (repeatable, exclude wins) diff --git a/src/helper/discovery.sh b/src/helper/discovery.sh index 94681e97..053c7ef9 100644 --- a/src/helper/discovery.sh +++ b/src/helper/discovery.sh @@ -70,6 +70,25 @@ function bashunit::helper::get_functions_to_run() { for fn in $function_names; do local _fn_match=false case "$fn" in ${prefix}_*${filter}*) _fn_match=true ;; esac + # --exclude-filter wins over the include filter, mirroring how + # --exclude-tag beats --tag. Read from the environment rather than passed + # in, so the header count and the runner cannot apply different selections + # (they call this from different places, one of them a subshell). + if [ "$_fn_match" = true ] && [ -n "${BASHUNIT_EXCLUDE_FILTER:-}" ]; then + local _old_ifs="$IFS" + IFS=',' + local _excl + for _excl in $BASHUNIT_EXCLUDE_FILTER; do + _excl=${_excl/test_/} + [ -z "$_excl" ] && continue + case "$fn" in ${prefix}_*${_excl}*) + _fn_match=false + break + ;; + esac + done + IFS="$_old_ifs" + fi if [ "$_fn_match" = true ]; then local _dup=false case "$filtered_functions" in *" $fn"*) _dup=true ;; esac diff --git a/src/main/test.sh b/src/main/test.sh index daa5ddc7..129dfe4f 100644 --- a/src/main/test.sh +++ b/src/main/test.sh @@ -45,6 +45,18 @@ function bashunit::main::cmd_test() { filter="$2" shift ;; + --exclude-filter) + if [ -z "$BASHUNIT_EXCLUDE_FILTER" ]; then + BASHUNIT_EXCLUDE_FILTER="$2" + else + BASHUNIT_EXCLUDE_FILTER="$BASHUNIT_EXCLUDE_FILTER,$2" + fi + # export -n like every other flag (#839): find_total_tests reads this + # from a plain subshell, which inherits it without exporting, and a real + # export would leak into nested ./bashunit runs. + export -n BASHUNIT_EXCLUDE_FILTER + shift + ;; --tag) bashunit::main::require_valid_tag_expression_or_exit "$2" if [ -z "$tag_filter" ]; then diff --git a/tests/acceptance/bashunit_exclude_filter_test.sh b/tests/acceptance/bashunit_exclude_filter_test.sh new file mode 100644 index 00000000..901fad40 --- /dev/null +++ b/tests/acceptance/bashunit_exclude_filter_test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --exclude-filter is the name-based counterpart of --exclude-tag (#1009). +# Selection is asserted through --list: same pipeline, exact comparison. + +FIXTURE=./tests/acceptance/fixtures/exclude_filter_fixture.sh + +function test_exclude_filter_skips_matching_tests() { + local output + output=$(./bashunit --list --exclude-filter admin "$FIXTURE" 2>/dev/null) + + assert_same "\ +$FIXTURE::test_user_list +$FIXTURE::test_report_export" "$output" +} + +function test_exclude_filter_combines_with_the_include_filter() { + local output + output=$(./bashunit --list --filter user --exclude-filter admin "$FIXTURE" 2>/dev/null) + + assert_same "$FIXTURE::test_user_list" "$output" +} + +function test_repeated_exclude_filters_are_or_ed() { + local output + output=$(./bashunit --list --exclude-filter admin --exclude-filter report "$FIXTURE" 2>/dev/null) + + assert_same "$FIXTURE::test_user_list" "$output" +} + +function test_exclude_filter_wins_when_a_name_matches_both() { + local output + output=$(./bashunit --list --filter admin --exclude-filter admin "$FIXTURE" 2>/dev/null) + + assert_empty "$output" +} + +# Not selected at all, so nothing is reported as skipped — same as --exclude-tag. +function test_excluded_tests_are_not_reported_as_skipped() { + local output + output=$(./bashunit --no-parallel --exclude-filter admin "$FIXTURE" 2>&1) + + assert_not_contains "skipped" "$output" +} + +function test_excluded_tests_are_not_counted_in_the_header() { + local output + output=$(./bashunit --no-parallel --exclude-filter admin "$FIXTURE" 2>&1) + + # The header count and the runner must agree, or the summary reads as if + # tests vanished mid-run. + assert_contains "Tests: 2" "$(printf '%s' "$output" | tr -s ' ')" +} + +function test_exclude_filter_works_with_explicit_function_targeting() { + local output + output=$(./bashunit --list --exclude-filter admin "$FIXTURE::test_user_admin" 2>/dev/null) + + assert_empty "$output" +} + +function test_without_the_flag_every_test_is_selected() { + local output + output=$(./bashunit --list "$FIXTURE" 2>/dev/null) + + assert_same "\ +$FIXTURE::test_user_list +$FIXTURE::test_user_admin +$FIXTURE::test_report_export" "$output" +} diff --git a/tests/acceptance/fixtures/exclude_filter_fixture.sh b/tests/acceptance/fixtures/exclude_filter_fixture.sh new file mode 100644 index 00000000..3c1ce975 --- /dev/null +++ b/tests/acceptance/fixtures/exclude_filter_fixture.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +function test_user_list() { + assert_same 1 1 +} + +function test_user_admin() { + assert_same 2 2 +} + +function test_report_export() { + assert_same 3 3 +} diff --git a/tests/unit/helper/helper_test.sh b/tests/unit/helper/helper_test.sh index 91370a81..c8e253f4 100644 --- a/tests/unit/helper/helper_test.sh +++ b/tests/unit/helper/helper_test.sh @@ -628,3 +628,68 @@ function test_get_function_line_number_preserves_caller_extdebug() { assert_same "on" "$state" } + +# --exclude-filter: the name-based counterpart of --exclude-tag (#1009). +# The value is read from BASHUNIT_EXCLUDE_FILTER so that the header count and +# the runner apply the same selection without threading a new argument through +# every call site. +_ORIG_EXCLUDE_FILTER="" + +function _with_exclude_filter() { # $1 exclude value, $2.. get_functions_to_run args + local value="$1" + shift + _ORIG_EXCLUDE_FILTER="${BASHUNIT_EXCLUDE_FILTER:-}" + BASHUNIT_EXCLUDE_FILTER="$value" + bashunit::helper::get_functions_to_run "$@" + BASHUNIT_EXCLUDE_FILTER="$_ORIG_EXCLUDE_FILTER" +} + +function test_exclude_filter_removes_matching_functions() { + local functions=("prefix_alpha" "prefix_beta" "prefix_gamma") + + assert_same "prefix_alpha prefix_gamma" \ + "$(_with_exclude_filter "beta" "prefix" "" "${functions[*]}")" +} + +function test_exclude_filter_keeps_everything_when_nothing_matches() { + local functions=("prefix_alpha" "prefix_beta") + + assert_same "prefix_alpha prefix_beta" \ + "$(_with_exclude_filter "nope" "prefix" "" "${functions[*]}")" +} + +function test_exclude_filter_is_or_across_comma_separated_values() { + local functions=("prefix_alpha" "prefix_beta" "prefix_gamma") + + assert_same "prefix_gamma" \ + "$(_with_exclude_filter "alpha,beta" "prefix" "" "${functions[*]}")" +} + +function test_exclude_filter_wins_over_the_include_filter() { + local functions=("prefix_user_list" "prefix_user_admin") + + assert_same "prefix_user_list" \ + "$(_with_exclude_filter "admin" "prefix" "user" "${functions[*]}")" +} + +function test_exclude_filter_can_empty_the_selection() { + local functions=("prefix_alpha") + + assert_same "" \ + "$(_with_exclude_filter "alpha" "prefix" "" "${functions[*]}")" +} + +# Symmetry with --filter, which strips a leading test_ from the value. +function test_exclude_filter_tolerates_a_test_prefix_in_the_value() { + local functions=("test_alpha" "test_beta") + + assert_same "test_beta" \ + "$(_with_exclude_filter "test_alpha" "test" "" "${functions[*]}")" +} + +function test_no_exclude_filter_keeps_previous_behaviour() { + local functions=("prefix_alpha" "prefix_beta") + + assert_same "prefix_alpha prefix_beta" \ + "$(_with_exclude_filter "" "prefix" "" "${functions[*]}")" +} From 13b32962003bbe8447dc7d6b50e61f7eabeaa094 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 9 Aug 2026 22:34:12 +0200 Subject: [PATCH 2/2] fix(test): declare arrays Bash 3.0 style in the exclude-filter tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `local functions=(...)` — declaring and initialising an array in one statement — is not supported on Bash 3.0; it stores the literal text, so the helper received "prefix_beta prefix_gamma)" and five tests failed on the floor while passing on 3.2 and 5.3. The surrounding tests in this file already carry the "Bash 3.0 compatible: separate declaration and assignment" comment; the new ones now follow it. Extracting the exclusion match into its own function is kept: it runs inside get_functions_to_run's `for fn in ...` loop, so its locals are __bu_-prefixed per bash-style.md to avoid shadowing the caller's loop variable. --- src/helper/discovery.sh | 54 ++++++++++++++++++++++---------- tests/unit/helper/helper_test.sh | 28 ++++++++++++----- 2 files changed, 58 insertions(+), 24 deletions(-) diff --git a/src/helper/discovery.sh b/src/helper/discovery.sh index 053c7ef9..ae9dde7f 100644 --- a/src/helper/discovery.sh +++ b/src/helper/discovery.sh @@ -56,6 +56,40 @@ function bashunit::helper::check_duplicate_functions() { } +## +# Whether a function name matches any --exclude-filter value. +# +# The value is read from BASHUNIT_EXCLUDE_FILTER rather than passed in, so the +# header count (which reaches get_functions_to_run from a subshell) and the +# runner cannot end up applying different selections. +# +# Locals are `__bu_`-prefixed (bash-style.md, PR #672). The only caller is +# get_functions_to_run, and this runs inside its `for fn in ...` loop, so plain +# `fn`/`prefix` locals here would shadow the caller's by dynamic scoping. +# +# Arguments: $1 - function prefix ("test"/"bench"), $2 - function name +# Returns: 0 when the name is excluded, 1 otherwise +## +function bashunit::helper::name_matches_exclude_filter() { + local __bu_prefix=$1 + local __bu_fn=$2 + + if [ -z "${BASHUNIT_EXCLUDE_FILTER:-}" ]; then + return 1 + fi + + local IFS=',' + local __bu_excl + for __bu_excl in $BASHUNIT_EXCLUDE_FILTER; do + __bu_excl=${__bu_excl/test_/} + if [ -n "$__bu_excl" ]; then + case "$__bu_fn" in ${__bu_prefix}_*${__bu_excl}*) return 0 ;; esac + fi + done + + return 1 +} + # Arguments: $1 - eg: "prefix", $2 - eg: "filter", $3 - eg: "[fn1, fn2, prefix_filter_fn3, fn4, ...]" # Returns: eg: "[prefix_filter_fn3, ...]" The filtered functions with prefix # @@ -71,23 +105,9 @@ function bashunit::helper::get_functions_to_run() { local _fn_match=false case "$fn" in ${prefix}_*${filter}*) _fn_match=true ;; esac # --exclude-filter wins over the include filter, mirroring how - # --exclude-tag beats --tag. Read from the environment rather than passed - # in, so the header count and the runner cannot apply different selections - # (they call this from different places, one of them a subshell). - if [ "$_fn_match" = true ] && [ -n "${BASHUNIT_EXCLUDE_FILTER:-}" ]; then - local _old_ifs="$IFS" - IFS=',' - local _excl - for _excl in $BASHUNIT_EXCLUDE_FILTER; do - _excl=${_excl/test_/} - [ -z "$_excl" ] && continue - case "$fn" in ${prefix}_*${_excl}*) - _fn_match=false - break - ;; - esac - done - IFS="$_old_ifs" + # --exclude-tag beats --tag. + if [ "$_fn_match" = true ] && bashunit::helper::name_matches_exclude_filter "$prefix" "$fn"; then + _fn_match=false fi if [ "$_fn_match" = true ]; then local _dup=false diff --git a/tests/unit/helper/helper_test.sh b/tests/unit/helper/helper_test.sh index c8e253f4..6e59a3d5 100644 --- a/tests/unit/helper/helper_test.sh +++ b/tests/unit/helper/helper_test.sh @@ -645,35 +645,45 @@ function _with_exclude_filter() { # $1 exclude value, $2.. get_functions_to_run } function test_exclude_filter_removes_matching_functions() { - local functions=("prefix_alpha" "prefix_beta" "prefix_gamma") + # Bash 3.0 compatible: separate declaration and assignment + local functions + functions=("prefix_alpha" "prefix_beta" "prefix_gamma") assert_same "prefix_alpha prefix_gamma" \ "$(_with_exclude_filter "beta" "prefix" "" "${functions[*]}")" } function test_exclude_filter_keeps_everything_when_nothing_matches() { - local functions=("prefix_alpha" "prefix_beta") + # Bash 3.0 compatible: separate declaration and assignment + local functions + functions=("prefix_alpha" "prefix_beta") assert_same "prefix_alpha prefix_beta" \ "$(_with_exclude_filter "nope" "prefix" "" "${functions[*]}")" } function test_exclude_filter_is_or_across_comma_separated_values() { - local functions=("prefix_alpha" "prefix_beta" "prefix_gamma") + # Bash 3.0 compatible: separate declaration and assignment + local functions + functions=("prefix_alpha" "prefix_beta" "prefix_gamma") assert_same "prefix_gamma" \ "$(_with_exclude_filter "alpha,beta" "prefix" "" "${functions[*]}")" } function test_exclude_filter_wins_over_the_include_filter() { - local functions=("prefix_user_list" "prefix_user_admin") + # Bash 3.0 compatible: separate declaration and assignment + local functions + functions=("prefix_user_list" "prefix_user_admin") assert_same "prefix_user_list" \ "$(_with_exclude_filter "admin" "prefix" "user" "${functions[*]}")" } function test_exclude_filter_can_empty_the_selection() { - local functions=("prefix_alpha") + # Bash 3.0 compatible: separate declaration and assignment + local functions + functions=("prefix_alpha") assert_same "" \ "$(_with_exclude_filter "alpha" "prefix" "" "${functions[*]}")" @@ -681,14 +691,18 @@ function test_exclude_filter_can_empty_the_selection() { # Symmetry with --filter, which strips a leading test_ from the value. function test_exclude_filter_tolerates_a_test_prefix_in_the_value() { - local functions=("test_alpha" "test_beta") + # Bash 3.0 compatible: separate declaration and assignment + local functions + functions=("test_alpha" "test_beta") assert_same "test_beta" \ "$(_with_exclude_filter "test_alpha" "test" "" "${functions[*]}")" } function test_no_exclude_filter_keeps_previous_behaviour() { - local functions=("prefix_alpha" "prefix_beta") + # Bash 3.0 compatible: separate declaration and assignment + local functions + functions=("prefix_alpha" "prefix_beta") assert_same "prefix_alpha prefix_beta" \ "$(_with_exclude_filter "" "prefix" "" "${functions[*]}")"