From 7ef6ae0304a5dca6cb119bfc5416efb920944884 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 29 Jul 2026 06:29:15 +0200 Subject: [PATCH] feat(assert): one-call custom assertion helper and self-labelled failures Add `bashunit::assert_that [args...]`, which runs the command and marks the assertion passed or failed in a single call. The three-part contract it replaces had two silent failure modes: a missing `return` after `bashunit::assertion_failed` bumped both counters for one assertion, and a missing `bashunit::assertion_passed` left the test with zero assertions, reported as risky. Also accept an optional 4th argument on `bashunit::assertion_failed` and forward it into `bashunit::assert::fail_with`'s existing label slot, so a custom assertion can name itself in its own failure block instead of showing the test name. Both changes are additive; the existing form keeps working. Docs: document the new form, correct the `bashunit::assertion_failed` signature in globals.md (the 3rd argument is the failure condition message, not a label), and point at `--boot` / `BASHUNIT_BOOTSTRAP` for loading a shared custom-assert file once per run instead of per test file. Closes #915 --- CHANGELOG.md | 2 + docs/custom-asserts.md | 103 ++++++++++++++++++++++-- docs/globals.md | 7 +- src/bashunit.sh | 33 +++++++- tests/functional/custom_asserts.sh | 17 ++++ tests/functional/custom_asserts_test.sh | 21 +++++ tests/unit/custom_assertions_test.sh | 69 ++++++++++++++++ 7 files changed, 244 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c501dee8..84f5fc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased ### Added +- `bashunit::assert_that [args...]` writes a custom assertion in one call: it runs the command and marks the assertion passed or failed, so the two counters can no longer drift apart by a forgotten `return` or a missing `bashunit::assertion_passed` (#915) +- `bashunit::assertion_failed` takes an optional 4th argument labelling the failure block, so a custom assertion can name itself instead of showing the test name (#915) - `--snapshot-report-unused` lists snapshot files no test resolved — the ones a rename or deletion leaves behind. Reports only, never deletes; refused on partial runs (#902) - `--no-snapshot-create` / `BASHUNIT_SNAPSHOT_CREATE=false` fails on a missing snapshot instead of recording it — the recommended CI setting, since a never-committed snapshot used to make CI green while asserting nothing (#901) - `--snapshot-update` / `BASHUNIT_SNAPSHOT_UPDATE=true` re-records existing snapshots (combine with `--filter`); snapshots holding a placeholder are left alone (#900) diff --git a/docs/custom-asserts.md b/docs/custom-asserts.md index 8f965128..a539ac95 100644 --- a/docs/custom-asserts.md +++ b/docs/custom-asserts.md @@ -20,8 +20,24 @@ Custom assertions automatically display the correct **test function name** in fa ## API Reference +### assert_that +> `bashunit::assert_that [args...]` + +Runs `cmd` and marks the assertion passed or failed accordingly, in a single call. + +| Parameter | Description | +|-----------|-------------| +| `expected` | What the assertion expects, as shown in the failure block | +| `actual` | The actual value received | +| `cmd [args...]` | The command deciding the verdict: exit `0` passes, anything else fails | + +Returns `0` when the command succeeds and `1` when it fails, so it can be chained. + +The command is invoked directly, without `eval`, so arguments keep their word +boundaries and nothing is re-parsed by the shell. + ### assertion_failed -> `bashunit::assertion_failed ` +> `bashunit::assertion_failed ` Marks the current assertion as failed and prints a failure message. @@ -30,6 +46,7 @@ Marks the current assertion as failed and prints a failure message. | `expected` | The expected value | | `actual` | The actual value received | | `failure_condition_message` | Optional message describing the failure condition (default: "but got") | +| `label` | Optional name shown in the failure block (default: the test function name) | ### assertion_passed > `bashunit::assertion_passed` @@ -38,6 +55,56 @@ Marks the current assertion as passed. Call this when your custom assertion succ ## Examples +### One-call assertion + +`bashunit::assert_that` collapses the pass/fail bookkeeping into a single line, +so the two counters cannot drift apart: + +```bash +function assert_positive_number() { + bashunit::assert_that "positive number" "$1" test "$1" -gt 0 +} + +function test_value_is_positive() { + assert_positive_number 1 # Passes +} + +function test_value_is_not_positive() { + assert_positive_number 0 # Fails with: "Expected 'positive number' but got '0'" +} +``` + +Any command works as the verdict, not only `test`: + +```bash +function assert_valid_json() { + bashunit::assert_that "valid JSON" "$1" jq -e . <<< "$1" +} + +function assert_file_is_executable() { + bashunit::assert_that "an executable file" "$1" test -x "$1" +} +``` + +### Naming your own failures + +By default a failure block is labelled with the test function name. Pass a +fourth argument to `bashunit::assertion_failed` when the assertion should name +itself instead: + +```bash +function assert_http_success() { + local status_code="$1" + + if [ "$status_code" -lt 200 ] || [ "$status_code" -ge 300 ]; then + bashunit::assertion_failed "a 2xx status" "$status_code" "but got " "Assert HTTP success" + return + fi + + bashunit::assertion_passed +} +``` + ### Basic custom assertion ```bash @@ -116,15 +183,41 @@ function assert_positive_number() { } ``` +## Loading your assertions once + +Sourcing a shared assertions file from `set_up` re-runs it for every test, and +only in the file that does it. Load it once for the whole run with a bootstrap +file instead: + +```bash +./bashunit --boot tests/bootstrap.sh tests/ +``` + +```bash +# .env or bashunit.env +BASHUNIT_BOOTSTRAP="tests/bootstrap.sh" +``` + +Your `tests/bootstrap.sh` then sources the assertions: + +```bash +source "$(dirname "${BASH_SOURCE[0]}")/custom_asserts.sh" +``` + +See [Configuration](/configuration) and [Command line](/command-line) for the +full bootstrap options. + ## Best practices -1. **Always return after failure**: Call `return` after `bashunit::assertion_failed` or `bashunit::fail` to stop execution of your custom assertion. +1. **Prefer `bashunit::assert_that`**: one call marks the assertion passed or failed, so you cannot forget the `return` after a failure (which would bump both counters) or forget `bashunit::assertion_passed` (which would leave the test with zero assertions, reported as risky). + +2. **Always return after failure**: when writing the long form by hand, call `return` after `bashunit::assertion_failed` or `bashunit::fail` to stop execution of your custom assertion. -2. **Always mark success**: Call `bashunit::assertion_passed` or `state::add_assertions_passed` when your assertion succeeds. +3. **Always mark success**: Call `bashunit::assertion_passed` or `state::add_assertions_passed` when your assertion succeeds. -3. **Use descriptive names**: Name your custom assertions clearly, e.g., `assert_valid_email`, `assert_file_contains_header`. +4. **Use descriptive names**: Name your custom assertions clearly, e.g., `assert_valid_email`, `assert_file_contains_header`. -4. **Keep assertions focused**: Each custom assertion should test one specific condition. +5. **Keep assertions focused**: Each custom assertion should test one specific condition. ## Related diff --git a/docs/globals.md b/docs/globals.md index 10ae74fa..11813e9e 100644 --- a/docs/globals.md +++ b/docs/globals.md @@ -89,9 +89,12 @@ bashunit::print_line 40 '=' # 40 equals signs These helpers are intended for building [custom assertions](/custom-asserts). +- `bashunit::assert_that [args...]` — Run `cmd` and mark + the assertion passed or failed accordingly, in a single call. - `bashunit::assertion_passed` — Mark the current assertion as passed. -- `bashunit::assertion_failed ` — Mark the current - assertion as failed and print a failure report. +- `bashunit::assertion_failed ` + — Mark the current assertion as failed and print a failure report. `label` + names the assertion in that report, defaulting to the test function name. - `bashunit::fail ` — Fail the current test with an optional message. See [Custom asserts](/custom-asserts) for full examples. diff --git a/src/bashunit.sh b/src/bashunit.sh index bc111797..18e44d29 100644 --- a/src/bashunit.sh +++ b/src/bashunit.sh @@ -4,14 +4,21 @@ # to interact with the internals of bashunit. # e.g. adding custom assertions +## +# Marks the current assertion as failed and prints the standard failure block. +# Arguments: $1 - expected, $2 - actual, $3 - failure condition message +# (optional, default: "but got "), $4 - label naming the assertion in +# the failure block (optional, defaults to the test function name) +## function bashunit::assertion_failed() { bashunit::assert::should_skip && return 0 local expected=$1 local actual=$2 local failure_condition_message=${3:-"but got "} + local label=${4:-} - bashunit::assert::fail_with "" "${expected}" \ + bashunit::assert::fail_with "$label" "${expected}" \ "$failure_condition_message" "${actual}" } @@ -20,3 +27,27 @@ function bashunit::assertion_passed() { bashunit::state::add_assertions_passed } + +## +# Runs a command and marks the assertion passed or failed accordingly, so a +# custom assertion cannot desync the passed/failed counters by forgetting to +# `return` after a failure or to mark a success. +# Arguments: $1 - expected (described for the failure block), $2 - actual, +# $3.. - the command and its arguments +# Returns: 0 when the command succeeds, 1 otherwise +## +function bashunit::assert_that() { + bashunit::assert::should_skip && return 0 + + local expected=$1 + local actual=$2 + shift 2 + + if "$@"; then + bashunit::state::add_assertions_passed + return 0 + fi + + bashunit::assert::fail_with "" "$expected" "but got " "$actual" + return 1 +} diff --git a/tests/functional/custom_asserts.sh b/tests/functional/custom_asserts.sh index baacfe56..0fd4b11e 100644 --- a/tests/functional/custom_asserts.sh +++ b/tests/functional/custom_asserts.sh @@ -22,3 +22,20 @@ function assert_positive_number() { bashunit::assertion_passed } + +# Same check as assert_positive_number, written with the one-call helper. +function assert_that_positive_number() { + bashunit::assert_that "positive number" "$1" test "$1" -gt 0 +} + +# Names itself in its own failure block via the optional label argument. +function assert_labelled_foo() { + local actual="$1" + + if [ "foo" != "$actual" ]; then + bashunit::assertion_failed "foo" "${actual}" "but got " "Assert labelled foo" + return + fi + + bashunit::assertion_passed +} diff --git a/tests/functional/custom_asserts_test.sh b/tests/functional/custom_asserts_test.sh index 232338ff..06fdb90d 100644 --- a/tests/functional/custom_asserts_test.sh +++ b/tests/functional/custom_asserts_test.sh @@ -24,3 +24,24 @@ function test_assert_positive_number_failed() { "$(bashunit::console_results::print_failed_test "Assert positive number failed" "positive number" "got" "0")" \ "$(assert_positive_number "0")" } + +function test_assert_that_positive_number_passed() { + assert_that_positive_number "1" +} + +function test_assert_that_positive_number_failed() { + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Assert that positive number failed" "positive number" "but got " "0")" \ + "$(assert_that_positive_number "0" || true)" +} + +function test_assert_labelled_foo_passed() { + assert_labelled_foo "foo" +} + +function test_assert_labelled_foo_failed() { + assert_same \ + "$(bashunit::console_results::print_failed_test "Assert labelled foo" "foo" "but got " "bar")" \ + "$(assert_labelled_foo "bar")" +} diff --git a/tests/unit/custom_assertions_test.sh b/tests/unit/custom_assertions_test.sh index 74b07e37..f8a06cbf 100644 --- a/tests/unit/custom_assertions_test.sh +++ b/tests/unit/custom_assertions_test.sh @@ -110,6 +110,75 @@ function test_custom_assertion_calling_assert_same_shows_correct_test_name() { assert_not_contains "Assert same" "$output" } +function test_assert_that_counts_exactly_one_passed_assertion() { + local counters + counters="$( + _BASHUNIT_ASSERTIONS_PASSED=0 + _BASHUNIT_ASSERTIONS_FAILED=0 + bashunit::assert_that "truthy" "1" test 1 -gt 0 + echo "$_BASHUNIT_ASSERTIONS_PASSED:$_BASHUNIT_ASSERTIONS_FAILED" + )" + + assert_same "1:0" "$counters" +} + +function test_assert_that_counts_exactly_one_failed_assertion() { + local counters + counters="$( + # shellcheck disable=SC2317,SC2329 + bashunit::console_results::print_line() { :; } + _BASHUNIT_ASSERTIONS_PASSED=0 + _BASHUNIT_ASSERTIONS_FAILED=0 + _BASHUNIT_ASSERTION_FAILED_IN_TEST=0 + bashunit::assert_that "truthy" "0" test 0 -gt 0 || true + echo "$_BASHUNIT_ASSERTIONS_PASSED:$_BASHUNIT_ASSERTIONS_FAILED" + )" + + assert_same "0:1" "$counters" +} + +function test_assert_that_returns_zero_when_the_command_succeeds() { + local exit_code=0 + + bashunit::assert_that "truthy" "1" test 1 -gt 0 || exit_code=$? + + assert_same "0" "$exit_code" +} + +function test_assert_that_returns_one_when_the_command_fails() { + local exit_code + exit_code="$( + # shellcheck disable=SC2317,SC2329 + bashunit::console_results::print_line() { :; } + _BASHUNIT_ASSERTION_FAILED_IN_TEST=0 + local ec=0 + bashunit::assert_that "truthy" "0" test 0 -gt 0 || ec=$? + echo "$ec" + )" + + assert_same "1" "$exit_code" +} + +function test_assertion_failed_uses_the_optional_label_over_the_test_name() { + local output + output="$( + _captured_output="" + # shellcheck disable=SC2317,SC2329 + bashunit::console_results::print_line() { + _captured_output="$2" + echo "$_captured_output" + } + + _BASHUNIT_ASSERTION_FAILED_IN_TEST=0 + bashunit::assertion_failed "positive number" "-5" "but got " "My own label" + + echo "$_captured_output" + )" + + assert_contains "My own label" "$output" + assert_not_contains "Assertion failed uses the optional label" "$output" +} + function test_helper_find_test_function_name_finds_test() { # Test that bashunit::helper::find_test_function_name correctly finds the test function local found_name