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 @@ -3,6 +3,8 @@
## Unreleased

### Added
- `bashunit::assert_that <expected> <actual> <cmd> [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)
Expand Down
103 changes: 98 additions & 5 deletions docs/custom-asserts.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,24 @@ Custom assertions automatically display the correct **test function name** in fa

## API Reference

### assert_that
> `bashunit::assert_that <expected> <actual> <cmd> [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 <expected> <actual> <failure_condition_message?>`
> `bashunit::assertion_failed <expected> <actual> <failure_condition_message?> <label?>`

Marks the current assertion as failed and prints a failure message.

Expand All @@ -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`
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions docs/globals.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,12 @@ bashunit::print_line 40 '=' # 40 equals signs

These helpers are intended for building [custom assertions](/custom-asserts).

- `bashunit::assert_that <expected> <actual> <cmd> [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 <expected> <actual> <?label>` — Mark the current
assertion as failed and print a failure report.
- `bashunit::assertion_failed <expected> <actual> <?failure_condition_message> <?label>`
— 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 <?message>` — Fail the current test with an optional message.

See [Custom asserts](/custom-asserts) for full examples.
Expand Down
33 changes: 32 additions & 1 deletion src/bashunit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}

Expand All @@ -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
}
17 changes: 17 additions & 0 deletions tests/functional/custom_asserts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
21 changes: 21 additions & 0 deletions tests/functional/custom_asserts_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")"
}
69 changes: 69 additions & 0 deletions tests/unit/custom_assertions_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading