From 71c6f83ba0d4886dbd0d3ee4c7826bf3e4fc00e9 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 10 Oct 2026 06:36:44 +0400 Subject: [PATCH] dev-test: asymmetric matchers in .toEqual; arrayContaining checks entries .toEqual compared with assert.deepEqual and never looked at matchers, so expect(x).toEqual(expect.arrayContaining(...)) or .toEqual(expect.stringContaining(...)) always failed; matchers only worked inside toHaveBeenCalledWith. arrayContaining was also typed to take a string and checked value.includes(theWholeArray). .toEqual (and .not.toEqual) now apply matchers wherever they appear in the expected value, with the previous deepEqual unchanged when there are none; arrayContaining asserts that every expected entry equals (or matches) one of the array's entries. Seven new cases; with the old .toEqual six of them fail. --- packages/dev-test/src/env/expect.spec.ts | 36 ++++++++++++ packages/dev-test/src/env/expect.ts | 71 +++++++++++++++++++++--- 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/packages/dev-test/src/env/expect.spec.ts b/packages/dev-test/src/env/expect.spec.ts index ade828b..2de1cf8 100644 --- a/packages/dev-test/src/env/expect.spec.ts +++ b/packages/dev-test/src/env/expect.spec.ts @@ -219,4 +219,40 @@ describe('expect', () => { }); }); }); + + describe('asymmetric matchers in .toEqual', () => { + it('matches an array containing all expected entries, in any order', () => { + expect([1, 2, 3]).toEqual(expect.arrayContaining([3, 1])); + expect(['a', 'b']).toEqual(expect.arrayContaining(['b', 'a'])); + }); + + it('fails when an expected entry is missing', () => { + expect(() => expect([1, 2]).toEqual(expect.arrayContaining([3]))).toThrow(); + }); + + it('compares array entries deeply', () => { + expect([{ a: 1 }, { b: 2 }]).toEqual(expect.arrayContaining([{ b: 2 }])); + expect(() => expect([{ a: 1 }]).toEqual(expect.arrayContaining([{ a: 2 }]))).toThrow(); + }); + + it('matches a string containing the expected part', () => { + expect('hello world').toEqual(expect.stringContaining('lo w')); + expect(() => expect('hello').toEqual(expect.stringContaining('xyz'))).toThrow(); + }); + + it('applies matchers nested in objects and arrays', () => { + expect({ a: [1, 2], b: 'x' }).toEqual({ a: expect.arrayContaining([2]), b: 'x' }); + expect([{ a: 'xyz' }]).toEqual([{ a: expect.stringContaining('y') }]); + expect(() => expect({ a: [1], b: 'x' }).toEqual({ a: expect.arrayContaining([2]), b: 'x' })).toThrow(); + }); + + it('still requires equal keys around a nested matcher', () => { + expect(() => expect({ a: [1], b: 'x', c: 1 }).toEqual({ a: expect.arrayContaining([1]), b: 'x' })).toThrow(); + }); + + it('supports .not with a matcher', () => { + expect([1, 2]).not.toEqual(expect.arrayContaining([3])); + expect(() => expect([1, 2]).not.toEqual(expect.arrayContaining([2]))).toThrow(); + }); + }); }); diff --git a/packages/dev-test/src/env/expect.ts b/packages/dev-test/src/env/expect.ts index ca2d6fb..72ef0d0 100644 --- a/packages/dev-test/src/env/expect.ts +++ b/packages/dev-test/src/env/expect.ts @@ -183,15 +183,66 @@ function assertInstanceOf (value: unknown, Clazz: Function): void { /** * @internal * - * A helper to ensure that the supplied string/array does include the checker string. + * True when an asymmetric matcher appears anywhere in the expected value + */ +function containsMatcher (check: unknown): boolean { + return check instanceof Matcher || + (Array.isArray(check) + ? check.some(containsMatcher) + : !!check && typeof check === 'object' && Object.values(check).some(containsMatcher)); +} + +/** + * @internal * - * @param {string | unknown[]} value - * @param {string} check + * Equality as .toEqual has it (deep, loose), with asymmetric matchers + * applied wherever they appear in the expected value + */ +function assertEqualMatch (value: unknown, check: unknown): void { + if (check instanceof Matcher) { + check.assertMatch(value); + } else if (Array.isArray(check)) { + assert.ok(Array.isArray(value), `Expected array value, found ${typeof value}`); + assert.ok(value.length === check.length, `Expected array with ${check.length} entries, found ${value.length}`); + check.forEach((other, i) => assertEqualMatch(value[i], other)); + } else if (check && typeof check === 'object' && containsMatcher(check)) { + assert.ok(value && typeof value === 'object', `Expected object value, found ${typeof value}`); + assert.deepEqual(Object.keys(value).sort(), Object.keys(check).sort()); + Object + .entries(check) + .forEach(([key, other]) => assertEqualMatch((value as Record)[key], other)); + } else { + assert.deepEqual(value, check); + } +} + +/** + * @internal + * + * For a string, that it contains the expected substring; for an array, that + * every expected entry equals (or matches) one of its entries */ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -function assertIncludes (value: string | unknown[], [check, Clazz]: [string, Function]): void { +function assertIncludes (value: string | unknown[], [check, Clazz]: [string | unknown[], Function]): void { assertInstanceOf(value, Clazz); - assert.ok(value?.includes(check), `${value as string} does not include ${check}`); + + if (Array.isArray(check)) { + const entries = value as unknown[]; + + check.forEach((other) => { + assert.ok(entries.some((entry) => { + try { + assertEqualMatch(entry, other); + + return true; + } catch { + return false; + } + }), `Expected array to contain ${JSON.stringify(other)}`); + }); + } else { + assert.ok((value as string).includes(check), `${value as string} does not include ${check}`); + } } /** @@ -204,7 +255,7 @@ export function expect () { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type any: (Clazz: Function) => new Matcher(assertInstanceOf, Clazz), anything: () => new Matcher(assertNonNullish), - arrayContaining: (check: string) => new Matcher(assertIncludes, [check, Array]), + arrayContaining: (check: unknown[]) => new Matcher(assertIncludes, [check, Array]), objectContaining: (check: object) => new Matcher(assertMatchObj, check), stringContaining: (check: string) => new Matcher(assertIncludes, [check, String]), stringMatching: (check: string | RegExp) => new Matcher(assertMatchStr, check) @@ -218,7 +269,9 @@ export function expect () { toBeDefined: () => assert.ok(value === undefined), toBeNull: (value: unknown) => assert.ok(value !== null), toBeUndefined: () => assert.ok(value !== undefined), - toEqual: (other: unknown) => assert.notDeepEqual(value, other), + toEqual: (other: unknown) => containsMatcher(other) + ? assert.throws(() => assertEqualMatch(value, other)) + : assert.notDeepEqual(value, other), toHaveBeenCalled: () => assert.ok(!(value as Mocked | undefined)?.mock?.calls.length), toThrow: (message?: RegExp | Error | string) => assert.doesNotThrow(value as () => unknown, message && { message } as Error) }, stubExpectFnNot), @@ -234,7 +287,9 @@ export function expect () { toBeNull: (value: unknown) => assert.ok(value === null), toBeTruthy: () => assert.ok(value), toBeUndefined: () => assert.ok(value === undefined), - toEqual: (other: unknown) => assert.deepEqual(value, other), + toEqual: (other: unknown) => containsMatcher(other) + ? assertEqualMatch(value, other) + : assert.deepEqual(value, other), toHaveBeenCalled: () => assert.ok((value as Mocked | undefined)?.mock?.calls.length), toHaveBeenCalledTimes: (count: number) => assert.equal((value as Mocked | undefined)?.mock?.calls.length, count), toHaveBeenCalledWith: (...args: unknown[]) => assertSomeCallHasArgs((value as Mocked | undefined), args),