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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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)
Expand Down
1 change: 1 addition & 0 deletions completions/_bashunit
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down
4 changes: 2 additions & 2 deletions completions/bashunit.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ bashunit test tests/ --parallel --simple
| `-a, --assert <fn> <args>` | Run a standalone assert function |
| `-e, --env, --boot <file>` | Load custom env/bootstrap file (supports args) |
| `-f, --filter <name>` | Only run tests matching name |
| `--exclude-filter <name>` | Skip tests whose name matches (repeatable) |
| `--tag <expr>` | Only run tests with matching `@tag`; supports `a&&b` and `!a` |
| `--exclude-tag <name>` | Skip tests with matching `@tag` (repeatable) |
| `--output <format>` | Output format (`tap` for TAP version 13) |
Expand Down Expand Up @@ -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 <name>`
Expand Down
5 changes: 5 additions & 0 deletions src/config/env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/console/header.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Options:
-a, --assert <fn> <args> Run a standalone assert function (deprecated: use 'bashunit assert')
-e, --env, --boot <file> Load a custom env/bootstrap file (supports args)
-f, --filter <name> Only run tests matching the name
--exclude-filter <name> Skip tests whose name matches (repeatable)
--tag <expr> Only run tests with matching @tag (repeatable, OR logic).
Supports 'a&&b' (AND) and '!a' (NOT)
--exclude-tag <name> Skip tests with matching @tag (repeatable, exclude wins)
Expand Down
39 changes: 39 additions & 0 deletions src/helper/discovery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand All @@ -70,6 +104,11 @@ 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.
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
case "$filtered_functions" in *" $fn"*) _dup=true ;; esac
Expand Down
12 changes: 12 additions & 0 deletions src/main/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions tests/acceptance/bashunit_exclude_filter_test.sh
Original file line number Diff line number Diff line change
@@ -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"
}
13 changes: 13 additions & 0 deletions tests/acceptance/fixtures/exclude_filter_fixture.sh
Original file line number Diff line number Diff line change
@@ -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
}
79 changes: 79 additions & 0 deletions tests/unit/helper/helper_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -628,3 +628,82 @@ 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() {
# 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() {
# 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() {
# 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() {
# 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() {
# Bash 3.0 compatible: separate declaration and assignment
local functions
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() {
# 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() {
# 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[*]}")"
}
Loading