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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <text|tap|json|junit>` 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 <cmd>` and `bashunit::skip_on <windows|macos|linux>` mark a test skipped **and** end it, replacing `bashunit::skip && return`, whose missing `return` silently kept the body running (#1019)
- Per-test `# @timeout <seconds>`, `# @retry <n>` 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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff 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 84 assertions plus spies, mocks, data providers, snapshots and more.
It ships 86 assertions plus spies, mocks, data providers, snapshots and more.

## Quick start

Expand Down
2 changes: 1 addition & 1 deletion completions/_bashunit
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion completions/bashunit.bash
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 \
Expand Down
40 changes: 39 additions & 1 deletion docs/assertions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand DownExpand Up@@ -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"`

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

Expand Down
87 changes: 87 additions & 0 deletions src/assert/json.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -698,13 +698,29 @@ 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"`

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`
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/assert/arity_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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() {
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/assert/json_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(assert): add JSON absence and length assertions by ColumbusLabs · Pull Request #1069 · TypedDevs/bashunit · GitHub
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <text|tap|json|junit>` 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 <cmd>` and `bashunit::skip_on <windows|macos|linux>` mark a test skipped **and** end it, replacing `bashunit::skip && return`, whose missing `return` silently kept the body running (#1019)
- Per-test `# @timeout <seconds>`, `# @retry <n>` 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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff 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 84 assertions plus spies, mocks, data providers, snapshots and more.
It ships 86 assertions plus spies, mocks, data providers, snapshots and more.

## Quick start

Expand Down
2 changes: 1 addition & 1 deletion completions/_bashunit
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion completions/bashunit.bash
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 \
Expand Down
40 changes: 39 additions & 1 deletion docs/assertions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand DownExpand Up@@ -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"`

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

Expand Down
87 changes: 87 additions & 0 deletions src/assert/json.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -698,13 +698,29 @@ 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"`

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`
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/assert/arity_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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() {
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/assert/json_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(assert): add JSON absence and length assertions by ColumbusLabs · Pull Request #1069 · TypedDevs/bashunit · GitHub
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <text|tap|json|junit>` 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 <cmd>` and `bashunit::skip_on <windows|macos|linux>` mark a test skipped **and** end it, replacing `bashunit::skip && return`, whose missing `return` silently kept the body running (#1019)
- Per-test `# @timeout <seconds>`, `# @retry <n>` 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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff 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 84 assertions plus spies, mocks, data providers, snapshots and more.
It ships 86 assertions plus spies, mocks, data providers, snapshots and more.

## Quick start

Expand Down
2 changes: 1 addition & 1 deletion completions/_bashunit
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion completions/bashunit.bash
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 \
Expand Down
40 changes: 39 additions & 1 deletion docs/assertions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand DownExpand Up@@ -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"`

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

Expand Down
87 changes: 87 additions & 0 deletions src/assert/json.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -698,13 +698,29 @@ 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"`

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`
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/assert/arity_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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() {
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/assert/json_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(assert): add JSON absence and length assertions by ColumbusLabs · Pull Request #1069 · TypedDevs/bashunit · GitHub
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <text|tap|json|junit>` 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 <cmd>` and `bashunit::skip_on <windows|macos|linux>` mark a test skipped **and** end it, replacing `bashunit::skip && return`, whose missing `return` silently kept the body running (#1019)
- Per-test `# @timeout <seconds>`, `# @retry <n>` 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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff 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 84 assertions plus spies, mocks, data providers, snapshots and more.
It ships 86 assertions plus spies, mocks, data providers, snapshots and more.

## Quick start

Expand Down
2 changes: 1 addition & 1 deletion completions/_bashunit
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion completions/bashunit.bash
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 \
Expand Down
40 changes: 39 additions & 1 deletion docs/assertions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand DownExpand Up@@ -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"`

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

Expand Down
87 changes: 87 additions & 0 deletions src/assert/json.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -698,13 +698,29 @@ 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"`

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`
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/assert/arity_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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() {
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/assert/json_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(assert): add JSON absence and length assertions by ColumbusLabs · Pull Request #1069 · TypedDevs/bashunit · GitHub
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <text|tap|json|junit>` 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 <cmd>` and `bashunit::skip_on <windows|macos|linux>` mark a test skipped **and** end it, replacing `bashunit::skip && return`, whose missing `return` silently kept the body running (#1019)
- Per-test `# @timeout <seconds>`, `# @retry <n>` 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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff 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 84 assertions plus spies, mocks, data providers, snapshots and more.
It ships 86 assertions plus spies, mocks, data providers, snapshots and more.

## Quick start

Expand Down
2 changes: 1 addition & 1 deletion completions/_bashunit
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion completions/bashunit.bash
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 \
Expand Down
40 changes: 39 additions & 1 deletion docs/assertions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand DownExpand Up@@ -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"`

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

Expand Down
87 changes: 87 additions & 0 deletions src/assert/json.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -698,13 +698,29 @@ 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"`

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`
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/assert/arity_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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() {
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/assert/json_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(assert): add JSON absence and length assertions by ColumbusLabs · Pull Request #1069 · TypedDevs/bashunit · GitHub
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <text|tap|json|junit>` 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 <cmd>` and `bashunit::skip_on <windows|macos|linux>` mark a test skipped **and** end it, replacing `bashunit::skip && return`, whose missing `return` silently kept the body running (#1019)
- Per-test `# @timeout <seconds>`, `# @retry <n>` 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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff 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 84 assertions plus spies, mocks, data providers, snapshots and more.
It ships 86 assertions plus spies, mocks, data providers, snapshots and more.

## Quick start

Expand Down
2 changes: 1 addition & 1 deletion completions/_bashunit
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion completions/bashunit.bash
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 \
Expand Down
40 changes: 39 additions & 1 deletion docs/assertions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand DownExpand Up@@ -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"`

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

Expand Down
87 changes: 87 additions & 0 deletions src/assert/json.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -698,13 +698,29 @@ 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"`

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`
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/assert/arity_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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() {
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/assert/json_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(assert): add JSON absence and length assertions by ColumbusLabs · Pull Request #1069 · TypedDevs/bashunit · GitHub
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <text|tap|json|junit>` 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 <cmd>` and `bashunit::skip_on <windows|macos|linux>` mark a test skipped **and** end it, replacing `bashunit::skip && return`, whose missing `return` silently kept the body running (#1019)
- Per-test `# @timeout <seconds>`, `# @retry <n>` 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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff 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 84 assertions plus spies, mocks, data providers, snapshots and more.
It ships 86 assertions plus spies, mocks, data providers, snapshots and more.

## Quick start

Expand Down
2 changes: 1 addition & 1 deletion completions/_bashunit
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion completions/bashunit.bash
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 \
Expand Down
40 changes: 39 additions & 1 deletion docs/assertions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand DownExpand Up@@ -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"`

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

Expand Down
87 changes: 87 additions & 0 deletions src/assert/json.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -698,13 +698,29 @@ 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"`

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`
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/assert/arity_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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() {
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/assert/json_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(assert): add JSON absence and length assertions by ColumbusLabs · Pull Request #1069 · TypedDevs/bashunit · GitHub
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <text|tap|json|junit>` 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 <cmd>` and `bashunit::skip_on <windows|macos|linux>` mark a test skipped **and** end it, replacing `bashunit::skip && return`, whose missing `return` silently kept the body running (#1019)
- Per-test `# @timeout <seconds>`, `# @retry <n>` 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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff 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 84 assertions plus spies, mocks, data providers, snapshots and more.
It ships 86 assertions plus spies, mocks, data providers, snapshots and more.

## Quick start

Expand Down
2 changes: 1 addition & 1 deletion completions/_bashunit
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion completions/bashunit.bash
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 \
Expand Down
40 changes: 39 additions & 1 deletion docs/assertions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand DownExpand Up@@ -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"`

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

Expand Down
87 changes: 87 additions & 0 deletions src/assert/json.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -698,13 +698,29 @@ 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"`

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`
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/assert/arity_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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() {
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/assert/json_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
}
Loading