diff --git a/CHANGELOG.md b/CHANGELOG.md index 829bf6d5..58c1ee25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- `assert_json_key_not_exists` checks that a JSON path is absent, while `assert_json_length` checks the size of an array, object, or string (#1025) - `--output ` prints the JSON and JUnit reports on stdout, suppressing every console rendering so a pipeline needs no temp file; `--report-json` still writes its file alongside (#1018) - `bashunit::skip_if`, `bashunit::skip_unless`, `bashunit::skip_unless_command ` and `bashunit::skip_on ` mark a test skipped **and** end it, replacing `bashunit::skip && return`, whose missing `return` silently kept the body running (#1019) - Per-test `# @timeout `, `# @retry ` and `# @skip [reason]` annotations override the run-wide `--test-timeout` and `--retry` in both directions; `@timeout 0` opts one test out of a global timeout and a malformed value aborts the run (#1020) diff --git a/README.md b/README.md index 58b05657..ba008193 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 84 assertions plus spies, mocks, data providers, snapshots and more. +It ships 86 assertions plus spies, mocks, data providers, snapshots and more. ## Quick start diff --git a/completions/_bashunit b/completions/_bashunit index 7f156bd6..f4238eee 100644 --- a/completions/_bashunit +++ b/completions/_bashunit @@ -30,7 +30,7 @@ _bashunit() { assert_is_directory_not_empty assert_is_directory_not_readable assert_is_directory_not_writable assert_is_directory_readable assert_is_directory_writable assert_is_file assert_is_file_empty assert_is_not_symlink assert_is_symlink - assert_json_contains assert_json_equals assert_json_key_exists + assert_json_contains assert_json_equals assert_json_key_exists assert_json_key_not_exists assert_json_length 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 diff --git a/completions/bashunit.bash b/completions/bashunit.bash index 5cd640d1..ab6e851c 100644 --- a/completions/bashunit.bash +++ b/completions/bashunit.bash @@ -44,7 +44,7 @@ assert_greater_than assert_is_directory assert_is_directory_empty \ assert_is_directory_not_empty assert_is_directory_not_readable \ assert_is_directory_not_writable assert_is_directory_readable \ assert_is_directory_writable assert_is_file assert_is_file_empty assert_is_not_symlink assert_is_symlink \ -assert_json_contains assert_json_equals assert_json_key_exists \ +assert_json_contains assert_json_equals assert_json_key_exists assert_json_key_not_exists assert_json_length \ 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 \ diff --git a/docs/assertions.md b/docs/assertions.md index bbc53c51..909c4b5d 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -38,7 +38,7 @@ trailing label override, so `assert_contains "zzz" "abc" "my label"` searches | **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) | -| **JSON** | [assert_json_equals](#assert-json-equals) · [assert_json_contains](#assert-json-contains) · [assert_json_key_exists](#assert-json-key-exists) | +| **JSON** | [assert_json_equals](#assert-json-equals) · [assert_json_contains](#assert-json-contains) · [assert_json_key_exists](#assert-json-key-exists) · [assert_json_key_not_exists](#assert-json-key-not-exists) · [assert_json_length](#assert-json-length) | | **Duration** | [assert_duration](#assert-duration) · [assert_duration_less_than](#assert-duration-less-than) · [assert_duration_greater_than](#assert-duration-greater-than) | | **Snapshots** | [assert_match_snapshot](#assert-match-snapshot) · [assert_match_named_snapshot](#assert-match-named-snapshot) · [assert_match_snapshot_ignore_colors](#assert-match-snapshot-ignore-colors) · [assert_match_named_snapshot_ignore_colors](#assert-match-named-snapshot-ignore-colors) | | **Spies** | [assert_have_been_called](#assert-have-been-called) · [assert_not_called](#assert-not-called) · [assert_have_been_called_with](#assert-have-been-called-with) · [assert_have_been_called_with_any](#assert-have-been-called-with-any) · [assert_have_been_called_with_args](#assert-have-been-called-with-args) · [assert_have_been_called_nth_with](#assert-have-been-called-nth-with) · [assert_have_been_called_times](#assert-have-been-called-times) | @@ -1730,6 +1730,25 @@ function test_failure() { ``` ::: +## assert_json_key_not_exists +> `assert_json_key_not_exists "key" "json"` + +Reports an error if `key` exists in the JSON string. Uses [jq](https://jqlang.github.io/jq/) syntax for key paths. Requires `jq` to be installed; if missing the test is skipped. + +A key with a `null` value still exists and makes this assertion fail. Invalid JSON also fails the assertion. + +::: code-group +```bash [Example] +function test_success() { + assert_json_key_not_exists ".user.password" '{"user":{"name":"bashunit"}}' +} + +function test_failure() { + assert_json_key_not_exists ".user.password" '{"user":{"password":null}}' +} +``` +::: + ## assert_json_equals > `assert_json_equals "expected" "actual"` @@ -1747,6 +1766,25 @@ function test_failure() { ``` ::: +## assert_json_length +> `assert_json_length "expected" "key" "json"` + +Reports an error if the array, object, or string at `key` does not have the expected length. Arrays count elements, objects count key-value pairs, and strings count Unicode codepoints, following `jq`'s `length` behavior. A missing path, unsupported value type, invalid JSON, or non-numeric expected length fails instead of being compared as empty or zero. Requires `jq` to be installed; if missing the test is skipped. + +::: code-group +```bash [Example] +function test_success() { + assert_json_length 3 ".items" '{"items":[1,2,3]}' + assert_json_length 2 ".metadata" '{"metadata":{"page":1,"total":3}}' +} + +function test_failure() { + assert_json_length 2 ".items" '{"items":[1,2,3]}' + assert_json_length 0 ".missing" '{"items":[]}' +} +``` +::: + ## assert_duration > `assert_duration "command" threshold_ms` diff --git a/src/assert/json.sh b/src/assert/json.sh index 0a0be553..326f68ec 100644 --- a/src/assert/json.sh +++ b/src/assert/json.sh @@ -24,6 +24,38 @@ function assert_json_key_exists() { bashunit::state::add_assertions_passed } +function assert_json_key_not_exists() { + bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "key, json" "$#" + return 2 + fi + bashunit::assert_json::require_jq || return 0 + + local key="$1" + local json="$2" + + if ! printf '%s' "$json" | jq -e 'true' >/dev/null 2>&1; then + bashunit::assert::fail_with "" "${json}" "to be valid JSON" "" + return + fi + + local exists + if ! exists=$(printf '%s' "$json" | jq -r \ + "(path($key)) as \$path | if \$path == [] then true else any(paths; . == \$path) end" \ + 2>/dev/null); then + bashunit::assert::fail_with "" "${json}" "to use a valid key path" "${key}" + return + fi + + if [ "$exists" = true ]; then + bashunit::assert::fail_with "" "${json}" "to not have key" "${key}" + return + fi + + bashunit::state::add_assertions_passed +} + function assert_json_contains() { bashunit::assert::should_skip && return 0 bashunit::assert_json::require_jq || return 0 @@ -70,3 +102,58 @@ function assert_json_equals() { bashunit::state::add_assertions_passed } + +function assert_json_length() { + bashunit::assert::should_skip && return 0 + if [ "$#" -lt 3 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 3 "expected, key, json" "$#" + return 2 + fi + + local expected="$1" + local key="$2" + local json="$3" + + case "$expected" in + '' | *[!0-9]*) + bashunit::assert::usage_error_detail "${FUNCNAME[0]}" \ + "expects a non-negative integer length, got '$expected'" + return 2 + ;; + esac + + bashunit::assert_json::require_jq || return 0 + + if ! printf '%s' "$json" | jq -e 'true' >/dev/null 2>&1; then + bashunit::assert::fail_with "" "${json}" "to be valid JSON" "" + return + fi + + local exists + if ! exists=$(printf '%s' "$json" | jq -r \ + "(path($key)) as \$path | if \$path == [] then true else any(paths; . == \$path) end" \ + 2>/dev/null); then + bashunit::assert::fail_with "" "${json}" "to use a valid key path" "${key}" + return + fi + if [ "$exists" != true ]; then + bashunit::assert::fail_with "" "${json}" "to have path" "${key}" + return + fi + + local actual + local length_filter + length_filter="$key | if type == \"array\" or type == \"object\" or type == \"string\"" + length_filter="$length_filter then length else error(\"unsupported type\") end" + if ! actual=$(printf '%s' "$json" | jq -r "$length_filter" 2>/dev/null); then + bashunit::assert::fail_with "" "${json}" "to have a measurable length at path" "${key}" + return + fi + + if [ "$actual" != "$expected" ]; then + bashunit::assert::fail_with "" "${expected}" "but got " "${actual}" + return + fi + + bashunit::state::add_assertions_passed +} 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 0b80acbb..14d6ad74 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 @@ -698,6 +698,15 @@ A key whose value is `null` or `false` is reported as missing, the same guard assert_json_key_exists uses. +## assert_json_key_not_exists +-------------- +> `assert_json_key_not_exists "key" "json"` + +Reports an error if `key` exists in the JSON string. Uses jq(https://jqlang.github.io/jq/) syntax for key paths. Requires `jq` to be installed; if missing the test is skipped. + +A key with a `null` value still exists and makes this assertion fail. Invalid JSON also fails the assertion. + + ## assert_json_equals -------------- > `assert_json_equals "expected" "actual"` @@ -705,6 +714,13 @@ assert_json_key_exists uses. Reports an error if the two JSON strings are not structurally equal. Key order is ignored. Requires `jq` to be installed; if missing the test is skipped. +## assert_json_length +-------------- +> `assert_json_length "expected" "key" "json"` + +Reports an error if the array, object, or string at `key` does not have the expected length. Arrays count elements, objects count key-value pairs, and strings count Unicode codepoints, following `jq`'s `length` behavior. A missing path, unsupported value type, invalid JSON, or non-numeric expected length fails instead of being compared as empty or zero. Requires `jq` to be installed; if missing the test is skipped. + + ## assert_duration -------------- > `assert_duration "command" threshold_ms` diff --git a/tests/unit/assert/arity_test.sh b/tests/unit/assert/arity_test.sh index 8a49941f..bd11ea5e 100644 --- a/tests/unit/assert/arity_test.sh +++ b/tests/unit/assert/arity_test.sh @@ -50,6 +50,8 @@ function provide_core_assertions_requiring_arguments() { 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" + bashunit::data_set assert_json_key_not_exists 2 "key, json" + bashunit::data_set assert_json_length 3 "expected, key, json" } function test_empty_values_still_count_as_supplied_arguments() { diff --git a/tests/unit/assert/json_test.sh b/tests/unit/assert/json_test.sh index 0583cc8e..3776402a 100644 --- a/tests/unit/assert/json_test.sh +++ b/tests/unit/assert/json_test.sh @@ -103,3 +103,120 @@ function test_unsuccessful_assert_json_equals_when_both_sides_are_invalid_json() "$expected" "but got " "$actual")" \ "$(assert_json_equals "$expected" "$actual")" } + +function test_successful_assert_json_key_not_exists() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + assert_empty "$(assert_json_key_not_exists ".password" '{"name":"bashunit"}')" +} + +function test_unsuccessful_assert_json_key_not_exists_when_key_is_present() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + local json='{"password":"secret"}' + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert json key not exists when key is present" \ + "$json" "to not have key" ".password")" \ + "$(assert_json_key_not_exists ".password" "$json")" +} + +function test_unsuccessful_assert_json_key_not_exists_when_value_is_null() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + local json='{"password":null}' + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert json key not exists when value is null" \ + "$json" "to not have key" ".password")" \ + "$(assert_json_key_not_exists ".password" "$json")" +} + +function test_unsuccessful_assert_json_key_not_exists_when_json_is_invalid() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + local json='not json' + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert json key not exists when json is invalid" \ + "$json" "to be valid JSON" "")" \ + "$(assert_json_key_not_exists ".password" "$json")" +} + +function test_successful_assert_json_length_for_array() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + assert_empty "$(assert_json_length 3 ".items" '{"items":[1,2,3]}')" +} + +function test_successful_assert_json_length_for_object() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + assert_empty "$(assert_json_length 2 ".item" '{"item":{"name":"bashunit","version":1}}')" +} + +function test_successful_assert_json_length_for_string() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + assert_empty "$(assert_json_length 8 ".name" '{"name":"bashunit"}')" +} + +function test_unsuccessful_assert_json_length() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert json length" "2" "but got " "3")" \ + "$(assert_json_length 2 ".items" '{"items":[1,2,3]}')" +} + +function test_unsuccessful_assert_json_length_when_path_is_missing() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + local json='{"items":[]}' + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert json length when path is missing" \ + "$json" "to have path" ".missing")" \ + "$(assert_json_length 0 ".missing" "$json")" +} + +function test_unsuccessful_assert_json_length_when_json_is_invalid() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + local json='not json' + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert json length when json is invalid" \ + "$json" "to be valid JSON" "")" \ + "$(assert_json_length 0 ".items" "$json")" +} + +function test_unsuccessful_assert_json_length_when_value_has_no_collection_length() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + local json='{"item":null}' + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert json length when value has no collection length" \ + "$json" "to have a measurable length at path" ".item")" \ + "$(assert_json_length 0 ".item" "$json")" +} + +function test_assert_json_length_rejects_non_numeric_expected_length() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + local output exit_code=0 + + output=$(assert_json_length nope ".items" '{"items":[]}' 2>&1) || exit_code=$? + + assert_same 2 "$exit_code" + assert_same \ + "bashunit: assertion usage error: assert_json_length expects a non-negative integer length, got 'nope'" \ + "$output" +} + +function test_new_json_assertions_use_the_existing_missing_jq_behavior() { + local existing not_exists length + existing=$(PATH="" assert_json_key_exists ".name" '{}') + not_exists=$(PATH="" assert_json_key_not_exists ".name" '{}') + length=$(PATH="" assert_json_length 0 ".items" '{}') + + assert_same "$existing" "$not_exists" + assert_same "$existing" "$length" +}