diff --git a/docs/api/expect.md b/docs/api/expect.md index 0c1df7d6ebb3..94ee348ecace 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -431,7 +431,17 @@ test('stocks are not the same', () => { ``` :::warning -A _deep equality_ will not be performed for `Error` objects. Only the `message` property of an Error is considered for equality. To customize equality to check properties other than `message`, use [`expect.addEqualityTesters`](#expect-addequalitytesters). To test if something was thrown, use [`toThrowError`](#tothrowerror) assertion. +For `Error` objects, non-enumerable properties such as `name`, `message`, `cause` and `AggregateError.errors` are also compared. For `Error.cause`, the comparison is done asymmetrically: + +```ts +// success +expect(new Error('hi', { cause: 'x' })).toEqual(new Error('hi')) + +// fail +expect(new Error('hi')).toEqual(new Error('hi', { cause: 'x' })) +``` + +To test if something was thrown, use [`toThrowError`](#tothrowerror) assertion. ::: ## toStrictEqual @@ -649,8 +659,9 @@ test('the number of elements must match exactly', () => { You can provide an optional argument to test that a specific error is thrown: -- regular expression: error message matches the pattern -- string: error message includes the substring +- `RegExp`: error message matches the pattern +- `string`: error message includes the substring +- `Error`, `AsymmetricMatcher`: compare with a received object similar to `toEqual(received)` :::tip You must wrap the code in a function, otherwise the error will not be caught, and test will fail. @@ -678,6 +689,13 @@ test('throws on pineapples', () => { expect(() => getFruitStock('pineapples')).toThrowError( /^Pineapples are not in stock$/, ) + + expect(() => getFruitStock('pineapples')).toThrowError( + new Error('Pineapples are not in stock'), + ) + expect(() => getFruitStock('pineapples')).toThrowError(expect.objectContaining({ + message: 'Pineapples are not in stock', + })) }) ``` diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index 223b151312d5..3a5975efea08 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -794,12 +794,16 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { } if (expected instanceof Error) { + const equal = jestEquals(thrown, expected, [ + ...customTesters, + iterableEquality, + ]) return this.assert( - thrown && expected.message === thrown.message, - `expected error to have message: ${expected.message}`, - `expected error not to have message: ${expected.message}`, - expected.message, - thrown && thrown.message, + equal, + 'expected a thrown error to be #{exp}', + 'expected a thrown error not to be #{exp}', + expected, + thrown, ) } diff --git a/packages/expect/src/jest-utils.ts b/packages/expect/src/jest-utils.ts index d3744bed0df3..248bd9f79b44 100644 --- a/packages/expect/src/jest-utils.ts +++ b/packages/expect/src/jest-utils.ts @@ -114,10 +114,6 @@ function eq( } } - if (a instanceof Error && b instanceof Error) { - return a.message === b.message - } - if (typeof URL === 'function' && a instanceof URL && b instanceof URL) { return a.href === b.href } @@ -196,6 +192,16 @@ function eq( return false } + if (a instanceof Error && b instanceof Error) { + try { + return isErrorEqual(a, b, aStack, bStack, customTesters, hasKey) + } + finally { + aStack.pop() + bStack.pop() + } + } + // Deep compare objects. const aKeys = keys(a, hasKey) let key @@ -225,6 +231,37 @@ function eq( return result } +function isErrorEqual( + a: Error, + b: Error, + aStack: Array, + bStack: Array, + customTesters: Array, + hasKey: any, +) { + // https://nodejs.org/docs/latest-v22.x/api/assert.html#comparison-details + // - [[Prototype]] of objects are compared using the === operator. + // - Only enumerable "own" properties are considered. + // - Error names, messages, causes, and errors are always compared, even if these are not enumerable properties. errors is also compared. + + let result = ( + Object.getPrototypeOf(a) === Object.getPrototypeOf(b) + && a.name === b.name + && a.message === b.message + ) + // check Error.cause asymmetrically + if (typeof b.cause !== 'undefined') { + result &&= eq(a.cause, b.cause, aStack, bStack, customTesters, hasKey) + } + // AggregateError.errors + if (a instanceof AggregateError && b instanceof AggregateError) { + result &&= eq(a.errors, b.errors, aStack, bStack, customTesters, hasKey) + } + // spread to compare enumerable properties + result &&= eq({ ...a }, { ...b }, aStack, bStack, customTesters, hasKey) + return result +} + function keys(obj: object, hasKey: (obj: object, key: string) => boolean) { const keys = [] diff --git a/packages/pretty-format/src/index.ts b/packages/pretty-format/src/index.ts index 710d1cd8b1f9..99eccbf1e306 100644 --- a/packages/pretty-format/src/index.ts +++ b/packages/pretty-format/src/index.ts @@ -296,6 +296,35 @@ function printComplexValue( )}}` } +const ErrorPlugin: NewPlugin = { + test: val => val && val instanceof Error, + serialize(val: Error, config, indentation, depth, refs, printer) { + if (refs.includes(val)) { + return '[Circular]' + } + refs = [...refs, val] + const hitMaxDepth = ++depth > config.maxDepth + const { message, cause, ...rest } = val + const entries = { + message, + ...typeof cause !== 'undefined' ? { cause } : {}, + ...val instanceof AggregateError ? { errors: val.errors } : {}, + ...rest, + } + const name = val.name !== 'Error' ? val.name : getConstructorName(val as any) + return hitMaxDepth + ? `[${name}]` + : `${name} {${printIteratorEntries( + Object.entries(entries).values(), + config, + indentation, + depth, + refs, + printer, + )}}` + }, +} + function isNewPlugin(plugin: Plugin): plugin is NewPlugin { return (plugin as NewPlugin).serialize != null } @@ -535,6 +564,7 @@ export const plugins: { Immutable: NewPlugin ReactElement: NewPlugin ReactTestComponent: NewPlugin + Error: NewPlugin } = { AsymmetricMatcher, DOMCollection, @@ -542,4 +572,5 @@ export const plugins: { Immutable, ReactElement, ReactTestComponent, + Error: ErrorPlugin, } diff --git a/packages/utils/src/diff/index.ts b/packages/utils/src/diff/index.ts index d9445dc47dc0..d69d33f99f3f 100644 --- a/packages/utils/src/diff/index.ts +++ b/packages/utils/src/diff/index.ts @@ -50,6 +50,7 @@ const PLUGINS = [ DOMCollection, Immutable, AsymmetricMatcher, + prettyFormatPlugins.Error, ] const FORMAT_OPTIONS = { plugins: PLUGINS, @@ -298,6 +299,19 @@ export function replaceAsymmetricMatcher( replacedActual: any replacedExpected: any } { + // handle asymmetric Error.cause diff + if ( + actual instanceof Error + && expected instanceof Error + && typeof actual.cause !== 'undefined' + && typeof expected.cause === 'undefined' + ) { + delete actual.cause + return { + replacedActual: actual, + replacedExpected: expected, + } + } if (!isReplaceable(actual, expected)) { return { replacedActual: actual, replacedExpected: expected } } diff --git a/test/core/test/__snapshots__/jest-expect.test.ts.snap b/test/core/test/__snapshots__/jest-expect.test.ts.snap index d97708aef02c..2921503d6ccd 100644 --- a/test/core/test/__snapshots__/jest-expect.test.ts.snap +++ b/test/core/test/__snapshots__/jest-expect.test.ts.snap @@ -279,7 +279,9 @@ exports[`asymmetric matcher error 21`] = ` StringContaining "ll" + Received: -[Error: hello]", +Error { + "message": "hello", +}", "expected": "StringContaining "ll"", "message": "expected error to match asymmetric matcher", } @@ -292,7 +294,9 @@ exports[`asymmetric matcher error 22`] = ` stringContainingCustom + Received: -[Error: hello]", +Error { + "message": "hello", +}", "expected": "stringContainingCustom", "message": "expected error to match asymmetric matcher", } @@ -305,7 +309,9 @@ exports[`asymmetric matcher error 23`] = ` [Function MyError1] + Received: -[Error: hello]", +MyError2 { + "message": "hello", +}", "expected": "[Function MyError1]", "message": "expected error to be instance of MyError1", } @@ -392,6 +398,234 @@ null } `; +exports[`error equality 1`] = ` +{ + "actual": "[Error: hi]", + "diff": "- Expected ++ Received + + MyError { + "message": "hi", +- "custom": "b", ++ "custom": "a", + }", + "expected": "[Error: hi]", + "message": "expected Error: hi { custom: 'a' } to deeply equal Error: hi { custom: 'b' }", +} +`; + +exports[`error equality 2`] = ` +{ + "actual": "[Error: hi]", + "diff": "- Expected ++ Received + + MyError { + "message": "hi", +- "custom": "b", ++ "custom": "a", + }", + "expected": "[Error: hi]", + "message": "expected a thrown error to be Error: hi { custom: 'b' }", +} +`; + +exports[`error equality 3`] = ` +{ + "actual": "[Error: hi]", + "diff": "- Expected ++ Received + + MyError { +- "message": "hello", ++ "message": "hi", + "custom": "a", + }", + "expected": "[Error: hello]", + "message": "expected Error: hi { custom: 'a' } to deeply equal Error: hello { custom: 'a' }", +} +`; + +exports[`error equality 4`] = ` +{ + "actual": "[Error: hello]", + "diff": "- Expected ++ Received + +- YourError { ++ MyError { + "message": "hello", + "custom": "a", + }", + "expected": "[Error: hello]", + "message": "expected Error: hello { custom: 'a' } to deeply equal Error: hello { custom: 'a' }", +} +`; + +exports[`error equality 5`] = ` +{ + "actual": "[Error: hello]", + "diff": "- Expected ++ Received + + Error { + "message": "hello", +- "cause": "y", ++ "cause": "x", + }", + "expected": "[Error: hello]", + "message": "expected Error: hello to deeply equal Error: hello", +} +`; + +exports[`error equality 6`] = ` +{ + "actual": "[Error: hello]", + "diff": "- Expected ++ Received + + Error { + "message": "hello", +- "cause": "y", + }", + "expected": "[Error: hello]", + "message": "expected Error: hello to deeply equal Error: hello", +} +`; + +exports[`error equality 7`] = ` +{ + "actual": "[Error: hello]", + "diff": "- Expected ++ Received + + Error { +- "message": "world", ++ "message": "hello", + }", + "expected": "[Error: world]", + "message": "expected Error: hello to deeply equal Error: world", +} +`; + +exports[`error equality 8`] = ` +{ + "actual": "[Error: hello]", + "diff": "- Expected ++ Received + +- { +- "something": "else", ++ Error { ++ "message": "hello", ++ "cause": "x", + }", + "expected": "Object { + "something": "else", +}", + "message": "expected Error: hello to deeply equal { something: 'else' }", +} +`; + +exports[`error equality 9`] = ` +{ + "actual": "[AggregateError: outer]", + "diff": "- Expected ++ Received + + AggregateError { + "message": "outer", + "cause": "x", + "errors": [ + Error { + "message": "inner", +- "cause": "y", ++ "cause": "x", + }, + ], + }", + "expected": "[AggregateError: outer]", + "message": "expected AggregateError: outer { …(1) } to deeply equal AggregateError: outer { …(1) }", +} +`; + +exports[`error equality 10`] = ` +{ + "actual": "[Error: hello]", + "diff": "- Expected ++ Received + + Error { +- "message": "world", ++ "message": "hello", + "cause": [Circular], + }", + "expected": "[Error: world]", + "message": "expected Error: hello to deeply equal Error: world", +} +`; + +exports[`error equality 11`] = ` +{ + "actual": "[Error: hello]", + "diff": "- Expected ++ Received + +- ObjectContaining { +- "cause": "y", ++ Error { + "message": "hello", ++ "cause": "x", + }", + "expected": "ObjectContaining { + "cause": "y", + "message": "hello", +}", + "message": "expected Error: hello to deeply equal ObjectContaining{…}", +} +`; + +exports[`error equality 12`] = ` +{ + "actual": "[Error: hello]", + "diff": "- Expected ++ Received + +- ObjectContaining { ++ Error { ++ "message": "hello", + "cause": "x", +- "message": "world", + }", + "expected": "ObjectContaining { + "cause": "x", + "message": "world", +}", + "message": "expected Error: hello to deeply equal ObjectContaining{…}", +} +`; + +exports[`error equality 13`] = ` +{ + "actual": "[Error: hello]", + "diff": "- Expected ++ Received + +- ObjectContaining { +- "cause": "y", +- "message": "world", ++ Error { ++ "message": "hello", ++ "cause": "x", + }", + "expected": "ObjectContaining { + "cause": "y", + "message": "world", +}", + "message": "expected Error: hello to deeply equal ObjectContaining{…}", +} +`; + exports[`toHaveBeenNthCalledWith error 1`] = ` { "actual": "Array [ diff --git a/test/core/test/expect.test.ts b/test/core/test/expect.test.ts index 0e454a3b3ac4..e857465832f5 100644 --- a/test/core/test/expect.test.ts +++ b/test/core/test/expect.test.ts @@ -1,7 +1,6 @@ import type { Tester } from '@vitest/expect' -import nodeAssert from 'node:assert' import { getCurrentTest } from '@vitest/runner' -import { assert, describe, expect, expectTypeOf, test, vi } from 'vitest' +import { describe, expect, expectTypeOf, test, vi } from 'vitest' describe('expect.soft', () => { test('types', () => { @@ -281,125 +280,6 @@ describe('recursive custom equality tester', () => { }) }) -describe('Error equality', () => { - class MyError extends Error { - constructor(message: string, public custom: string) { - super(message) - } - } - - class YourError extends Error { - constructor(message: string, public custom: string) { - super(message) - } - } - - test('basic', () => { - // - // default behavior - // - - { - // different custom property - const e1 = new MyError('hi', 'a') - const e2 = new MyError('hi', 'b') - expect(e1).toEqual(e2) - expect(e1).toStrictEqual(e2) - assert.deepEqual(e1, e2) - nodeAssert.notDeepStrictEqual(e1, e2) - } - - { - // different message - const e1 = new MyError('hi', 'a') - const e2 = new MyError('hello', 'a') - expect(e1).not.toEqual(e2) - expect(e1).not.toStrictEqual(e2) - assert.notDeepEqual(e1, e2) - nodeAssert.notDeepStrictEqual(e1, e2) - } - - { - // different class - const e1 = new MyError('hello', 'a') - const e2 = new YourError('hello', 'a') - expect(e1).toEqual(e2) - expect(e1).not.toStrictEqual(e2) // toStrictEqual checks constructor already - assert.deepEqual(e1, e2) - nodeAssert.notDeepStrictEqual(e1, e2) - } - - { - // same - const e1 = new MyError('hi', 'a') - const e2 = new MyError('hi', 'a') - expect(e1).toEqual(e2) - expect(e1).toStrictEqual(e2) - assert.deepEqual(e1, e2) - nodeAssert.deepStrictEqual(e1, e2) - } - - // - // stricter behavior with custom equality tester - // - - expect.addEqualityTesters( - [ - function tester(a, b, customTesters) { - const aOk = a instanceof Error - const bOk = b instanceof Error - if (aOk && bOk) { - // cf. assert.deepStrictEqual https://nodejs.org/api/assert.html#comparison-details_1 - // > [[Prototype]] of objects are compared using the === operator. - // > Only enumerable "own" properties are considered. - // > Error names and messages are always compared, even if these are not enumerable properties. - return ( - Object.getPrototypeOf(a) === Object.getPrototypeOf(b) - && a.name === b.name - && a.message === b.message - && this.equals({ ...a }, { ...b }, customTesters) - ) - } - return aOk !== bOk ? false : undefined - }, - ], - ) - - { - // different custom property - const e1 = new MyError('hi', 'a') - const e2 = new MyError('hi', 'b') - expect(e1).not.toEqual(e2) // changed - expect(e1).not.toStrictEqual(e2) // changed - assert.deepEqual(e1, e2) // chai assert is still same - } - - { - // different message - const e1 = new MyError('hi', 'a') - const e2 = new MyError('hello', 'a') - expect(e1).not.toEqual(e2) - expect(e1).not.toStrictEqual(e2) - } - - { - // different class - const e1 = new MyError('hello', 'a') - const e2 = new YourError('hello', 'a') - expect(e1).not.toEqual(e2) // changed - expect(e1).not.toStrictEqual(e2) - } - - { - // same - const e1 = new MyError('hi', 'a') - const e2 = new MyError('hi', 'a') - expect(e1).toEqual(e2) - expect(e1).toStrictEqual(e2) - } - }) -}) - describe('iterator', () => { test('returns true when given iterator within equal objects', () => { const a = { diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 31c050fe1e8b..fbdeeef43a17 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -1,5 +1,5 @@ /* eslint-disable no-sparse-arrays */ -import { AssertionError } from 'node:assert' +import nodeAssert, { AssertionError } from 'node:assert' import { stripVTControlCharacters } from 'node:util' import { generateToBeMessage } from '@vitest/expect' import { processError } from '@vitest/utils/error' @@ -1114,7 +1114,7 @@ describe('async expect', () => { expect.unreachable() } catch (error) { - expect(error).toEqual(new Error('promise resolved "+0" instead of rejecting')) + expect(error).toMatchObject({ message: 'promise resolved "+0" instead of rejecting' }) } try { @@ -1122,7 +1122,7 @@ describe('async expect', () => { expect.unreachable() } catch (error) { - expect(error).toEqual(new Error('promise rejected "+0" instead of resolving')) + expect(error).toMatchObject({ message: 'promise rejected "+0" instead of resolving' }) } }) }) @@ -1608,6 +1608,174 @@ it('asymmetric matcher error', () => { }).toThrow(MyError1)) }) +it('error equality', () => { + class MyError extends Error { + constructor(message: string, public custom: string) { + super(message) + } + } + + class YourError extends Error { + constructor(message: string, public custom: string) { + super(message) + } + } + + { + // different custom property + const e1 = new MyError('hi', 'a') + const e2 = new MyError('hi', 'b') + snapshotError(() => expect(e1).toEqual(e2)) + expect(e1).not.toEqual(e2) + expect(e1).not.toStrictEqual(e2) + assert.deepEqual(e1, e2) + nodeAssert.notDeepStrictEqual(e1, e2) + + // toThrowError also compare errors similar to toEqual + snapshotError(() => + expect(() => { + throw e1 + }).toThrowError(e2), + ) + } + + { + // different message + const e1 = new MyError('hi', 'a') + const e2 = new MyError('hello', 'a') + snapshotError(() => expect(e1).toEqual(e2)) + expect(e1).not.toEqual(e2) + expect(e1).not.toStrictEqual(e2) + assert.notDeepEqual(e1, e2) + nodeAssert.notDeepStrictEqual(e1, e2) + } + + { + // different class + const e1 = new MyError('hello', 'a') + const e2 = new YourError('hello', 'a') + snapshotError(() => expect(e1).toEqual(e2)) + expect(e1).not.toEqual(e2) + expect(e1).not.toStrictEqual(e2) // toStrictEqual checks constructor already + assert.deepEqual(e1, e2) + nodeAssert.notDeepStrictEqual(e1, e2) + } + + { + // same + const e1 = new MyError('hi', 'a') + const e2 = new MyError('hi', 'a') + expect(e1).toEqual(e2) + expect(e1).toStrictEqual(e2) + assert.deepEqual(e1, e2) + nodeAssert.deepStrictEqual(e1, e2) + + expect(() => { + throw e1 + }).toThrowError(e2) + } + + { + // same + const e1 = new MyError('hi', 'a') + const e2 = new MyError('hi', 'a') + expect(e1).toEqual(e2) + expect(e1).toStrictEqual(e2) + assert.deepEqual(e1, e2) + nodeAssert.deepStrictEqual(e1, e2) + } + + { + // different cause + const e1 = new Error('hello', { cause: 'x' }) + const e2 = new Error('hello', { cause: 'y' }) + snapshotError(() => expect(e1).toEqual(e2)) + expect(e1).not.toEqual(e2) + } + + { + // different cause (asymmetric fail) + const e1 = new Error('hello') + const e2 = new Error('hello', { cause: 'y' }) + snapshotError(() => expect(e1).toEqual(e2)) + expect(e1).not.toEqual(e2) + } + + { + // different cause (asymmetric pass) + const e1 = new Error('hello', { cause: 'x' }) + const e2 = new Error('hello') + expect(e1).toEqual(e2) + } + + { + // different cause (fail by other props) + const e1 = new Error('hello', { cause: 'x' }) + const e2 = new Error('world') + snapshotError(() => expect(e1).toEqual(e2)) + } + + { + // different cause + const e1 = new Error('hello', { cause: 'x' }) + const e2 = { something: 'else' } + snapshotError(() => expect(e1).toEqual(e2)) + } + + { + // AggregateError (pass) + const e1 = new AggregateError([new Error('inner')], 'outer', { cause: 'x' }) + const e2 = new AggregateError([new Error('inner')], 'outer', { cause: 'x' }) + expect(e1).toEqual(e2) + } + + { + // AggregateError (fail) + const e1 = new AggregateError([new Error('inner', { cause: 'x' })], 'outer', { cause: 'x' }) + const e2 = new AggregateError([new Error('inner', { cause: 'y' })], 'outer', { cause: 'x' }) + snapshotError(() => expect(e1).toEqual(e2)) + } + + { + // cyclic (pass) + const e1 = new Error('hi') + e1.cause = e1 + const e2 = new Error('hi') + e2.cause = e2 + expect(e1).toEqual(e2) + } + + { + // cyclic (fail) + const e1 = new Error('hello') + e1.cause = e1 + const e2 = new Error('world') + e2.cause = e2 + snapshotError(() => expect(e1).toEqual(e2)) + } + + { + // asymmetric matcher + const e1 = new Error('hello', { cause: 'x' }) + expect(e1).toEqual(expect.objectContaining({ + message: 'hello', + cause: 'x', + })) + snapshotError(() => expect(e1).toEqual(expect.objectContaining({ + message: 'hello', + cause: 'y', + }))) + snapshotError(() => expect(e1).toEqual(expect.objectContaining({ + message: 'world', + cause: 'x', + }))) + snapshotError(() => expect(e1).toEqual(expect.objectContaining({ + message: 'world', + cause: 'y', + }))) + } +}) + it('toHaveBeenNthCalledWith error', () => { const fn = vi.fn() fn('World')