Skip to content

Commit e5cb734

Browse files
authored
Merge pull request #594 from TypedDevs/feat/test-tags
feat(runner): add --tag and --exclude-tag for filtering tests by annotations
2 parents a1241a0 + 4ccb5a8 commit e5cb734

8 files changed

Lines changed: 399 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Add `assert_string_matches_format` and `assert_string_not_matches_format` with format placeholders (`%d`, `%s`, `%f`, `%i`, `%x`, `%e`, `%%`)
88
- Add JSON assertions: `assert_json_key_exists`, `assert_json_contains`, `assert_json_equals` (requires `jq`)
99
- Add duration assertions: `assert_duration`, `assert_duration_less_than`, `assert_duration_greater_than`
10+
- Add `--tag` and `--exclude-tag` CLI flags for filtering tests by `# @tag` annotations
1011

1112
### Changed
1213
- Split Windows CI test jobs into parallel chunks to avoid timeouts

src/console_header.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ Options:
105105
-a, --assert <fn> <args> Run a standalone assert function (deprecated: use 'bashunit assert')
106106
-e, --env, --boot <file> Load a custom env/bootstrap file (supports args)
107107
-f, --filter <name> Only run tests matching the name
108+
--tag <name> Only run tests with matching @tag (repeatable, OR logic)
109+
--exclude-tag <name> Skip tests with matching @tag (repeatable, exclude wins)
108110
--log-junit <file> Write JUnit XML report
109111
-p, --parallel Run tests in parallel
110112
--no-parallel Run tests sequentially

src/helpers.sh

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,3 +505,117 @@ function bashunit::helper::find_function_at_line() {
505505

506506
echo "$best_match"
507507
}
508+
509+
#
510+
# Extracts @tag annotations for a specific function from a test file.
511+
# Looks for comment lines `# @tag <name>` immediately above the function definition.
512+
#
513+
# @param $1 string Function name
514+
# @param $2 string Script file path
515+
#
516+
# @return string Comma-separated list of tags, or empty if none
517+
#
518+
function bashunit::helper::get_tags_for_function() {
519+
local function_name="$1"
520+
local script="$2"
521+
522+
if [[ ! -f "$script" && -n "${BASHUNIT_WORKING_DIR:-}" ]]; then
523+
script="$BASHUNIT_WORKING_DIR/$script"
524+
fi
525+
526+
if [[ ! -f "$script" ]]; then
527+
return
528+
fi
529+
530+
# Find the line number of the function definition
531+
local fn_line_num
532+
fn_line_num=$(grep -n -E "(function[[:space:]]+)?${function_name}[[:space:]]*\(\)" "$script" 2>/dev/null | head -1)
533+
if [ -z "$fn_line_num" ]; then
534+
return
535+
fi
536+
fn_line_num="${fn_line_num%%:*}"
537+
538+
# Walk backwards from the line above the function, collecting @tag comments
539+
local tags=""
540+
local check_line=$((fn_line_num - 1))
541+
while [ "$check_line" -ge 1 ]; do
542+
local content
543+
content=$(sed -n "${check_line}p" "$script")
544+
local _re='^[[:space:]]*#[[:space:]]*@tag[[:space:]]'
545+
if [[ "$content" =~ $_re ]]; then
546+
local tag_name
547+
tag_name=$(echo "$content" | sed -nE 's/^[[:space:]]*#[[:space:]]*@tag[[:space:]]+//p')
548+
if [ -n "$tag_name" ]; then
549+
if [ -z "$tags" ]; then
550+
tags="$tag_name"
551+
else
552+
tags="$tags,$tag_name"
553+
fi
554+
fi
555+
elif [[ "$content" =~ ^[[:space:]]*# ]]; then
556+
# Other comment line, keep walking
557+
:
558+
elif [[ "$content" =~ ^[[:space:]]*$ ]]; then
559+
# Empty line, stop looking
560+
break
561+
else
562+
# Non-comment, non-empty line, stop
563+
break
564+
fi
565+
check_line=$((check_line - 1))
566+
done
567+
568+
echo "$tags"
569+
}
570+
571+
#
572+
# Checks if a function's tags match the include/exclude filters.
573+
# Include uses OR logic (any match passes).
574+
# Exclude uses OR logic (any match fails).
575+
# Exclude takes precedence over include.
576+
#
577+
# @param $1 string Comma-separated tags for the function
578+
# @param $2 string Comma-separated include tags (empty = no filter)
579+
# @param $3 string Comma-separated exclude tags (empty = no filter)
580+
#
581+
# @return 0 if function should run, 1 if it should be skipped
582+
#
583+
function bashunit::helper::function_matches_tags() {
584+
local fn_tags="$1"
585+
local include_tags="$2"
586+
local exclude_tags="$3"
587+
588+
# Check exclude tags first (exclude wins over include)
589+
if [ -n "$exclude_tags" ]; then
590+
local IFS=','
591+
local etag
592+
for etag in $exclude_tags; do
593+
local check_tag
594+
for check_tag in $fn_tags; do
595+
if [ "$check_tag" = "$etag" ]; then
596+
return 1
597+
fi
598+
done
599+
done
600+
fi
601+
602+
# Check include tags (OR logic: any match passes)
603+
if [ -n "$include_tags" ]; then
604+
if [ -z "$fn_tags" ]; then
605+
return 1
606+
fi
607+
local IFS=','
608+
local itag
609+
for itag in $include_tags; do
610+
local check_tag
611+
for check_tag in $fn_tags; do
612+
if [ "$check_tag" = "$itag" ]; then
613+
return 0
614+
fi
615+
done
616+
done
617+
return 1
618+
fi
619+
620+
return 0
621+
}

src/main.sh

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
#############################
66
function bashunit::main::cmd_test() {
77
local filter=""
8+
local tag_filter=""
9+
local exclude_tag_filter=""
810
local IFS=$' \t\n'
911
local -a raw_args=()
1012
local raw_args_count=0
@@ -24,6 +26,22 @@ function bashunit::main::cmd_test() {
2426
filter="$2"
2527
shift
2628
;;
29+
--tag)
30+
if [ -z "$tag_filter" ]; then
31+
tag_filter="$2"
32+
else
33+
tag_filter="$tag_filter,$2"
34+
fi
35+
shift
36+
;;
37+
--exclude-tag)
38+
if [ -z "$exclude_tag_filter" ]; then
39+
exclude_tag_filter="$2"
40+
else
41+
exclude_tag_filter="$exclude_tag_filter,$2"
42+
fi
43+
shift
44+
;;
2745
-s | --simple)
2846
export BASHUNIT_SIMPLE_OUTPUT=true
2947
;;
@@ -250,9 +268,9 @@ function bashunit::main::cmd_test() {
250268
# Bash 3.0 compatible: only pass args if we have files
251269
# (local args without =() creates a scalar, not an empty array)
252270
if [[ "$args_count" -gt 0 ]]; then
253-
bashunit::main::exec_tests "$filter" "${args[@]}"
271+
bashunit::main::exec_tests "$filter" "$tag_filter" "$exclude_tag_filter" "${args[@]}"
254272
else
255-
bashunit::main::exec_tests "$filter"
273+
bashunit::main::exec_tests "$filter" "$tag_filter" "$exclude_tag_filter"
256274
fi
257275
fi
258276
}
@@ -462,7 +480,9 @@ function bashunit::main::cmd_assert() {
462480
#############################
463481
function bashunit::main::exec_tests() {
464482
local filter=$1
465-
shift
483+
local tag_filter="${2:-}"
484+
local exclude_tag_filter="${3:-}"
485+
shift 3
466486

467487
# Bash 3.0 compatible: collect files into array
468488
local test_files
@@ -513,7 +533,7 @@ function bashunit::main::exec_tests() {
513533
printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '#'
514534
fi
515535

516-
bashunit::runner::load_test_files "$filter" "${test_files[@]}"
536+
bashunit::runner::load_test_files "$filter" "$tag_filter" "$exclude_tag_filter" "${test_files[@]}"
517537

518538
if bashunit::parallel::is_enabled; then
519539
wait

src/runner.sh

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ function bashunit::runner::restore_workdir() {
1414

1515
function bashunit::runner::load_test_files() {
1616
local filter=$1
17-
shift
17+
local tag_filter="${2:-}"
18+
local exclude_tag_filter="${3:-}"
19+
shift 3
1820
local IFS=$' \t\n'
1921
local -a files
2022
files=("$@")
@@ -49,6 +51,19 @@ function bashunit::runner::load_test_files() {
4951
filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS")
5052
local functions_for_script
5153
functions_for_script=$(bashunit::runner::functions_for_script "$test_file" "$filtered_functions")
54+
# Apply tag filtering to the early check as well
55+
if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then
56+
local _early_filtered=""
57+
local _early_fn
58+
for _early_fn in $functions_for_script; do
59+
local _early_tags
60+
_early_tags=$(bashunit::helper::get_tags_for_function "$_early_fn" "$test_file")
61+
if bashunit::helper::function_matches_tags "$_early_tags" "$tag_filter" "$exclude_tag_filter"; then
62+
_early_filtered="$_early_filtered $_early_fn"
63+
fi
64+
done
65+
functions_for_script="${_early_filtered# }"
66+
fi
5267
if [[ -z "$functions_for_script" ]]; then
5368
bashunit::runner::clean_set_up_and_tear_down_after_script
5469
bashunit::runner::restore_workdir
@@ -83,9 +98,9 @@ function bashunit::runner::load_test_files() {
8398
continue
8499
fi
85100
if bashunit::parallel::is_enabled; then
86-
bashunit::runner::call_test_functions "$test_file" "$filter" 2>/dev/null &
101+
bashunit::runner::call_test_functions "$test_file" "$filter" "$tag_filter" "$exclude_tag_filter" 2>/dev/null &
87102
else
88-
bashunit::runner::call_test_functions "$test_file" "$filter"
103+
bashunit::runner::call_test_functions "$test_file" "$filter" "$tag_filter" "$exclude_tag_filter"
89104
fi
90105
bashunit::runner::run_tear_down_after_script "$test_file"
91106
bashunit::runner::clean_set_up_and_tear_down_after_script
@@ -322,6 +337,8 @@ function bashunit::runner::parse_data_provider_args() {
322337
function bashunit::runner::call_test_functions() {
323338
local script="$1"
324339
local filter="$2"
340+
local tag_filter="${3:-}"
341+
local exclude_tag_filter="${4:-}"
325342
local IFS=$' \t\n'
326343
local prefix="test"
327344
# Use cached function names for better performance
@@ -337,6 +354,23 @@ function bashunit::runner::call_test_functions() {
337354
functions_to_run_count=$((functions_to_run_count + 1))
338355
done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions")
339356

357+
# Apply tag filtering if --tag or --exclude-tag was specified
358+
if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then
359+
local -a tag_filtered=()
360+
local tag_filtered_count=0
361+
local _tf_fn
362+
for _tf_fn in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do
363+
local fn_tags
364+
fn_tags=$(bashunit::helper::get_tags_for_function "$_tf_fn" "$script")
365+
if bashunit::helper::function_matches_tags "$fn_tags" "$tag_filter" "$exclude_tag_filter"; then
366+
tag_filtered[tag_filtered_count]="$_tf_fn"
367+
tag_filtered_count=$((tag_filtered_count + 1))
368+
fi
369+
done
370+
functions_to_run=("${tag_filtered[@]+"${tag_filtered[@]}"}")
371+
functions_to_run_count=$tag_filtered_count
372+
fi
373+
340374
if [[ "$functions_to_run_count" -le 0 ]]; then
341375
return
342376
fi
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
function set_up_before_script() {
5+
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
6+
}
7+
8+
function test_tag_runs_only_matching_tests() {
9+
local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh
10+
local output
11+
12+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag slow "$test_file" 2>&1)
13+
14+
assert_contains "2 passed" "$output"
15+
assert_contains "2 total" "$output"
16+
}
17+
18+
function test_tag_fast_runs_only_fast_tests() {
19+
local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh
20+
local output
21+
22+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag fast "$test_file" 2>&1)
23+
24+
assert_contains "1 passed" "$output"
25+
assert_contains "1 total" "$output"
26+
}
27+
28+
function test_tag_database_runs_only_database_tests() {
29+
local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh
30+
local output
31+
32+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag database "$test_file" 2>&1)
33+
34+
assert_contains "1 passed" "$output"
35+
assert_contains "1 total" "$output"
36+
}
37+
38+
function test_exclude_tag_skips_matching_tests() {
39+
local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh
40+
local output
41+
42+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --exclude-tag slow "$test_file" 2>&1)
43+
44+
assert_contains "2 passed" "$output"
45+
assert_contains "2 total" "$output"
46+
}
47+
48+
function test_exclude_tag_takes_precedence_over_tag() {
49+
local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh
50+
local output
51+
52+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag slow --exclude-tag database "$test_file" 2>&1)
53+
54+
assert_contains "1 passed" "$output"
55+
assert_contains "1 total" "$output"
56+
}
57+
58+
function test_multiple_tags_use_or_logic() {
59+
local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh
60+
local output
61+
62+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag slow --tag fast "$test_file" 2>&1)
63+
64+
assert_contains "3 passed" "$output"
65+
assert_contains "3 total" "$output"
66+
}
67+
68+
function test_no_tag_flags_runs_all_tests() {
69+
local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh
70+
local output
71+
72+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file" 2>&1)
73+
74+
assert_contains "4 passed" "$output"
75+
assert_contains "4 total" "$output"
76+
}
77+
78+
function test_tag_nonexistent_runs_zero_tests() {
79+
local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh
80+
local output
81+
82+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag nonexistent "$test_file" 2>&1) || true
83+
84+
assert_contains "0 total" "$output"
85+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env bash
2+
3+
# @tag slow
4+
function test_slow_operation() {
5+
assert_same 1 1
6+
}
7+
8+
# @tag fast
9+
function test_fast_operation() {
10+
assert_same 2 2
11+
}
12+
13+
# @tag slow
14+
# @tag database
15+
function test_slow_database_query() {
16+
assert_same 3 3
17+
}
18+
19+
function test_no_tags() {
20+
assert_same 4 4
21+
}

0 commit comments

Comments
 (0)