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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### Added
- Add inline filter syntax to run specific tests from a file
- `path::function_name` - filter tests by function name
- `path:line_number` - run the test containing the specified line

### Changed
- **BREAKING:** Introduce subcommand-based CLI architecture
- `bashunit test [path]` - run tests (default, backwards compatible with `bashunit [path]`)
Expand Down
32 changes: 32 additions & 0 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,38 @@ bashunit test tests/ --filter "user_login"
```
:::

### Inline Filter Syntax

You can also specify a filter directly in the file path using `::` or `:line` syntax:

**Run a specific test by function name:**
> `bashunit test path::function_name`

::: code-group
```bash [Exact match]
bashunit test tests/unit/example_test.sh::test_user_login
```
```bash [Partial match]
# Runs all tests containing "user" in their name
bashunit test tests/unit/example_test.sh::user
```
:::

**Run the test at a specific line number:**
> `bashunit test path:line_number`

This is useful when jumping to a test from your editor or IDE.

::: code-group
```bash [Example]
bashunit test tests/unit/example_test.sh:42
```
:::

::: tip
The line number syntax finds the test function that contains the specified line. If the line is before any test function, an error is shown.
:::

### Parallel

> `bashunit test -p|--parallel`
Expand Down
69 changes: 69 additions & 0 deletions src/helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -372,3 +372,72 @@ function helper::generate_id() {
echo "${sanitized_basename}_$$"
fi
}

#
# Parses a file path that may contain a filter suffix.
# Supports two syntaxes:
# - path::function_name (filter by function name)
# - path:line_number (filter by line number)
#
# @param $1 string Eg: "tests/test.sh::test_foo" or "tests/test.sh:123"
#
# @return string Two lines: first is file path, second is filter (or empty)
#
function helper::parse_file_path_filter() {
local input="$1"
local file_path=""
local filter=""

# Check for :: syntax (function name filter)
if [[ "$input" == *"::"* ]]; then
file_path="${input%%::*}"
filter="${input#*::}"
# Check for :number syntax (line number filter)
elif [[ "$input" =~ ^(.+):([0-9]+)$ ]]; then
file_path="${BASH_REMATCH[1]}"
local line_number="${BASH_REMATCH[2]}"
# Line number will be resolved to function name later
filter="__line__:${line_number}"
else
file_path="$input"
fi

echo "$file_path"
echo "$filter"
}

#
# Finds the test function that contains a given line number in a file.
#
# @param $1 string File path
# @param $2 number Line number
#
# @return string The function name, or empty if not found
#
function helper::find_function_at_line() {
local file="$1"
local target_line="$2"

if [[ ! -f "$file" ]]; then
return 1
fi

# Find all test function definitions and their line numbers
local best_match=""
local best_line=0

while IFS=: read -r line_num content; do
# Extract function name from the line
local fn_name=""
if [[ "$content" =~ ^[[:space:]]*(function[[:space:]]+)?(test[a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\(\) ]]; then
fn_name="${BASH_REMATCH[2]}"
fi

if [[ -n "$fn_name" && "$line_num" -le "$target_line" && "$line_num" -gt "$best_line" ]]; then
best_match="$fn_name"
best_line="$line_num"
fi
done < <(grep -n -E '^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)' "$file")

echo "$best_match"
}
56 changes: 50 additions & 6 deletions src/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,57 @@ function main::cmd_test() {
shift
done

# Expand positional arguments
# Expand positional arguments and extract inline filters
# Skip filter parsing for assert mode - args are not file paths
local inline_filter=""
local inline_filter_file=""
if [[ ${#raw_args[@]} -gt 0 ]]; then
for arg in "${raw_args[@]}"; do
while IFS= read -r file; do
args+=("$file")
done < <(helper::find_files_recursive "$arg" '*[tT]est.sh')
done
if [[ -n "$assert_fn" ]]; then
# Assert mode: pass args as-is without file path processing
args=("${raw_args[@]}")
else
# Test mode: process file paths and extract inline filters
for arg in "${raw_args[@]}"; do
local parsed_path parsed_filter
{
read -r parsed_path
read -r parsed_filter
} < <(helper::parse_file_path_filter "$arg")

# If an inline filter was found, store it
if [[ -n "$parsed_filter" ]]; then
inline_filter="$parsed_filter"
inline_filter_file="$parsed_path"
fi

while IFS= read -r file; do
args+=("$file")
done < <(helper::find_files_recursive "$parsed_path" '*[tT]est.sh')
done

# Resolve line number filter to function name
if [[ "$inline_filter" == "__line__:"* ]]; then
local line_number="${inline_filter#__line__:}"
local resolved_file="${inline_filter_file}"

# If the file path was a pattern, use the first resolved file
if [[ ${#args[@]} -gt 0 ]]; then
resolved_file="${args[0]}"
fi

inline_filter=$(helper::find_function_at_line "$resolved_file" "$line_number")
if [[ -z "$inline_filter" ]]; then
printf "%sError: No test function found at line %s in %s%s\n" \
"${_COLOR_FAILED}" "$line_number" "$resolved_file" "${_COLOR_DEFAULT}"
exit 1
fi
fi

# Use inline filter if no -f filter was provided
if [[ -z "$filter" && -n "$inline_filter" ]]; then
filter="$inline_filter"
fi
fi
fi

# Optional bootstrap
Expand Down
67 changes: 67 additions & 0 deletions tests/acceptance/bashunit_inline_filter_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail

function set_up_before_script() {
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
}

function test_double_colon_syntax_runs_specific_test() {
local output
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
"tests/acceptance/fixtures/tests_path/a_test.sh::test_assert_empty")

assert_contains "1 passed" "$output"
assert_contains "Assert empty" "$output"
}

function test_double_colon_syntax_with_partial_match() {
local output
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
"tests/acceptance/fixtures/tests_path/a_test.sh::test_assert")

assert_contains "2 passed" "$output"
}

function test_line_number_syntax_runs_specific_test() {
local output
# Line 4 should be inside test_assert_greater_and_less_than (starts at line 3)
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
"tests/acceptance/fixtures/tests_path/a_test.sh:4")

assert_contains "passed" "$output"
assert_contains "Assert greater and less than" "$output"
}

function test_line_number_at_second_function() {
local output
# Line 9 should be inside test_assert_empty (starts at line 8)
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
"tests/acceptance/fixtures/tests_path/a_test.sh:9")

assert_contains "1 passed" "$output"
assert_contains "Assert empty" "$output"
}

function test_line_number_before_any_test_shows_error() {
local output
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
"tests/acceptance/fixtures/tests_path/a_test.sh:1" 2>&1) || true

assert_contains "No test function found" "$output"
}

function test_double_colon_syntax_no_match_runs_nothing() {
local output
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
"tests/acceptance/fixtures/tests_path/a_test.sh::nonexistent_test")

assert_contains "0 total" "$output"
}

function test_regular_filter_option_still_works() {
local output
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
--filter "test_assert_empty" "tests/acceptance/fixtures/tests_path/a_test.sh")

assert_contains "1 passed" "$output"
}
88 changes: 88 additions & 0 deletions tests/unit/helpers_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,91 @@ function test_find_total_tests_with_filter() {

assert_same "3" "$(helpers_test::find_total_in_subshell "with_provider" "$file1" "$file2")"
}

function test_parse_file_path_filter_plain_path() {
local result
result=$(helper::parse_file_path_filter "tests/unit/example_test.sh")

local file_path filter
{
read -r file_path
read -r filter
} <<< "$result"

assert_same "tests/unit/example_test.sh" "$file_path"
assert_same "" "$filter"
}

function test_parse_file_path_filter_with_double_colon() {
local result
result=$(helper::parse_file_path_filter "tests/unit/example_test.sh::test_my_function")

local file_path filter
{
read -r file_path
read -r filter
} <<< "$result"

assert_same "tests/unit/example_test.sh" "$file_path"
assert_same "test_my_function" "$filter"
}

function test_parse_file_path_filter_with_line_number() {
local result
result=$(helper::parse_file_path_filter "tests/unit/example_test.sh:42")

local file_path filter
{
read -r file_path
read -r filter
} <<< "$result"

assert_same "tests/unit/example_test.sh" "$file_path"
assert_same "__line__:42" "$filter"
}

function test_parse_file_path_filter_with_colon_in_path() {
local result
result=$(helper::parse_file_path_filter "/path/to:weird/example_test.sh::test_func")

local file_path filter
{
read -r file_path
read -r filter
} <<< "$result"

assert_same "/path/to:weird/example_test.sh" "$file_path"
assert_same "test_func" "$filter"
}

function test_find_function_at_line_first_function() {
local file
file="$(current_dir)/fixtures/find_total_tests/simple_test.sh"

assert_same "test_first" "$(helper::find_function_at_line "$file" 4)"
}

function test_find_function_at_line_second_function() {
local file
file="$(current_dir)/fixtures/find_total_tests/simple_test.sh"

assert_same "test_second" "$(helper::find_function_at_line "$file" 8)"
}

function test_find_function_at_line_exact_function_line() {
local file
file="$(current_dir)/fixtures/find_total_tests/simple_test.sh"

assert_same "test_first" "$(helper::find_function_at_line "$file" 3)"
}

function test_find_function_at_line_before_any_function() {
local file
file="$(current_dir)/fixtures/find_total_tests/simple_test.sh"

assert_same "" "$(helper::find_function_at_line "$file" 1)"
}

function test_find_function_at_line_nonexistent_file() {
assert_general_error "$(helper::find_function_at_line "/nonexistent/file.sh" 10)"
}
Loading