From 06bae4ddab764fe11843115710158278f9e812cd Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 15 Jan 2024 23:02:15 +0900 Subject: [PATCH] fix(expect): implement chai inspect for `AsymmetricMatcher` (#4942) Co-authored-by: Vladimir --- .../expect/src/jest-asymmetric-matchers.ts | 10 + .../__snapshots__/jest-expect.test.ts.snap | 229 ++++++++++++++++++ test/core/test/jest-expect.test.ts | 72 +++++- 3 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 test/core/test/__snapshots__/jest-expect.test.ts.snap diff --git a/packages/expect/src/jest-asymmetric-matchers.ts b/packages/expect/src/jest-asymmetric-matchers.ts index 3879b02af992..c4df97ff51c9 100644 --- a/packages/expect/src/jest-asymmetric-matchers.ts +++ b/packages/expect/src/jest-asymmetric-matchers.ts @@ -41,6 +41,16 @@ export abstract class AsymmetricMatcher< abstract toString(): string getExpectedType?(): string toAsymmetricMatcher?(): string + + // implement custom chai/loupe inspect for better AssertionError.message formatting + // https://github.com/chaijs/loupe/blob/9b8a6deabcd50adc056a64fb705896194710c5c6/src/index.ts#L29 + [Symbol.for('chai/inspect')](options: { depth: number; truncate: number }) { + // minimal pretty-format with simple manual truncation + const result = stringify(this, options.depth, { min: true }) + if (result.length <= options.truncate) + return result + return `${this.toString()}{…}` + } } export class StringContaining extends AsymmetricMatcher { diff --git a/test/core/test/__snapshots__/jest-expect.test.ts.snap b/test/core/test/__snapshots__/jest-expect.test.ts.snap new file mode 100644 index 000000000000..e451d39894c0 --- /dev/null +++ b/test/core/test/__snapshots__/jest-expect.test.ts.snap @@ -0,0 +1,229 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`asymmetric matcher error 1`] = ` +{ + "actual": "hello", + "diff": null, + "expected": "StringContaining "xx"", + "message": "expected 'hello' to deeply equal StringContaining "xx"", +} +`; + +exports[`asymmetric matcher error 2`] = ` +{ + "actual": "hello", + "diff": null, + "expected": "StringNotContaining "ll"", + "message": "expected 'hello' to deeply equal StringNotContaining "ll"", +} +`; + +exports[`asymmetric matcher error 3`] = ` +{ + "actual": "Object { + "foo": "hello", +}", + "diff": "- Expected ++ Received + + Object { +- "foo": StringContaining "xx", ++ "foo": "hello", + }", + "expected": "Object { + "foo": StringContaining "xx", +}", + "message": "expected { foo: 'hello' } to deeply equal { foo: StringContaining "xx" }", +} +`; + +exports[`asymmetric matcher error 4`] = ` +{ + "actual": "Object { + "foo": "hello", +}", + "diff": "- Expected ++ Received + + Object { +- "foo": StringNotContaining "ll", ++ "foo": "hello", + }", + "expected": "Object { + "foo": StringNotContaining "ll", +}", + "message": "expected { foo: 'hello' } to deeply equal { foo: StringNotContaining "ll" }", +} +`; + +exports[`asymmetric matcher error 5`] = ` +{ + "actual": "hello", + "diff": "- Expected: +stringContainingCustom + ++ Received: +"hello"", + "expected": "stringContainingCustom", + "message": "expected 'hello' to deeply equal stringContainingCustom", +} +`; + +exports[`asymmetric matcher error 6`] = ` +{ + "actual": "hello", + "diff": "- Expected: +not.stringContainingCustom + ++ Received: +"hello"", + "expected": "not.stringContainingCustom", + "message": "expected 'hello' to deeply equal not.stringContainingCustom", +} +`; + +exports[`asymmetric matcher error 7`] = ` +{ + "actual": "Object { + "foo": "hello", +}", + "diff": "- Expected ++ Received + + Object { +- "foo": stringContainingCustom, ++ "foo": "hello", + }", + "expected": "Object { + "foo": stringContainingCustom, +}", + "message": "expected { foo: 'hello' } to deeply equal { foo: stringContainingCustom }", +} +`; + +exports[`asymmetric matcher error 8`] = ` +{ + "actual": "Object { + "foo": "hello", +}", + "diff": "- Expected ++ Received + + Object { +- "foo": not.stringContainingCustom, ++ "foo": "hello", + }", + "expected": "Object { + "foo": not.stringContainingCustom, +}", + "message": "expected { foo: 'hello' } to deeply equal { foo: not.stringContainingCustom }", +} +`; + +exports[`asymmetric matcher error 9`] = ` +{ + "actual": "undefined", + "diff": undefined, + "expected": "undefined", + "message": "expected "hello" to contain "xx"", +} +`; + +exports[`asymmetric matcher error 10`] = ` +{ + "actual": "undefined", + "diff": undefined, + "expected": "undefined", + "message": "expected "hello" not to contain "ll"", +} +`; + +exports[`asymmetric matcher error 11`] = ` +{ + "actual": "hello", + "diff": "- Expected: +testComplexMatcher<[object Object]> + ++ Received: +"hello"", + "expected": "testComplexMatcher<[object Object]>", + "message": "expected 'hello' to deeply equal testComplexMatcher<[object Object]>", +} +`; + +exports[`asymmetric matcher error 12`] = ` +{ + "actual": "Object { + "k": "v", + "k2": "v2", +}", + "diff": "- Expected ++ Received + +- ObjectContaining { ++ Object { + "k": "v", +- "k3": "v3", ++ "k2": "v2", + }", + "expected": "ObjectContaining { + "k": "v", + "k3": "v3", +}", + "message": "expected { k: 'v', k2: 'v2' } to deeply equal ObjectContaining {"k": "v", "k3": "v3"}", +} +`; + +exports[`asymmetric matcher error 13`] = ` +{ + "actual": "Array [ + "a", + "b", +]", + "diff": "- Expected ++ Received + +- ArrayContaining [ ++ Array [ + "a", +- "c", ++ "b", + ]", + "expected": "ArrayContaining [ + "a", + "c", +]", + "message": "expected [ 'a', 'b' ] to deeply equal ArrayContaining ["a", "c"]", +} +`; + +exports[`asymmetric matcher error 14`] = ` +{ + "actual": "hello", + "diff": null, + "expected": "StringMatching /xx/", + "message": "expected 'hello' to deeply equal StringMatching /xx/", +} +`; + +exports[`asymmetric matcher error 15`] = ` +{ + "actual": "2.5", + "diff": "- Expected ++ Received + +- NumberCloseTo 2 (1 digit) ++ 2.5", + "expected": "NumberCloseTo 2 (1 digit)", + "message": "expected 2.5 to deeply equal NumberCloseTo 2 (1 digit)", +} +`; + +exports[`asymmetric matcher error 16`] = ` +{ + "actual": "hello", + "diff": null, + "expected": "StringContaining "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"", + "message": "expected 'hello' to deeply equal StringContaining{…}", +} +`; diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 81a4e5703010..814853ff3a6a 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -180,7 +180,7 @@ describe('jest-expect', () => { }).toEqual({ sum: expect.closeTo(0.4), }) - }).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected { sum: 0.30000000000000004 } to deeply equal { sum: CloseTo{ …(4) } }]`) + }).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected { sum: 0.30000000000000004 } to deeply equal { sum: NumberCloseTo 0.4 (2 digits) }]`) }) it('asymmetric matchers negate', () => { @@ -947,7 +947,7 @@ it('toHaveProperty error diff', () => { // non match value (with asymmetric matcher) expect(getError(() => expect({ name: 'foo' }).toHaveProperty('name', expect.any(Number)))).toMatchInlineSnapshot(` [ - "expected { name: 'foo' } to have property "name" with value Any{ …(3) }", + "expected { name: 'foo' } to have property "name" with value Any", "- Expected: Any @@ -959,7 +959,7 @@ it('toHaveProperty error diff', () => { // non match key (with asymmetric matcher) expect(getError(() => expect({ noName: 'foo' }).toHaveProperty('name', expect.any(Number)))).toMatchInlineSnapshot(` [ - "expected { noName: 'foo' } to have property "name" with value Any{ …(3) }", + "expected { noName: 'foo' } to have property "name" with value Any", "- Expected: Any @@ -993,4 +993,70 @@ it('toHaveProperty error diff', () => { `) }) +it('asymmetric matcher error', () => { + setupColors(getDefaultColors()) + + function snapshotError(f: () => unknown) { + try { + f() + return expect.unreachable() + } + catch (error) { + const e = processError(error) + expect({ + message: e.message, + diff: e.diff, + expected: e.expected, + actual: e.actual, + }).toMatchSnapshot() + } + } + + expect.extend({ + stringContainingCustom(received: unknown, other: string) { + return { + pass: typeof received === 'string' && received.includes(other), + message: () => `expected ${this.utils.printReceived(received)} ${this.isNot ? 'not ' : ''}to contain ${this.utils.printExpected(other)}`, + } + }, + }) + + // builtin: stringContaining + snapshotError(() => expect('hello').toEqual(expect.stringContaining('xx'))) + snapshotError(() => expect('hello').toEqual(expect.not.stringContaining('ll'))) + snapshotError(() => expect({ foo: 'hello' }).toEqual({ foo: expect.stringContaining('xx') })) + snapshotError(() => expect({ foo: 'hello' }).toEqual({ foo: expect.not.stringContaining('ll') })) + + // custom + snapshotError(() => expect('hello').toEqual((expect as any).stringContainingCustom('xx'))) + snapshotError(() => expect('hello').toEqual((expect as any).not.stringContainingCustom('ll'))) + snapshotError(() => expect({ foo: 'hello' }).toEqual({ foo: (expect as any).stringContainingCustom('xx') })) + snapshotError(() => expect({ foo: 'hello' }).toEqual({ foo: (expect as any).not.stringContainingCustom('ll') })) + + // assertion form + snapshotError(() => (expect('hello') as any).stringContainingCustom('xx')) + snapshotError(() => (expect('hello') as any).not.stringContainingCustom('ll')) + + // matcher with complex argument + // (serialized by `String` so it becomes "testComplexMatcher<[object Object]>", which is same as jest's asymmetric matcher and pretty-format) + expect.extend({ + testComplexMatcher(_received: unknown, _arg: unknown) { + return { + pass: false, + message: () => `NA`, + } + }, + }) + snapshotError(() => expect('hello').toEqual((expect as any).testComplexMatcher({ x: 'y' }))) + + // more builtins + snapshotError(() => expect({ k: 'v', k2: 'v2' }).toEqual(expect.objectContaining({ k: 'v', k3: 'v3' }))) + snapshotError(() => expect(['a', 'b']).toEqual(expect.arrayContaining(['a', 'c']))) + snapshotError(() => expect('hello').toEqual(expect.stringMatching(/xx/))) + snapshotError(() => expect(2.5).toEqual(expect.closeTo(2, 1))) + + // simple truncation if pretty-format is too long + snapshotError(() => expect('hello').toEqual(expect.stringContaining('a'.repeat(40)))) +}) + it('timeout', () => new Promise(resolve => setTimeout(resolve, 500)))