From ced83fc6d7ec08f7b28990052f092b1fce72b863 Mon Sep 17 00:00:00 2001 From: Alisue Date: Fri, 9 Jan 2026 11:12:40 +0900 Subject: [PATCH] feat(@probitas/expect): allow PropertySatisfying matcher to handle null/undefined values Previously, toHavePropertySatisfying performed automatic property existence checks and rejected null/undefined values before calling the matcher. This prevented users from validating null values, checking for non-existent properties, or handling broken object paths. Since PropertySatisfying is designed for custom user-defined validation logic, users should have full control over all validation aspects, including property existence checks. This change removes the automatic guards and always invokes the matcher with the actual value (or undefined if the property path is invalid). Changes: - Remove toHaveProperty existence check before matcher execution - Remove ensureNonNullish call that rejected null/undefined - Simplify error handling to only report matcher failures - Add test cases for null values and broken intermediate paths --- .../object_value_mixin_test.ts.snap | 12 ++--- .../mixin/object_value_mixin.ts | 22 +-------- .../mixin/object_value_mixin_test.ts | 49 +++++++++++++++++++ 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/packages/probitas-expect/mixin/__snapshots__/object_value_mixin_test.ts.snap b/packages/probitas-expect/mixin/__snapshots__/object_value_mixin_test.ts.snap index f838f0e..c8627ed 100644 --- a/packages/probitas-expect/mixin/__snapshots__/object_value_mixin_test.ts.snap +++ b/packages/probitas-expect/mixin/__snapshots__/object_value_mixin_test.ts.snap @@ -109,10 +109,10 @@ Context (packages/probitas-expect/mixin/object_value_mixin_test.ts:236:5) 236│ await assertSnapshotWithoutColors( 237│ t, ┆ - 509│ t, - 510│ catchError(() => applied.toHaveUserMatching({ name: "Bob" })).message, + 558│ t, + 559│ catchError(() => applied.toHaveUserMatching({ name: "Bob" })).message, │ ^ - 511│ );' + 560│ );' `; snapshot[`createObjectValueMixin - toHaveUserMatching with source context 2`] = ` @@ -132,8 +132,8 @@ snapshot[`createObjectValueMixin - toHaveUserMatching with source context 2`] = 251│ const applied = mixin({ dummy: true }); 252│ await assertSnapshotWithoutColors(  ┆ - 526│ t, - 527│ catchError(() => applied.toHaveUserMatching({ name: "Bob" })).message, + 575│ t, + 576│ catchError(() => applied.toHaveUserMatching({ name: "Bob" })).message,  │ ^ - 528│ );' + 577│ );' `; diff --git a/packages/probitas-expect/mixin/object_value_mixin.ts b/packages/probitas-expect/mixin/object_value_mixin.ts index e5878c8..9c1c626 100644 --- a/packages/probitas-expect/mixin/object_value_mixin.ts +++ b/packages/probitas-expect/mixin/object_value_mixin.ts @@ -4,7 +4,6 @@ import { Any, buildMatchingExpected, buildPropertyExpected, - ensureNonNullish, formatValue, toPascalCase, tryOk, @@ -318,18 +317,9 @@ export function createObjectValueMixin< const obj = getter.call(this); let matcherError: Error | undefined; - let propertyExists = false; try { - // @std/expect mutates array keyPath, so we need to copy it - const keyPathCopy = Array.isArray(keyPath) ? [...keyPath] : keyPath; - stdExpect(obj).toHaveProperty(keyPathCopy); - propertyExists = true; - const keyPathStr = Array.isArray(keyPath) ? keyPath.join(".") : keyPath; - const value = ensureNonNullish( - getPropertyValue(obj, keyPath), - keyPathStr, - ); + const value = getPropertyValue(obj, keyPath); matcher(value); } catch (error) { if (error instanceof Error) { @@ -344,16 +334,6 @@ export function createObjectValueMixin< if (isNegated ? passes : !passes) { const keyPathStr = Array.isArray(keyPath) ? keyPath.join(".") : keyPath; - if (!propertyExists && !isNegated) { - throw createExpectationError({ - message: - `Expected ${valueName} property "${keyPathStr}" to exist and satisfy the matcher, but it does not exist`, - expectOrigin: config.expectOrigin, - theme: config.theme, - subject: config.subject, - }); - } - throw createExpectationError({ message: isNegated ? `Expected ${valueName} property "${keyPathStr}" to not satisfy the matcher, but it did` diff --git a/packages/probitas-expect/mixin/object_value_mixin_test.ts b/packages/probitas-expect/mixin/object_value_mixin_test.ts index 3fec7e6..ed446df 100644 --- a/packages/probitas-expect/mixin/object_value_mixin_test.ts +++ b/packages/probitas-expect/mixin/object_value_mixin_test.ts @@ -492,6 +492,55 @@ Deno.test("createObjectValueMixin - toHaveUserPropertySatisfying", async (t) => ).message, ); }); + + await t.step("success - null value", () => { + const mixin = createObjectValueMixin( + () => ({ value: null }), + () => false, + { valueName: "data" }, + ); + const applied = mixin({ dummy: true }); + assertEquals( + applied.toHaveDataPropertySatisfying("value", (v) => { + if (v !== null) throw new Error("Must be null"); + }), + applied, + ); + }); + + await t.step("success - nested path with null intermediate", () => { + const mixin = createObjectValueMixin( + () => ({ user: null }), + () => false, + { valueName: "data" }, + ); + const applied = mixin({ dummy: true }); + assertEquals( + applied.toHaveDataPropertySatisfying("user.profile.age", (v) => { + if (v !== undefined) { + throw new Error("Must be undefined when intermediate is null"); + } + }), + applied, + ); + }); + + await t.step("success - nested path with undefined intermediate", () => { + const mixin = createObjectValueMixin( + () => ({ user: { profile: undefined } }), + () => false, + { valueName: "data" }, + ); + const applied = mixin({ dummy: true }); + assertEquals( + applied.toHaveDataPropertySatisfying("user.profile.age", (v) => { + if (v !== undefined) { + throw new Error("Must be undefined when intermediate is undefined"); + } + }), + applied, + ); + }); }); Deno.test("createObjectValueMixin - toHaveUserMatching with source context", async (t) => {