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

### Added
- `--changed [<ref>]` runs only the test files git reports as touched since `<ref>` (default `origin/HEAD`, then `HEAD`), covering committed, staged, unstaged and untracked changes. Deletions are dropped, a rename selects its new path, and a missing work tree or unresolvable ref fails the run instead of selecting nothing (#1010)
- `assert_between <min> <max> <actual>` and `assert_not_between` add inclusive numeric-range assertions for integers and decimals (#1026)
- `--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)
Expand All @@ -15,6 +16,7 @@
- Performance: `--coverage` is about 1.6x to 2.3x faster. Executable-line classification no longer forks `grep` per source line, which was roughly half of a coverage run's wall time and affected both engines equally (#1005)

### Fixed
- `assert_within_delta` rejects malformed numbers such as `1.2.3` or `5-3` as non-numeric instead of leaking a raw `bc` parse error or silently evaluating them as an expression (#1026)
- Report formats are no longer empty under `--parallel`. `--report-junit`, `--report-tap`, `--report-json`, `--report-html` and `--log-junit` all recorded zero tests, because the rows were collected inside the per-test worker and nothing rebuilt them in the parent (#1004)

## [0.45.0](https://github.com/TypedDevs/bashunit/compare/0.44.0...0.45.0) - 2026-08-09
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
## Why bashunit

A lightweight, fast testing framework for **Bash 3.0+**, focused on developer experience.
It ships 75 assertions plus spies, mocks, data providers, snapshots and more.
It ships 77 assertions plus spies, mocks, data providers, snapshots and more.

## Quick start

Expand Down
4 changes: 2 additions & 2 deletions completions/_bashunit
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ _bashunit() {
assert_fns=(
assert_array_contains assert_array_length assert_array_not_contains
assert_arrays_equal assert_assertion_fails assert_assertion_fails_with
assert_assertion_passes assert_command_available assert_command_not_found assert_contains
assert_assertion_passes assert_between assert_command_available assert_command_not_found assert_contains
assert_contains_ignore_case assert_date_after assert_date_before
assert_date_equals assert_date_within_delta assert_date_within_range
assert_directory_exists assert_directory_not_exists assert_duration
Expand All @@ -34,7 +34,7 @@ _bashunit() {
assert_less_or_equal_than assert_less_than assert_line_count
assert_match_named_snapshot assert_match_named_snapshot_ignore_colors
assert_match_snapshot assert_match_snapshot_ignore_colors assert_matches
assert_not_contains assert_not_empty assert_not_equals assert_not_matches
assert_not_between assert_not_contains assert_not_empty assert_not_equals assert_not_matches
assert_not_same assert_same assert_string_ends_with
assert_string_matches_format assert_string_not_ends_with
assert_string_not_matches_format assert_string_not_starts_with
Expand Down
4 changes: 2 additions & 2 deletions completions/bashunit.bash
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ _BASHUNIT_COMPLETIONS_TEST_OPTS="--assert --boot --changed --coverage --coverage

_BASHUNIT_COMPLETIONS_ASSERT_FNS="assert_array_contains assert_array_length \
assert_array_not_contains assert_arrays_equal assert_assertion_fails \
assert_assertion_fails_with assert_assertion_passes assert_command_available assert_command_not_found \
assert_assertion_fails_with assert_assertion_passes assert_between assert_command_available assert_command_not_found \
assert_contains assert_contains_ignore_case assert_date_after \
assert_date_before assert_date_equals assert_date_within_delta \
assert_date_within_range assert_directory_exists assert_directory_not_exists \
Expand All @@ -47,7 +47,7 @@ assert_json_contains assert_json_equals assert_json_key_exists \
assert_less_or_equal_than assert_less_than assert_line_count \
assert_match_named_snapshot assert_match_named_snapshot_ignore_colors \
assert_match_snapshot assert_match_snapshot_ignore_colors assert_matches \
assert_not_contains assert_not_empty assert_not_equals assert_not_matches \
assert_not_between assert_not_contains assert_not_empty assert_not_equals assert_not_matches \
assert_not_same assert_same assert_string_ends_with \
assert_string_matches_format assert_string_not_ends_with \
assert_string_not_matches_format assert_string_not_starts_with \
Expand Down
43 changes: 42 additions & 1 deletion docs/assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ to narrow it (`bashunit doc json`).
|-------|------------|
| **Booleans and equality** | [assert_true](#assert-true) · [assert_false](#assert-false) · [assert_same](#assert-same) · [assert_not_same](#assert-not-same) · [assert_equals](#assert-equals) · [assert_not_equals](#assert-not-equals) |
| **Strings** | [assert_contains](#assert-contains) · [assert_not_contains](#assert-not-contains) · [assert_contains_ignore_case](#assert-contains-ignore-case) · [assert_matches](#assert-matches) · [assert_not_matches](#assert-not-matches) · [assert_string_starts_with](#assert-string-starts-with) · [assert_string_not_starts_with](#assert-string-not-starts-with) · [assert_string_ends_with](#assert-string-ends-with) · [assert_string_not_ends_with](#assert-string-not-ends-with) · [assert_string_matches_format](#assert-string-matches-format) · [assert_string_not_matches_format](#assert-string-not-matches-format) · [assert_empty](#assert-empty) · [assert_not_empty](#assert-not-empty) · [assert_line_count](#assert-line-count) |
| **Numbers** | [assert_less_than](#assert-less-than) · [assert_less_or_equal_than](#assert-less-or-equal-than) · [assert_greater_than](#assert-greater-than) · [assert_greater_or_equal_than](#assert-greater-or-equal-than) · [assert_within_delta](#assert-within-delta) |
| **Numbers** | [assert_less_than](#assert-less-than) · [assert_less_or_equal_than](#assert-less-or-equal-than) · [assert_greater_than](#assert-greater-than) · [assert_greater_or_equal_than](#assert-greater-or-equal-than) · [assert_between](#assert-between) · [assert_not_between](#assert-not-between) · [assert_within_delta](#assert-within-delta) |
| **Dates** | [assert_date_equals](#assert-date-equals) · [assert_date_before](#assert-date-before) · [assert_date_after](#assert-date-after) · [assert_date_within_range](#assert-date-within-range) · [assert_date_within_delta](#assert-date-within-delta) |
| **Exit codes and commands** | [assert_exit_code](#assert-exit-code) · [assert_successful_code](#assert-successful-code) · [assert_unsuccessful_code](#assert-unsuccessful-code) · [assert_general_error](#assert-general-error) · [assert_command_available](#assert-command-available) · [assert_command_not_found](#assert-command-not-found) · [assert_exec](#assert-exec) |
| **Files** | [assert_file_exists](#assert-file-exists) · [assert_file_not_exists](#assert-file-not-exists) · [assert_file_contains](#assert-file-contains) · [assert_file_not_contains](#assert-file-not-contains) · [assert_is_file](#assert-is-file) · [assert_is_file_empty](#assert-is-file-empty) · [assert_is_symlink](#assert-is-symlink) · [assert_is_not_symlink](#assert-is-not-symlink) · [assert_symlink_to](#assert-symlink-to) · [assert_file_permissions](#assert-file-permissions) · [assert_files_equals](#assert-files-equals) · [assert_files_not_equals](#assert-files-not-equals) |
Expand Down Expand Up @@ -405,6 +405,47 @@ function test_failure() {
```
:::

## assert_between
> `assert_between "min" "max" "actual"`

Reports an error if `actual` is outside the inclusive numeric range from `min` to `max`.
Integers, decimals, and negative values are supported. `min` must not be greater than `max`.

- [assert_not_between](#assert-not-between) is the exact negation and takes the same arguments.

::: code-group
```bash [Example]
function test_success() {
assert_between "100" "500" "275"
assert_between "0.1" "0.3" "0.2"
}

function test_failure() {
assert_between "100" "500" "750"
}
```
:::

## assert_not_between
> `assert_not_between "min" "max" "actual"`

Reports an error if `actual` is inside the inclusive numeric range from `min` to `max`.
Integers, decimals, and negative values are supported. `min` must not be greater than `max`.

- [assert_between](#assert-between) is the exact negation and takes the same arguments.

::: code-group
```bash [Example]
function test_success() {
assert_not_between "400" "499" "200"
}

function test_failure() {
assert_not_between "400" "499" "404"
}
```
:::

## assert_within_delta
> `assert_within_delta "expected" "actual" "delta"`

Expand Down
5 changes: 3 additions & 2 deletions docs/public/bashunit-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,15 @@ cleaned up automatically and are safe under `--parallel`.

## Assertions

`bashunit doc` prints the full catalogue (75 assertions) locally; `bashunit doc contains`
`bashunit doc` prints the full catalogue (77 assertions) locally; `bashunit doc contains`
filters it. The same list is at https://bashunit.com/assertions. **Do not invent names**
— a wrong name is a runtime error, not a failed assertion.

- Equality: `assert_same`, `assert_not_same`, `assert_equals`, `assert_not_equals`
- Strings: `assert_contains`, `assert_not_contains`, `assert_matches`,
`assert_string_starts_with`, `assert_string_ends_with`, `assert_empty`, `assert_not_empty`
- Numbers: `assert_greater_than`, `assert_less_than`, `assert_within_delta`
- Numbers: `assert_greater_than`, `assert_less_than`, `assert_between`,
`assert_not_between`, `assert_within_delta`
- Exit codes: `assert_successful_code`, `assert_general_error`, `assert_exit_code`,
`assert_command_not_found`, `assert_command_available`
- Files: `assert_file_exists`, `assert_file_contains`, `assert_is_file_empty`,
Expand Down
102 changes: 101 additions & 1 deletion src/assert/core.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,71 @@ function assert_greater_or_equal_than() {
bashunit::state::add_assertions_passed
}

##
# Asserts that a numeric value falls inside an inclusive range.
# Arguments: $1 - minimum, $2 - maximum, $3 - actual, $4 - label (optional)
# Returns: 0 after reporting the assertion, 2 for invalid input
##
function assert_between() {
bashunit::assert::should_skip && return 0
if [ "$#" -lt 3 ]; then
bashunit::assert::usage_error "${FUNCNAME[0]}" 3 "min, max, actual" "$#"
return 2
fi

local min="$1"
local max="$2"
local actual="$3"
local label_override="${4:-}"

if ! bashunit::assert::_validate_range_args "${FUNCNAME[0]}" "$min" "$max" "$actual"; then
return 2
fi

if ! bashunit::math::is_le "$min" "$actual"; then
bashunit::assert::fail_with "$label_override" "$actual" "to be between" "$min and $max" \
"Violated lower bound" "$min"
return
fi

if ! bashunit::math::is_le "$actual" "$max"; then
bashunit::assert::fail_with "$label_override" "$actual" "to be between" "$min and $max" \
"Violated upper bound" "$max"
return
fi

bashunit::state::add_assertions_passed
}

##
# Asserts that a numeric value falls outside an inclusive range.
# Arguments: $1 - minimum, $2 - maximum, $3 - actual, $4 - label (optional)
# Returns: 0 after reporting the assertion, 2 for invalid input
##
function assert_not_between() {
bashunit::assert::should_skip && return 0
if [ "$#" -lt 3 ]; then
bashunit::assert::usage_error "${FUNCNAME[0]}" 3 "min, max, actual" "$#"
return 2
fi

local min="$1"
local max="$2"
local actual="$3"
local label_override="${4:-}"

if ! bashunit::assert::_validate_range_args "${FUNCNAME[0]}" "$min" "$max" "$actual"; then
return 2
fi

if bashunit::math::is_le "$min" "$actual" && bashunit::math::is_le "$actual" "$max"; then
bashunit::assert::fail_with "$label_override" "$actual" "to not be between" "$min and $max"
return
fi

bashunit::state::add_assertions_passed
}

##
# Whether a value looks like a number (integer or decimal, optional sign).
# Returns: 0 when numeric, 1 otherwise.
Expand All @@ -1039,14 +1104,49 @@ function bashunit::assert::_is_numeric() {
local value="$1"
case "$value" in
'' | *[!0-9.+-]*) return 1 ;;
-*) value=${value#-} ;;
+*) value=${value#+} ;;
esac
# Must contain at least one digit (rejects ".", "-", "+").

case "$value" in
'' | '.' | *[+-]*) return 1 ;;
*.*)
local fraction=${value#*.}
case "$fraction" in *.*) return 1 ;; esac
;;
esac

case "$value" in
*[0-9]*) return 0 ;;
esac
return 1
}

##
# Validates the shared numeric-range contract.
# Arguments: $1 - assertion name, $2 - min, $3 - max, $4 - actual
# Returns: 0 when valid, 1 after emitting a usage error otherwise
##
function bashunit::assert::_validate_range_args() {
local assertion=$1
local min=$2
local max=$3
local actual=$4

if ! bashunit::assert::_is_numeric "$min" ||
! bashunit::assert::_is_numeric "$max" ||
! bashunit::assert::_is_numeric "$actual"; then
bashunit::assert::usage_error_detail "$assertion" \
"expects numeric min, max, and actual values, got '$min', '$max', '$actual'"
return 1
fi

if ! bashunit::math::is_le "$min" "$max"; then
bashunit::assert::usage_error_detail "$assertion" "expects min <= max, got '$min' and '$max'"
return 1
fi
}

##
# Asserts the actual value is within +/- delta of the expected value:
# |actual - expected| <= delta. Supports floats via bashunit::math::calculate.
Expand Down
6 changes: 6 additions & 0 deletions src/util/math.sh
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ function bashunit::math::is_le() {
return
fi

# bc cannot parse a leading `+`: it answers with a parse error on stderr and an
# empty result, which reads as "greater than" here. The fixed-point path above
# handles the sign itself, so only the fallbacks need it stripped.
left=${left#+}
right=${right#+}

if bashunit::dependencies::has_bc; then
[ "$(echo "$left <= $right" | bc)" = "1" ]
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,26 @@ Reports an error if `actual` is not greater than or equal to `expected`.
- assert_less_or_equal_than is the inverse of this assertion and takes the same arguments.


## assert_between
--------------
> `assert_between "min" "max" "actual"`

Reports an error if `actual` is outside the inclusive numeric range from `min` to `max`.
Integers, decimals, and negative values are supported. `min` must not be greater than `max`.

- assert_not_between is the exact negation and takes the same arguments.


## assert_not_between
--------------
> `assert_not_between "min" "max" "actual"`

Reports an error if `actual` is inside the inclusive numeric range from `min` to `max`.
Integers, decimals, and negative values are supported. `min` must not be greater than `max`.

- assert_between is the exact negation and takes the same arguments.


## assert_within_delta
--------------
> `assert_within_delta "expected" "actual" "delta"`
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/assert/arity_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ function provide_core_assertions_requiring_arguments() {
bashunit::data_set assert_less_or_equal_than 2 "expected, actual"
bashunit::data_set assert_greater_than 2 "expected, actual"
bashunit::data_set assert_greater_or_equal_than 2 "expected, actual"
bashunit::data_set assert_between 3 "min, max, actual"
bashunit::data_set assert_not_between 3 "min, max, actual"
bashunit::data_set assert_within_delta 3 "expected, actual, delta"
bashunit::data_set assert_line_count 2 "expected, actual"
bashunit::data_set assert_string_matches_format 2 "format, actual"
Expand Down
Loading
Loading