Skip to content

Commit de215c1

Browse files
hi-ogawaclaude
andauthored
fix(expect): support arbitrary value equality for toThrow and make Error detection robust (#9570)
Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 728ba61 commit de215c1

File tree

7 files changed

+118
-15
lines changed

7 files changed

+118
-15
lines changed

docs/api/expect.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ test('the number of elements must match exactly', () => {
779779

780780
## toThrowError
781781

782-
- **Type:** `(received: any) => Awaitable<void>`
782+
- **Type:** `(expected?: any) => Awaitable<void>`
783783

784784
- **Alias:** `toThrow`
785785

@@ -789,7 +789,7 @@ You can provide an optional argument to test that a specific error is thrown:
789789

790790
- `RegExp`: error message matches the pattern
791791
- `string`: error message includes the substring
792-
- `Error`, `AsymmetricMatcher`: compare with a received object similar to `toEqual(received)`
792+
- any other value: compare with thrown value using deep equality (similar to `toEqual`)
793793

794794
:::tip
795795
You must wrap the code in a function, otherwise the error will not be caught, and test will fail.
@@ -849,6 +849,17 @@ test('throws on pineapples', async () => {
849849
```
850850
:::
851851

852+
:::tip
853+
You can also test non-Error values that are thrown:
854+
855+
```ts
856+
test('throws non-Error values', () => {
857+
expect(() => { throw 42 }).toThrowError(42)
858+
expect(() => { throw { message: 'error' } }).toThrowError({ message: 'error' })
859+
})
860+
```
861+
:::
862+
852863
## toMatchSnapshot
853864

854865
- **Type:** `<T>(shape?: Partial<T> | string, hint?: string) => void`

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export default antfu(
116116
'ts/method-signature-style': 'off',
117117
'no-self-compare': 'off',
118118
'import/no-mutable-exports': 'off',
119+
'no-throw-literal': 'off',
119120
},
120121
},
121122
{

packages/expect/src/jest-expect.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
arrayBufferEquality,
1717
generateToBeMessage,
1818
getObjectSubset,
19+
isError,
1920
iterableEquality,
2021
equals as jestEquals,
2122
sparseArrayEquality,
@@ -808,7 +809,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
808809
)
809810
}
810811

811-
if (expected instanceof Error) {
812+
if (isError(expected)) {
812813
const equal = jestEquals(thrown, expected, [
813814
...customTesters,
814815
iterableEquality,
@@ -837,8 +838,16 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
837838
)
838839
}
839840

840-
throw new Error(
841-
`"toThrow" expects string, RegExp, function, Error instance or asymmetric matcher, got "${typeof expected}"`,
841+
const equal = jestEquals(thrown, expected, [
842+
...customTesters,
843+
iterableEquality,
844+
])
845+
return this.assert(
846+
equal,
847+
'expected a thrown value to equal #{exp}',
848+
'expected a thrown value not to equal #{exp}',
849+
expected,
850+
thrown,
842851
)
843852
},
844853
)

packages/expect/src/jest-utils.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,21 @@ function asymmetricMatch(a: any, b: any, customTesters: Array<Tester>) {
8686
}
8787
}
8888

89+
// https://github.com/jestjs/jest/blob/905bcbced3d40cdf7aadc4cdf6fb731c4bb3dbe3/packages/expect-utils/src/utils.ts#L509
90+
export function isError(value: unknown): value is Error {
91+
if (typeof Error.isError === 'function') {
92+
return Error.isError(value)
93+
}
94+
switch (Object.prototype.toString.call(value)) {
95+
case '[object Error]':
96+
case '[object Exception]':
97+
case '[object DOMException]':
98+
return true
99+
default:
100+
return value instanceof Error
101+
}
102+
};
103+
89104
// Equality function lovingly adapted from isEqual in
90105
// [Underscore](http://underscorejs.org)
91106
function eq(
@@ -204,7 +219,7 @@ function eq(
204219
return false
205220
}
206221

207-
if (a instanceof Error && b instanceof Error) {
222+
if (isError(a) && isError(b)) {
208223
try {
209224
return isErrorEqual(a, b, aStack, bStack, customTesters, hasKey)
210225
}
@@ -257,7 +272,7 @@ function isErrorEqual(
257272
// - Error names, messages, causes, and errors are always compared, even if these are not enumerable properties. errors is also compared.
258273

259274
let result = (
260-
Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
275+
Object.prototype.toString.call(a) === Object.prototype.toString.call(b)
261276
&& a.name === b.name
262277
&& a.message === b.message
263278
)
@@ -581,7 +596,7 @@ function hasPropertyInObject(object: object, key: string | symbol): boolean {
581596
function isObjectWithKeys(a: any) {
582597
return (
583598
isObject(a)
584-
&& !(a instanceof Error)
599+
&& !isError(a)
585600
&& !Array.isArray(a)
586601
&& !(a instanceof Date)
587602
&& !(a instanceof Set)

packages/expect/src/types.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import type { Test } from '@vitest/runner'
1010
import type { MockInstance } from '@vitest/spy'
11-
import type { Constructable } from '@vitest/utils'
1211
import type { Formatter } from 'tinyrainbow'
1312
import type { AsymmetricMatcher } from './jest-asymmetric-matchers'
1413
import type { diff, getMatcherUtils, stringify } from './jest-matcher-utils'
@@ -535,8 +534,9 @@ export interface JestAssertion<T = any> extends jest.Matchers<void, T>, CustomMa
535534
* @example
536535
* expect(() => functionWithError()).toThrow('Error message');
537536
* expect(() => parseJSON('invalid')).toThrow(SyntaxError);
537+
* expect(() => { throw 42 }).toThrow(42);
538538
*/
539-
toThrow: (expected?: string | Constructable | RegExp | Error) => void
539+
toThrow: (expected?: any) => void
540540

541541
/**
542542
* Used to test that a function throws when it is called.
@@ -546,8 +546,9 @@ export interface JestAssertion<T = any> extends jest.Matchers<void, T>, CustomMa
546546
* @example
547547
* expect(() => functionWithError()).toThrowError('Error message');
548548
* expect(() => parseJSON('invalid')).toThrowError(SyntaxError);
549+
* expect(() => { throw 42 }).toThrowError(42);
549550
*/
550-
toThrowError: (expected?: string | Constructable | RegExp | Error) => void
551+
toThrowError: (expected?: any) => void
551552

552553
/**
553554
* Use to test that the mock function successfully returned (i.e., did not throw an error) at least one time

test/core/test/__snapshots__/jest-expect.test.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,7 @@ exports[`error equality 4`] = `
538538
"custom": "a",
539539
}",
540540
"expected": "[Error: hello]",
541-
"message": "expected Error: hello { custom: 'a' } to deeply equal Error: hello { custom: 'a' }",
541+
"message": "expected Error: hello { custom: 'a' } to strictly equal Error: hello { custom: 'a' }",
542542
}
543543
`;
544544

test/core/test/jest-expect.test.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,73 @@ describe('jest-expect', () => {
484484
}).toThrow(Error)
485485
}).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected function to throw an error, but it didn't]`)
486486
})
487+
488+
it('custom error class', () => {
489+
class Error1 extends Error {};
490+
class Error2 extends Error {};
491+
492+
// underlying `toEqual` doesn't require constructor/prototype equality
493+
expect(() => {
494+
throw new Error1('hi')
495+
}).toThrowError(new Error2('hi'))
496+
expect(new Error1('hi')).toEqual(new Error2('hi'))
497+
expect(new Error1('hi')).not.toStrictEqual(new Error2('hi'))
498+
})
499+
500+
it('non Error instance', () => {
501+
// primitives
502+
expect(() => {
503+
// eslint-disable-next-line no-throw-literal
504+
throw 42
505+
}).toThrow(42)
506+
expect(() => {
507+
// eslint-disable-next-line no-throw-literal
508+
throw 42
509+
}).not.toThrow(43)
510+
511+
expect(() => {
512+
expect(() => {
513+
// eslint-disable-next-line no-throw-literal
514+
throw 42
515+
}).toThrow(43)
516+
}).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected a thrown value to equal 43]`)
517+
518+
// deep equality
519+
expect(() => {
520+
// eslint-disable-next-line no-throw-literal
521+
throw { foo: 'hello world' }
522+
}).toThrow({ foo: expect.stringContaining('hello') })
523+
expect(() => {
524+
// eslint-disable-next-line no-throw-literal
525+
throw { foo: 'bar' }
526+
}).not.toThrow({ foo: expect.stringContaining('hello') })
527+
528+
expect(() => {
529+
expect(() => {
530+
// eslint-disable-next-line no-throw-literal
531+
throw { foo: 'bar' }
532+
}).toThrow({ foo: expect.stringContaining('hello') })
533+
}).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected a thrown value to equal { foo: StringContaining "hello" }]`)
534+
})
535+
536+
it('error from different realm', async () => {
537+
const vm = await import('node:vm')
538+
const context: any = {}
539+
vm.createContext(context)
540+
new vm.Script('fn = () => { throw new TypeError("oops") }; globalObject = this').runInContext(context)
541+
const { fn, globalObject } = context
542+
543+
// constructor
544+
expect(fn).toThrow(globalObject.TypeError)
545+
expect(fn).not.toThrow(globalObject.ReferenceError)
546+
expect(fn).not.toThrow(globalObject.EvalError)
547+
548+
// instance
549+
expect(fn).toThrow(new globalObject.TypeError('oops'))
550+
expect(fn).not.toThrow(new globalObject.TypeError('message'))
551+
expect(fn).not.toThrow(new globalObject.ReferenceError('oops'))
552+
expect(fn).not.toThrow(new globalObject.EvalError('no way'))
553+
})
487554
})
488555
})
489556

@@ -1892,9 +1959,8 @@ it('error equality', () => {
18921959
// different class
18931960
const e1 = new MyError('hello', 'a')
18941961
const e2 = new YourError('hello', 'a')
1895-
snapshotError(() => expect(e1).toEqual(e2))
1896-
expect(e1).not.toEqual(e2)
1897-
expect(e1).not.toStrictEqual(e2) // toStrictEqual checks constructor already
1962+
snapshotError(() => expect(e1).toStrictEqual(e2))
1963+
expect(e1).toEqual(e2)
18981964
assert.deepEqual(e1, e2)
18991965
nodeAssert.notDeepStrictEqual(e1, e2)
19001966
}

0 commit comments

Comments
 (0)