Skip to content

Commit

Permalink
fix(expect)!: check more properties for error equality (#5876)
Browse files Browse the repository at this point in the history
Co-authored-by: Vladimir <sleuths.slews0s@icloud.com>
  • Loading branch information
hi-ogawa and sheremet-va authored Dec 10, 2024
1 parent 2fb585a commit 100230e
Show file tree
Hide file tree
Showing 8 changed files with 525 additions and 139 deletions.
24 changes: 21 additions & 3 deletions docs/api/expect.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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',
}))
})
```

Expand Down
14 changes: 9 additions & 5 deletions packages/expect/src/jest-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}

Expand Down
45 changes: 41 additions & 4 deletions packages/expect/src/jest-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -225,6 +231,37 @@ function eq(
return result
}

function isErrorEqual(
a: Error,
b: Error,
aStack: Array<unknown>,
bStack: Array<unknown>,
customTesters: Array<Tester>,
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 = []

Expand Down
31 changes: 31 additions & 0 deletions packages/pretty-format/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -535,11 +564,13 @@ export const plugins: {
Immutable: NewPlugin
ReactElement: NewPlugin
ReactTestComponent: NewPlugin
Error: NewPlugin
} = {
AsymmetricMatcher,
DOMCollection,
DOMElement,
Immutable,
ReactElement,
ReactTestComponent,
Error: ErrorPlugin,
}
14 changes: 14 additions & 0 deletions packages/utils/src/diff/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const PLUGINS = [
DOMCollection,
Immutable,
AsymmetricMatcher,
prettyFormatPlugins.Error,
]
const FORMAT_OPTIONS = {
plugins: PLUGINS,
Expand Down Expand Up @@ -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 }
}
Expand Down
Loading

0 comments on commit 100230e

Please sign in to comment.