Skip to content

Commit 100230e

Browse files
fix(expect)!: check more properties for error equality (#5876)
Co-authored-by: Vladimir <[email protected]>
1 parent 2fb585a commit 100230e

File tree

8 files changed

+525
-139
lines changed

8 files changed

+525
-139
lines changed

docs/api/expect.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,17 @@ test('stocks are not the same', () => {
431431
```
432432

433433
:::warning
434-
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.
434+
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:
435+
436+
```ts
437+
// success
438+
expect(new Error('hi', { cause: 'x' })).toEqual(new Error('hi'))
439+
440+
// fail
441+
expect(new Error('hi')).toEqual(new Error('hi', { cause: 'x' }))
442+
```
443+
444+
To test if something was thrown, use [`toThrowError`](#tothrowerror) assertion.
435445
:::
436446

437447
## toStrictEqual
@@ -649,8 +659,9 @@ test('the number of elements must match exactly', () => {
649659

650660
You can provide an optional argument to test that a specific error is thrown:
651661

652-
- regular expression: error message matches the pattern
653-
- string: error message includes the substring
662+
- `RegExp`: error message matches the pattern
663+
- `string`: error message includes the substring
664+
- `Error`, `AsymmetricMatcher`: compare with a received object similar to `toEqual(received)`
654665

655666
:::tip
656667
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', () => {
678689
expect(() => getFruitStock('pineapples')).toThrowError(
679690
/^Pineapples are not in stock$/,
680691
)
692+
693+
expect(() => getFruitStock('pineapples')).toThrowError(
694+
new Error('Pineapples are not in stock'),
695+
)
696+
expect(() => getFruitStock('pineapples')).toThrowError(expect.objectContaining({
697+
message: 'Pineapples are not in stock',
698+
}))
681699
})
682700
```
683701

packages/expect/src/jest-expect.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -794,12 +794,16 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
794794
}
795795

796796
if (expected instanceof Error) {
797+
const equal = jestEquals(thrown, expected, [
798+
...customTesters,
799+
iterableEquality,
800+
])
797801
return this.assert(
798-
thrown && expected.message === thrown.message,
799-
`expected error to have message: ${expected.message}`,
800-
`expected error not to have message: ${expected.message}`,
801-
expected.message,
802-
thrown && thrown.message,
802+
equal,
803+
'expected a thrown error to be #{exp}',
804+
'expected a thrown error not to be #{exp}',
805+
expected,
806+
thrown,
803807
)
804808
}
805809

packages/expect/src/jest-utils.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,6 @@ function eq(
114114
}
115115
}
116116

117-
if (a instanceof Error && b instanceof Error) {
118-
return a.message === b.message
119-
}
120-
121117
if (typeof URL === 'function' && a instanceof URL && b instanceof URL) {
122118
return a.href === b.href
123119
}
@@ -196,6 +192,16 @@ function eq(
196192
return false
197193
}
198194

195+
if (a instanceof Error && b instanceof Error) {
196+
try {
197+
return isErrorEqual(a, b, aStack, bStack, customTesters, hasKey)
198+
}
199+
finally {
200+
aStack.pop()
201+
bStack.pop()
202+
}
203+
}
204+
199205
// Deep compare objects.
200206
const aKeys = keys(a, hasKey)
201207
let key
@@ -225,6 +231,37 @@ function eq(
225231
return result
226232
}
227233

234+
function isErrorEqual(
235+
a: Error,
236+
b: Error,
237+
aStack: Array<unknown>,
238+
bStack: Array<unknown>,
239+
customTesters: Array<Tester>,
240+
hasKey: any,
241+
) {
242+
// https://nodejs.org/docs/latest-v22.x/api/assert.html#comparison-details
243+
// - [[Prototype]] of objects are compared using the === operator.
244+
// - Only enumerable "own" properties are considered.
245+
// - Error names, messages, causes, and errors are always compared, even if these are not enumerable properties. errors is also compared.
246+
247+
let result = (
248+
Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
249+
&& a.name === b.name
250+
&& a.message === b.message
251+
)
252+
// check Error.cause asymmetrically
253+
if (typeof b.cause !== 'undefined') {
254+
result &&= eq(a.cause, b.cause, aStack, bStack, customTesters, hasKey)
255+
}
256+
// AggregateError.errors
257+
if (a instanceof AggregateError && b instanceof AggregateError) {
258+
result &&= eq(a.errors, b.errors, aStack, bStack, customTesters, hasKey)
259+
}
260+
// spread to compare enumerable properties
261+
result &&= eq({ ...a }, { ...b }, aStack, bStack, customTesters, hasKey)
262+
return result
263+
}
264+
228265
function keys(obj: object, hasKey: (obj: object, key: string) => boolean) {
229266
const keys = []
230267

packages/pretty-format/src/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,35 @@ function printComplexValue(
296296
)}}`
297297
}
298298

299+
const ErrorPlugin: NewPlugin = {
300+
test: val => val && val instanceof Error,
301+
serialize(val: Error, config, indentation, depth, refs, printer) {
302+
if (refs.includes(val)) {
303+
return '[Circular]'
304+
}
305+
refs = [...refs, val]
306+
const hitMaxDepth = ++depth > config.maxDepth
307+
const { message, cause, ...rest } = val
308+
const entries = {
309+
message,
310+
...typeof cause !== 'undefined' ? { cause } : {},
311+
...val instanceof AggregateError ? { errors: val.errors } : {},
312+
...rest,
313+
}
314+
const name = val.name !== 'Error' ? val.name : getConstructorName(val as any)
315+
return hitMaxDepth
316+
? `[${name}]`
317+
: `${name} {${printIteratorEntries(
318+
Object.entries(entries).values(),
319+
config,
320+
indentation,
321+
depth,
322+
refs,
323+
printer,
324+
)}}`
325+
},
326+
}
327+
299328
function isNewPlugin(plugin: Plugin): plugin is NewPlugin {
300329
return (plugin as NewPlugin).serialize != null
301330
}
@@ -535,11 +564,13 @@ export const plugins: {
535564
Immutable: NewPlugin
536565
ReactElement: NewPlugin
537566
ReactTestComponent: NewPlugin
567+
Error: NewPlugin
538568
} = {
539569
AsymmetricMatcher,
540570
DOMCollection,
541571
DOMElement,
542572
Immutable,
543573
ReactElement,
544574
ReactTestComponent,
575+
Error: ErrorPlugin,
545576
}

packages/utils/src/diff/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const PLUGINS = [
5050
DOMCollection,
5151
Immutable,
5252
AsymmetricMatcher,
53+
prettyFormatPlugins.Error,
5354
]
5455
const FORMAT_OPTIONS = {
5556
plugins: PLUGINS,
@@ -298,6 +299,19 @@ export function replaceAsymmetricMatcher(
298299
replacedActual: any
299300
replacedExpected: any
300301
} {
302+
// handle asymmetric Error.cause diff
303+
if (
304+
actual instanceof Error
305+
&& expected instanceof Error
306+
&& typeof actual.cause !== 'undefined'
307+
&& typeof expected.cause === 'undefined'
308+
) {
309+
delete actual.cause
310+
return {
311+
replacedActual: actual,
312+
replacedExpected: expected,
313+
}
314+
}
301315
if (!isReplaceable(actual, expected)) {
302316
return { replacedActual: actual, replacedExpected: expected }
303317
}

0 commit comments

Comments
 (0)