From 709c071494683775fa608ee5337f9326275adf25 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 10 Oct 2026 12:51:13 +0400 Subject: [PATCH] expect: fix toBeNull, add not.toHaveLength and toHaveProperty, toThrow matches as jest does - toBeNull and not.toBeNull took a parameter named value, which shadowed the value under test: .toBeNull() always failed and .not.toBeNull() always passed. - not.toHaveLength and toHaveProperty (dotted or array path, optional expected value with asymmetric matchers) were unimplemented stubs. - toThrow and rejects.toThrow with a string required the message to be equal; as in jest, the message now has to contain it. A RegExp matches the message and an Error compares messages. --- packages/dev-test/src/env/expect.spec.ts | 65 ++++++++++++++++++++++++ packages/dev-test/src/env/expect.ts | 55 ++++++++++++++++++-- 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/packages/dev-test/src/env/expect.spec.ts b/packages/dev-test/src/env/expect.spec.ts index b14a0c3..f692748 100644 --- a/packages/dev-test/src/env/expect.spec.ts +++ b/packages/dev-test/src/env/expect.spec.ts @@ -1,6 +1,8 @@ // Copyright 2017-2026 @pezkuwi/dev-test authors & contributors // SPDX-License-Identifier: Apache-2.0 +import { strict as assert } from 'node:assert'; + describe('expect', () => { it('has been decorated', () => { expect(expect(true).not).toBeDefined(); @@ -24,6 +26,58 @@ describe('expect', () => { Promise.reject(new Error('this is a rejection message')) ).rejects.toThrow(/rejection/); }); + + it('matches a rejection containing the message', async () => { + await expect( + Promise.reject(new Error('Unable to find an element by: [data-testid="x"]\n\n
')) + ).rejects.toThrow('Unable to find an element by: [data-testid="x"]'); + }); + + it('fails on a rejection without the message', async () => { + // typed as void, it returns the promise of assert.rejects + await assert.rejects(() => + Promise.resolve(expect(Promise.reject(new Error('another reason'))).rejects.toThrow('rejection')) + ); + }); + }); + + describe('.toBeNull', () => { + it('passes on null, and fails on anything else', () => { + expect(null).toBeNull(); + expect(() => expect(undefined).toBeNull()).toThrow(); + expect(() => expect(0).toBeNull()).toThrow(); + }); + + it('supports .not', () => { + expect(undefined).not.toBeNull(); + expect(() => expect(null).not.toBeNull()).toThrow(); + }); + }); + + describe('.toHaveLength', () => { + it('supports .not', () => { + expect([1]).not.toHaveLength(0); + expect(() => expect([]).not.toHaveLength(0)).toThrow(); + }); + }); + + describe('.toHaveProperty', () => { + const value = { a: { b: [1, 2] }, c: undefined }; + + it('finds a property by key, dotted path or array path', () => { + expect(value).toHaveProperty('c'); + expect(value).toHaveProperty('a.b'); + expect(value).toHaveProperty(['a', 'b', '1']); + expect(() => expect(value).toHaveProperty('a.d')).toThrow(); + expect(() => expect(null).toHaveProperty('a')).toThrow(); + }); + + it('compares the value when one is given', () => { + expect(value).toHaveProperty('a.b', [1, 2]); + expect(value).toHaveProperty('a', { b: expect.arrayContaining([2]) }); + expect(() => expect(value).toHaveProperty('a.b', [1])).toThrow(); + expect(() => expect(value).toHaveProperty('c', null)).toThrow(); + }); }); describe('.toBeDefined', () => { @@ -59,6 +113,17 @@ describe('expect', () => { expect(thrower).toThrow(/me er/); }); + it('matches error containing the message', () => { + expect(thrower).toThrow('some'); + expect(thrower).toThrow(new Error('some error')); + }); + + it('fails on an error without the message', () => { + expect(() => expect(thrower).toThrow('other')).toThrow('Expected an error containing "other", found "some error"'); + expect(() => expect(thrower).toThrow(/other/)).toThrow(); + expect(() => expect(thrower).toThrow(new Error('some'))).toThrow(); + }); + it('handles .not correctly (no throw, empty message)', () => { expect(() => undefined).not.toThrow(); }); diff --git a/packages/dev-test/src/env/expect.ts b/packages/dev-test/src/env/expect.ts index 2a2fec9..6093aaa 100644 --- a/packages/dev-test/src/env/expect.ts +++ b/packages/dev-test/src/env/expect.ts @@ -279,6 +279,51 @@ function assertIncludes (value: string | unknown[], [check, Clazz]: [string | un } } +/** + * @internal + * + * As .toThrow(check) has it: a string is contained in the message, a RegExp + * matches it, an Error has the same message. Returns true, as the validation + * functions of assert.throws and assert.rejects must + */ +function assertErrorMatches (error: unknown, check?: RegExp | Error | string): true { + const message = error instanceof Error + ? error.message + : String(error); + + if (typeof check === 'string') { + assert.ok(message.includes(check), `Expected an error containing "${check}", found "${message}"`); + } else if (check instanceof RegExp) { + assert.match(message, check); + } else if (check) { + assert.strictEqual(message, check.message); + } + + return true; +} + +/** + * @internal + * + * As .toHaveProperty(path, expected?) has it: the (dotted or array) path + * exists, and when an expected value is given, equals it + */ +function assertHasProperty (value: unknown, path: string | string[], expected: unknown[]): void { + const keys = Array.isArray(path) + ? path + : path.split('.'); + let current = value; + + for (const key of keys) { + assert.ok(current !== null && current !== undefined && key in Object(current), `Expected a property at "${keys.join('.')}"`); + current = (current as Record)[key]; + } + + if (expected.length) { + assertEqualMatch(current, expected[0]); + } +} + /** * Sets up the shimmed expect(...) function, including all .to* and .not.to* * functions. This is not comprehensive, rather is contains what we need to @@ -301,17 +346,18 @@ export function expect () { not: enhanceObj({ toBe: (other: unknown) => assert.notStrictEqual(value, other), toBeDefined: () => assert.ok(value === undefined), - toBeNull: (value: unknown) => assert.ok(value !== null), + toBeNull: () => assert.ok(value !== null, 'Expected a value other than null'), toBeUndefined: () => assert.ok(value !== undefined), toContain: (item: unknown) => assertNotContains(value, item), toEqual: (other: unknown) => containsMatcher(other) ? assert.throws(() => assertEqualMatch(value, other)) : assert.notDeepEqual(value, other), toHaveBeenCalled: () => assert.ok(!(value as Mocked | undefined)?.mock?.calls.length), + toHaveLength: (length: number) => assert.notEqual((value as unknown[] | undefined)?.length, length), toThrow: (message?: RegExp | Error | string) => assert.doesNotThrow(value as () => unknown, message && { message } as Error) }, stubExpectFnNot), rejects: enhanceObj({ - toThrow: (message?: RegExp | Error | string) => assert.rejects(value as Promise, message && { message } as Error) + toThrow: (check?: RegExp | Error | string) => assert.rejects(value as Promise, (error) => assertErrorMatches(error, check)) }, stubExpectFnRejects), resolves: enhanceObj({}, stubExpectFnResolves), toBe: (other: unknown) => assert.strictEqual(value, other), @@ -319,7 +365,7 @@ export function expect () { toBeFalsy: () => assert.ok(!value), // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type toBeInstanceOf: (Clazz: Function) => assertInstanceOf(value, Clazz), - toBeNull: (value: unknown) => assert.ok(value === null), + toBeNull: () => assert.ok(value === null, `Expected null, found ${String(value)}`), toBeTruthy: () => assert.ok(value), toBeUndefined: () => assert.ok(value === undefined), toContain: (item: unknown) => assertContains(value, item), @@ -331,9 +377,10 @@ export function expect () { toHaveBeenCalledWith: (...args: unknown[]) => assertSomeCallHasArgs((value as Mocked | undefined), args), toHaveBeenLastCalledWith: (...args: unknown[]) => assertCallHasArgs((value as Mocked | undefined)?.mock?.calls.at(-1), args), toHaveLength: (length: number) => assert.equal((value as unknown[] | undefined)?.length, length), + toHaveProperty: (path: string | string[], ...expected: unknown[]) => assertHasProperty(value, path, expected), toMatch: (check: string | RegExp) => assertMatchStr(value, check), toMatchObject: (check: object) => assertMatchObj(value, check), - toThrow: (message?: RegExp | Error | string) => assert.throws(value as () => unknown, message && { message } as Error) + toThrow: (check?: RegExp | Error | string) => assert.throws(value as () => unknown, (error) => assertErrorMatches(error, check)) }, stubExpectFn), rootMatchers), stubExpect) }; }