diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ee8f5a66..6584f9ad 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -79,7 +79,7 @@ jobs: run: | docker run --rm -v "$(pwd)":/project alpine:latest /bin/sh -c " \ apk update && \ - apk add --no-cache bash make git && \ + apk add --no-cache bash make git jq && \ adduser -D builder && \ chown -R builder /project && \ su - builder -c 'cd /project; make test';" diff --git a/CHANGELOG.md b/CHANGELOG.md index 51bc2e6d..ef4b16a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Add `assert_have_been_called_nth_with` for verifying arguments on the Nth invocation of a spy - Add `assert_string_matches_format` and `assert_string_not_matches_format` with format placeholders (`%d`, `%s`, `%f`, `%i`, `%x`, `%e`, `%%`) +- Add JSON assertions: `assert_json_key_exists`, `assert_json_contains`, `assert_json_equals` (requires `jq`) ### Changed - Split Windows CI test jobs into parallel chunks to avoid timeouts diff --git a/docs/assertions.md b/docs/assertions.md index 1eb9fdab..55b4c54b 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -1216,6 +1216,60 @@ function test_failure() { ``` ::: +## assert_json_key_exists +> `assert_json_key_exists "key" "json"` + +Reports an error if `key` does not exist 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. + +::: code-group +```bash [Example] +function test_success() { + assert_json_key_exists ".name" '{"name":"bashunit","version":"1.0"}' + assert_json_key_exists ".data.id" '{"data":{"id":42}}' +} + +function test_failure() { + assert_json_key_exists ".missing" '{"name":"bashunit"}' +} +``` +::: + +## assert_json_contains +> `assert_json_contains "key" "expected" "json"` + +Reports an error if `key` does not exist in the JSON string or its value does not equal `expected`. Uses [jq](https://jqlang.github.io/jq/) syntax for key paths. Requires `jq` to be installed; if missing the test is skipped. + +::: code-group +```bash [Example] +function test_success() { + assert_json_contains ".name" "bashunit" '{"name":"bashunit","version":"1.0"}' + assert_json_contains ".count" "42" '{"count":42}' +} + +function test_failure() { + assert_json_contains ".name" "other" '{"name":"bashunit"}' + assert_json_contains ".missing" "value" '{"name":"bashunit"}' +} +``` +::: + +## 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. + +::: code-group +```bash [Example] +function test_success() { + assert_json_equals '{"b":2,"a":1}' '{"a":1,"b":2}' +} + +function test_failure() { + assert_json_equals '{"a":1}' '{"a":2}' +} +``` +::: + ## bashunit::fail > `bashunit::fail "failure message"` diff --git a/src/assert_json.sh b/src/assert_json.sh new file mode 100644 index 00000000..8ec163a1 --- /dev/null +++ b/src/assert_json.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +function bashunit::assert_json::require_jq() { + if ! command -v jq >/dev/null 2>&1; then + bashunit::skip "jq is required for JSON assertions" + return 1 + fi + return 0 +} + +function assert_json_key_exists() { + bashunit::assert::should_skip && return 0 + bashunit::assert_json::require_jq || return 0 + + local key="$1" + local json="$2" + + local result + if ! result=$(printf '%s' "$json" | jq -e "$key" 2>/dev/null) || [ "$result" = "null" ]; then + local test_fn + test_fn="$(bashunit::helper::find_test_function_name)" + local label + label="$(bashunit::helper::normalize_test_function_name "$test_fn")" + bashunit::assert::mark_failed + bashunit::console_results::print_failed_test "${label}" "${json}" "to 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 + + local key="$1" + local expected="$2" + local json="$3" + + local result + if ! result=$(printf '%s' "$json" | jq -e -r "$key" 2>/dev/null) || [ "$result" = "null" ]; then + local test_fn + test_fn="$(bashunit::helper::find_test_function_name)" + local label + label="$(bashunit::helper::normalize_test_function_name "$test_fn")" + bashunit::assert::mark_failed + bashunit::console_results::print_failed_test "${label}" "${json}" "to have key" "${key}" + return + fi + + if [ "$result" != "$expected" ]; then + local test_fn + test_fn="$(bashunit::helper::find_test_function_name)" + local label + label="$(bashunit::helper::normalize_test_function_name "$test_fn")" + bashunit::assert::mark_failed + bashunit::console_results::print_failed_test "${label}" "${expected}" "but got " "${result}" + return + fi + + bashunit::state::add_assertions_passed +} + +function assert_json_equals() { + bashunit::assert::should_skip && return 0 + bashunit::assert_json::require_jq || return 0 + + local expected="$1" + local actual="$2" + + local expected_sorted + expected_sorted=$(printf '%s' "$expected" | jq -S '.' 2>/dev/null) + local actual_sorted + actual_sorted=$(printf '%s' "$actual" | jq -S '.' 2>/dev/null) + + if [ "$expected_sorted" != "$actual_sorted" ]; then + local test_fn + test_fn="$(bashunit::helper::find_test_function_name)" + local label + label="$(bashunit::helper::normalize_test_function_name "$test_fn")" + bashunit::assert::mark_failed + bashunit::console_results::print_failed_test "${label}" "${expected}" "but got " "${actual}" + return + fi + + bashunit::state::add_assertions_passed +} diff --git a/src/assertions.sh b/src/assertions.sh index 2b76b4ce..c612afb8 100644 --- a/src/assertions.sh +++ b/src/assertions.sh @@ -4,6 +4,7 @@ source "$BASHUNIT_ROOT_DIR/src/assert.sh" source "$BASHUNIT_ROOT_DIR/src/assert_arrays.sh" source "$BASHUNIT_ROOT_DIR/src/assert_files.sh" source "$BASHUNIT_ROOT_DIR/src/assert_folders.sh" +source "$BASHUNIT_ROOT_DIR/src/assert_json.sh" source "$BASHUNIT_ROOT_DIR/src/assert_snapshot.sh" source "$BASHUNIT_ROOT_DIR/src/skip_todo.sh" source "$BASHUNIT_ROOT_DIR/src/test_doubles.sh" diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_filtered_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_filtered_assert_docs.snapshot index 63c6ee31..fad9346d 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_filtered_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_filtered_assert_docs.snapshot @@ -23,3 +23,11 @@ Reports an error if `expected` and `actual` are not equals. Reports an error if `expected` and `actual` are not equals. - assert_files_equals is the inverse of this assertion and takes the same arguments. + + +## 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. + diff --git a/tests/unit/assert_json_test.sh b/tests/unit/assert_json_test.sh new file mode 100644 index 00000000..885a2016 --- /dev/null +++ b/tests/unit/assert_json_test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +_JQ_AVAILABLE=false +if command -v jq >/dev/null 2>&1; then + _JQ_AVAILABLE=true +fi + +function test_successful_assert_json_key_exists() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + assert_empty "$(assert_json_key_exists ".name" '{"name":"bashunit","version":"1.0"}')" +} + +function test_successful_assert_json_key_exists_nested() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + assert_empty "$(assert_json_key_exists ".data.id" '{"data":{"id":42}}')" +} + +function test_unsuccessful_assert_json_key_exists() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + local json='{"name":"bashunit"}' + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert json key exists" \ + "$json" "to have key" ".missing")" \ + "$(assert_json_key_exists ".missing" "$json")" +} + +function test_successful_assert_json_contains() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + assert_empty "$(assert_json_contains ".name" "bashunit" '{"name":"bashunit","version":"1.0"}')" +} + +function test_successful_assert_json_contains_numeric() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + assert_empty "$(assert_json_contains ".count" "42" '{"count":42}')" +} + +function test_unsuccessful_assert_json_contains_wrong_value() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + local json='{"name":"bashunit"}' + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert json contains wrong value" \ + "other" "but got " "bashunit")" \ + "$(assert_json_contains ".name" "other" "$json")" +} + +function test_unsuccessful_assert_json_contains_missing_key() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + local json='{"name":"bashunit"}' + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert json contains missing key" \ + "$json" "to have key" ".missing")" \ + "$(assert_json_contains ".missing" "value" "$json")" +} + +function test_successful_assert_json_equals() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + assert_empty "$(assert_json_equals '{"b":2,"a":1}' '{"a":1,"b":2}')" +} + +function test_unsuccessful_assert_json_equals() { + if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi + local expected='{"a":1}' + local actual='{"a":2}' + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert json equals" \ + "$expected" "but got " "$actual")" \ + "$(assert_json_equals "$expected" "$actual")" +}