Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(expect)!: check more properties for error equality #5876

Open
wants to merge 36 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f6d436b
fix(expect)!: check more properties for error equality
hi-ogawa Jun 12, 2024
a768f59
test: update
hi-ogawa Jun 12, 2024
7f3c0c2
chore: comment
hi-ogawa Jun 12, 2024
79818e5
wip: check cause and errors
hi-ogawa Jun 12, 2024
33e71aa
test: error diff
hi-ogawa Jun 12, 2024
7091ecf
test: move test
hi-ogawa Jun 12, 2024
fb1b9fe
Revert "test: error diff"
hi-ogawa Jun 12, 2024
1dfcafc
test: fix colors
hi-ogawa Jun 12, 2024
25084e7
Merge branch 'main' into error-equality-custom-properties
hi-ogawa Jul 3, 2024
47e7496
chore: revert format
hi-ogawa Jul 3, 2024
257d49d
Merge branch 'main' into error-equality-custom-properties
hi-ogawa Oct 6, 2024
3609c3e
wip: format error
hi-ogawa Oct 7, 2024
fdaa614
test: update
hi-ogawa Oct 7, 2024
6e5fe19
test: more
hi-ogawa Oct 7, 2024
bff22de
fix: enable error format plugin only during diff
hi-ogawa Oct 7, 2024
46f3f9c
test: more snapshot
hi-ogawa Oct 7, 2024
23ca80a
chore: comment
hi-ogawa Oct 7, 2024
b9d9204
test: move code
hi-ogawa Oct 7, 2024
898ce93
test: more test
hi-ogawa Oct 7, 2024
755cfb8
fix: handle asymmetric cause diff
hi-ogawa Oct 7, 2024
b9aab2b
fix: print error constructor
hi-ogawa Oct 7, 2024
45062b6
fix: handle cyclic during format
hi-ogawa Oct 7, 2024
86a0b5b
test: update snapshot
hi-ogawa Oct 7, 2024
de9a4de
wip: handle cycle
hi-ogawa Oct 7, 2024
dbbe137
fix: handle cyclic
hi-ogawa Oct 7, 2024
e2a1c32
refactor: minor
hi-ogawa Oct 7, 2024
87031b5
docs: update `toEqual`
hi-ogawa Oct 7, 2024
82b5971
fix: do the same for `toThrowError`
hi-ogawa Oct 7, 2024
646f81f
chore: tweak
hi-ogawa Oct 7, 2024
b9f6d42
Merge branch 'main' into error-equality-custom-properties
hi-ogawa Oct 7, 2024
5de6d0a
refactor: many if
hi-ogawa Oct 7, 2024
1908bd6
fix: tweak asymmetric error.cause diff
hi-ogawa Oct 8, 2024
d0b805e
chore: remove debug
hi-ogawa Oct 8, 2024
b3d9557
Merge branch 'main' into error-equality-custom-properties
hi-ogawa Oct 8, 2024
1b41876
Merge branch 'main' into error-equality-custom-properties
hi-ogawa Oct 9, 2024
78edae3
fix: simplify back
hi-ogawa Oct 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -683,12 +683,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 error to be #{exp}',
'expected error not to be #{exp}',
expected,
thrown,
)
}

Expand Down
44 changes: 40 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,36 @@ 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.
// (NOTE: causes and errors are added in v22)
return (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rewrite it as a bunch of if statements? This is hard to process 😄

Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
&& a.name === b.name
&& a.message === b.message
// check Error.cause asymmetrically
&& (typeof b.cause !== 'undefined'
? eq(a.cause, b.cause, aStack, bStack, customTesters, hasKey)
: true)
// AggregateError.errors
&& (a instanceof AggregateError && b instanceof AggregateError
? eq(a.errors, b.errors, aStack, bStack, customTesters, hasKey)
: true)
// spread to compare enumerable properties
&& eq({ ...a }, { ...b }, aStack, bStack, customTesters, hasKey)
)
}

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 @@ -297,6 +298,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
153 changes: 150 additions & 3 deletions test/core/test/__snapshots__/jest-expect.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand All @@ -292,7 +294,9 @@ exports[`asymmetric matcher error 22`] = `
stringContainingCustom<ll>

+ Received:
[Error: hello]",
Error {
"message": "hello",
}",
"expected": "stringContainingCustom<ll>",
"message": "expected error to match asymmetric matcher",
}
Expand All @@ -305,12 +309,155 @@ exports[`asymmetric matcher error 23`] = `
[Function MyError1]

+ Received:
[Error: hello]",
MyError2 {
"message": "hello",
}",
"expected": "[Function MyError1]",
"message": "expected error to be instance of MyError1",
}
`;

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 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 { cause: 'x' } to deeply equal Error: hello { cause: 'y' }",
}
`;

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 { cause: 'y' }",
}
`;

exports[`error equality 7`] = `
{
"actual": "[Error: hello]",
"diff": "- Expected
+ Received

Error {
- "message": "world",
+ "message": "hello",
}",
"expected": "[Error: world]",
"message": "expected Error: hello { cause: 'x' } to deeply equal Error: world",
}
`;

exports[`error equality 8`] = `
{
"actual": "[AggregateError: outer]",
"diff": "- Expected
+ Received

AggregateError {
"message": "outer",
"cause": "x",
"errors": Array [
Error {
"message": "inner",
- "cause": "y",
+ "cause": "x",
},
],
}",
"expected": "[AggregateError: outer]",
"message": "expected AggregateError: outer { …(2) } to deeply equal AggregateError: outer { …(2) }",
}
`;

exports[`error equality 9`] = `
{
"actual": "undefined",
"diff": undefined,
"expected": "undefined",
"message": "Maximum call stack size exceeded",
}
`;

exports[`toHaveBeenNthCalledWith error 1`] = `
{
"actual": "Array [
Expand Down
Loading
Loading