diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d89f7a9..afbd1875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `--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) - `# @tags a b` above any top-level line applies those tags to every test in the file, unioned with per-function `# @tag` (#1008) - `--tag` accepts expressions: `'a&&b'` (AND) and `'!a'` (NOT), combinable as `'a&&!b'`. Repeated `--tag` flags keep OR semantics, and `--exclude-tag` still wins (#1008) +- `assert_command_available ` asserts that an external command, shell builtin or function resolves through `command -v` (#1027) - The coverage engine in use is reported by `--verbose`, and an explicit `BASHUNIT_COVERAGE_ENGINE=xtrace` that the running Bash cannot honour now warns instead of being silently ignored (#1005) ### Changed diff --git a/README.md b/README.md index e46dc2df..78f8754b 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ ## Why bashunit A lightweight, fast testing framework for **Bash 3.0+**, focused on developer experience. -It ships 73 assertions plus spies, mocks, data providers, snapshots and more. +It ships 75 assertions plus spies, mocks, data providers, snapshots and more. ## Quick start diff --git a/completions/_bashunit b/completions/_bashunit index 8202c39f..62724ebf 100644 --- a/completions/_bashunit +++ b/completions/_bashunit @@ -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_not_found assert_contains + assert_assertion_passes 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 diff --git a/completions/bashunit.bash b/completions/bashunit.bash index 2836bb4f..0f2aa29b 100644 --- a/completions/bashunit.bash +++ b/completions/bashunit.bash @@ -30,7 +30,7 @@ _BASHUNIT_COMPLETIONS_TEST_OPTS="--assert --boot --coverage --coverage-exclude \ _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_not_found \ +assert_assertion_fails_with assert_assertion_passes 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 \ diff --git a/docs/assertions.md b/docs/assertions.md index de9c15f2..19d0bba0 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -22,7 +22,7 @@ to narrow it (`bashunit doc json`). | **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) | | **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_not_found](#assert-command-not-found) · [assert_exec](#assert-exec) | +| **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) | | **Directories** | [assert_directory_exists](#assert-directory-exists) · [assert_directory_not_exists](#assert-directory-not-exists) · [assert_is_directory](#assert-is-directory) · [assert_is_directory_empty](#assert-is-directory-empty) · [assert_is_directory_not_empty](#assert-is-directory-not-empty) · [assert_is_directory_readable](#assert-is-directory-readable) · [assert_is_directory_not_readable](#assert-is-directory-not-readable) · [assert_is_directory_writable](#assert-is-directory-writable) · [assert_is_directory_not_writable](#assert-is-directory-not-writable) | | **Arrays** | [assert_arrays_equal](#assert-arrays-equal) · [assert_array_contains](#assert-array-contains) · [assert_array_not_contains](#assert-array-not-contains) · [assert_array_length](#assert-array-length) | @@ -832,6 +832,33 @@ function test_failure() { ``` ::: +## assert_command_available +> `assert_command_available "command"` + +Reports an error if `command` is not available. + +Availability uses the same `command -v` check as the +[bashunit::is_command_available](/globals#bashunit-is-command-available) helper, so +external commands, shell builtins and shell functions are supported. The command +is only resolved; it is not executed. + +::: code-group +```bash [Example] +function test_dependencies_are_installed() { + assert_command_available bash + assert_command_available jq +} + +function test_shell_function_is_available() { + function project_build() { + make build + } + + assert_command_available project_build +} +``` +::: + ## assert_command_not_found > `assert_command_not_found` diff --git a/docs/public/bashunit-skill.md b/docs/public/bashunit-skill.md index 82dbcc8b..7f08f7cc 100644 --- a/docs/public/bashunit-skill.md +++ b/docs/public/bashunit-skill.md @@ -118,7 +118,7 @@ cleaned up automatically and are safe under `--parallel`. ## Assertions -`bashunit doc` prints the full catalogue (73 assertions) locally; `bashunit doc contains` +`bashunit doc` prints the full catalogue (75 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. @@ -127,7 +127,7 @@ filters it. The same list is at https://bashunit.com/assertions. **Do not invent `assert_string_starts_with`, `assert_string_ends_with`, `assert_empty`, `assert_not_empty` - Numbers: `assert_greater_than`, `assert_less_than`, `assert_within_delta` - Exit codes: `assert_successful_code`, `assert_general_error`, `assert_exit_code`, - `assert_command_not_found` + `assert_command_not_found`, `assert_command_available` - Files: `assert_file_exists`, `assert_file_contains`, `assert_is_file_empty`, `assert_directory_exists`, `assert_file_permissions` - Arrays: `assert_array_contains`, `assert_array_length`, `assert_arrays_equal` diff --git a/src/assert/core.sh b/src/assert/core.sh index c03a287b..e0954866 100755 --- a/src/assert/core.sh +++ b/src/assert/core.sh @@ -814,6 +814,32 @@ function assert_general_error() { bashunit::state::add_assertions_passed } +## +# Reports an error unless the command resolves through +# bashunit::is_command_available, so builtins and shell functions count as +# available exactly as they do for that helper. The command is resolved, never +# executed. +# Arguments: $1 - command, $2 - label override (optional) +## +function assert_command_available() { + bashunit::assert::should_skip && return 0 + if [ "$#" -lt 1 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 1 "command" "$#" + return 2 + fi + + local command="$1" + local label_override="${2:-}" + + if ! bashunit::is_command_available "$command"; then + bashunit::assert::fail_with "${label_override:-}" \ + "${command}" "to be available but was" "not found" + return + fi + + bashunit::state::add_assertions_passed +} + function assert_command_not_found() { local actual_exit_code=${3-"$?"} # Capture $? before guard check local label_override="" diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot index 3d150e4f..eaa54f5f 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot @@ -319,6 +319,18 @@ Use assert_exec if you want to pass a command as a string and check its exit cod - assert_exit_code is the full version of this assertion where you can specify the expected exit code. +## assert_command_available +-------------- +> `assert_command_available "command"` + +Reports an error if `command` is not available. + +Availability uses the same `command -v` check as the +bashunit::is_command_available(/globals#bashunit-is-command-available) helper, so +external commands, shell builtins and shell functions are supported. The command +is only resolved; it is not executed. + + ## assert_command_not_found -------------- > `assert_command_not_found` diff --git a/tests/unit/assert/advanced_test.sh b/tests/unit/assert/advanced_test.sh index fc29dd07..0660f9fb 100644 --- a/tests/unit/assert/advanced_test.sh +++ b/tests/unit/assert/advanced_test.sh @@ -55,6 +55,57 @@ function test_unsuccessful_assert_command_not_found() { "$(assert_command_not_found "$(fake_function)")" } +function test_successful_assert_command_available_for_external_command() { + assert_empty "$(assert_command_available bash)" +} + +function test_successful_assert_command_available_for_builtin() { + assert_empty "$(assert_command_available printf)" +} + +function test_successful_assert_command_available_for_shell_function() { + function available_shell_function() { + # shellcheck disable=SC2317 # Never invoked: the assertion only resolves it. + : + } + + assert_empty "$(assert_command_available available_shell_function)" +} + +function test_unsuccessful_assert_command_available_names_command() { + local command_name="bashunit_command_that_does_not_exist" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert command available names command" \ + "$command_name" "to be available but was" "not found")" \ + "$(assert_command_available "$command_name")" +} + +function test_unsuccessful_assert_command_available_with_custom_label() { + assert_same \ + "$(bashunit::console_results::print_failed_test "my custom label" \ + "bashunit_command_that_does_not_exist" "to be available but was" "not found")" \ + "$(assert_command_available "bashunit_command_that_does_not_exist" "my custom label")" +} + +function test_assert_command_available_moves_the_assertion_counters() { + assert_assertion_passes assert_command_available printf + assert_assertion_fails assert_command_available "bashunit_command_that_does_not_exist" +} + +# The two never agree about the same command. Both run inside $() so their +# failures stay in the subshell instead of moving this test's counters. +function test_assert_command_available_is_the_inverse_of_assert_command_not_found() { + # Missing: not_found passes on the 127, available fails. + assert_empty "$(assert_command_not_found "$(bashunit_command_that_does_not_exist 2>/dev/null)")" + assert_not_empty "$(assert_command_available "bashunit_command_that_does_not_exist")" + + # Present: available passes, not_found fails on the 0. + assert_empty "$(assert_command_available printf)" + assert_not_empty "$(assert_command_not_found "$(printf '')")" +} + function test_successful_assert_exec() { # shellcheck disable=SC2317 function fake_command() { diff --git a/tests/unit/assert/arity_test.sh b/tests/unit/assert/arity_test.sh index 285f087c..21aa71b1 100644 --- a/tests/unit/assert/arity_test.sh +++ b/tests/unit/assert/arity_test.sh @@ -1,17 +1,23 @@ #!/usr/bin/env bash -# @data_provider provide_core_comparison_assertions -function test_core_comparison_assertions_reject_missing_arguments() { +# @data_provider provide_core_assertions_requiring_arguments +function test_core_assertions_reject_missing_arguments() { local assertion=$1 local required=$2 local signature=$3 local output exit_code=0 - if [ "$required" -eq 3 ]; then - output=$("$assertion" "first" "second" 2>&1) || exit_code=$? - else - output=$("$assertion" "first" 2>&1) || exit_code=$? - fi + # One argument short of the signature, whatever the arity. The `+` guard keeps + # an empty list expandable under `set -u` on Bash 3.x (a one-argument + # assertion is called with nothing at all). + local supplied=1 + local -a args=() + while [ "$supplied" -lt "$required" ]; do + args[${#args[@]}]="arg${supplied}" + supplied=$((supplied + 1)) + done + + output=$("$assertion" "${args[@]+"${args[@]}"}" 2>&1) || exit_code=$? assert_same 2 "$exit_code" assert_same \ @@ -19,7 +25,7 @@ function test_core_comparison_assertions_reject_missing_arguments() { "$output" } -function provide_core_comparison_assertions() { +function provide_core_assertions_requiring_arguments() { bashunit::data_set assert_same 2 "expected, actual" bashunit::data_set assert_equals 2 "expected, actual" bashunit::data_set assert_not_same 2 "expected, actual" @@ -41,6 +47,7 @@ function provide_core_comparison_assertions() { bashunit::data_set assert_line_count 2 "expected, actual" bashunit::data_set assert_string_matches_format 2 "format, actual" bashunit::data_set assert_string_not_matches_format 2 "format, actual" + bashunit::data_set assert_command_available 1 "command" } function test_empty_values_still_count_as_supplied_arguments() {