From 27e8a641c8d8e1575892c374aebdc6a9829ebb46 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 2 Feb 2026 17:24:14 +0900 Subject: [PATCH 1/6] fix(expect): support cross-realm errors in toThrow matcher Use `isError()` utility instead of `instanceof Error` to properly detect errors from different JavaScript realms (e.g., vm module contexts). The `isError()` function uses `Error.isError()` when available (ES2024+) and falls back to `Object.prototype.toString` checks for cross-realm compatibility. Also allows `toThrow()` to accept any value for comparison, not just Error instances, enabling tests like `expect(() => { throw 42 }).toThrow(42)`. Fixes #8898 Co-Authored-By: Claude Opus 4.5 --- packages/expect/src/jest-expect.ts | 15 +++++++++--- packages/expect/src/jest-utils.ts | 19 +++++++++++++-- packages/expect/src/types.ts | 4 ++-- test/core/test/jest-expect.test.ts | 38 ++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index 4adecb219248..a6e04ecdea7f 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -16,6 +16,7 @@ import { arrayBufferEquality, generateToBeMessage, getObjectSubset, + isError, iterableEquality, equals as jestEquals, sparseArrayEquality, @@ -808,7 +809,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { ) } - if (expected instanceof Error) { + if (isError(expected)) { const equal = jestEquals(thrown, expected, [ ...customTesters, iterableEquality, @@ -837,8 +838,16 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { ) } - throw new Error( - `"toThrow" expects string, RegExp, function, Error instance or asymmetric matcher, got "${typeof expected}"`, + const equal = jestEquals(thrown, expected, [ + ...customTesters, + iterableEquality, + ]) + return this.assert( + equal, + 'expected a thrown value #{this} to equal #{exp}', + 'expected a thrown value #{this} not to equal #{exp}', + expected, + thrown, ) }, ) diff --git a/packages/expect/src/jest-utils.ts b/packages/expect/src/jest-utils.ts index 8b0f9b337c9f..30290ce86915 100644 --- a/packages/expect/src/jest-utils.ts +++ b/packages/expect/src/jest-utils.ts @@ -86,6 +86,21 @@ function asymmetricMatch(a: any, b: any, customTesters: Array) { } } +// https://github.com/jestjs/jest/blob/905bcbced3d40cdf7aadc4cdf6fb731c4bb3dbe3/packages/expect-utils/src/utils.ts#L509 +export function isError(value: unknown): value is Error { + if (typeof Error.isError === 'function') { + return Error.isError(value) + } + switch (Object.prototype.toString.call(value)) { + case '[object Error]': + case '[object Exception]': + case '[object DOMException]': + return true + default: + return value instanceof Error + } +}; + // Equality function lovingly adapted from isEqual in // [Underscore](http://underscorejs.org) function eq( @@ -204,7 +219,7 @@ function eq( return false } - if (a instanceof Error && b instanceof Error) { + if (isError(a) && isError(b)) { try { return isErrorEqual(a, b, aStack, bStack, customTesters, hasKey) } @@ -581,7 +596,7 @@ function hasPropertyInObject(object: object, key: string | symbol): boolean { function isObjectWithKeys(a: any) { return ( isObject(a) - && !(a instanceof Error) + && !isError(a) && !Array.isArray(a) && !(a instanceof Date) && !(a instanceof Set) diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 18f0bf577625..6cd76442968f 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -536,7 +536,7 @@ export interface JestAssertion extends jest.Matchers, CustomMa * expect(() => functionWithError()).toThrow('Error message'); * expect(() => parseJSON('invalid')).toThrow(SyntaxError); */ - toThrow: (expected?: string | Constructable | RegExp | Error) => void + toThrow: (expected?: string | Constructable | RegExp | Error | unknown) => void /** * Used to test that a function throws when it is called. @@ -547,7 +547,7 @@ export interface JestAssertion extends jest.Matchers, CustomMa * expect(() => functionWithError()).toThrowError('Error message'); * expect(() => parseJSON('invalid')).toThrowError(SyntaxError); */ - toThrowError: (expected?: string | Constructable | RegExp | Error) => void + toThrowError: (expected?: string | Constructable | RegExp | Error | unknown) => void /** * Use to test that the mock function successfully returned (i.e., did not throw an error) at least one time diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 903ec9e8f9e1..c691141c178b 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -484,6 +484,44 @@ describe('jest-expect', () => { }).toThrow(Error) }).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected function to throw an error, but it didn't]`) }) + + it('non Error instance', () => { + expect(() => { + // eslint-disable-next-line no-throw-literal + throw 42 + }).toThrow(42) + expect(() => { + // eslint-disable-next-line no-throw-literal + throw 42 + }).not.toThrow(43) + expect(() => { + // eslint-disable-next-line no-throw-literal + throw { foo: 'bar' } + }).toThrow({ foo: 'bar' }) + expect(() => { + // eslint-disable-next-line no-throw-literal + throw { foo: 'bar' } + }).not.toThrow({ foo: 'baz' }) + }) + + it('error from different realm', async () => { + const vm = await import('node:vm') + const context: any = {} + vm.createContext(context) + new vm.Script('fn = () => { throw new TypeError("oops") }; globalObject = this').runInContext(context) + const { fn, globalObject } = context + + // using constructor works + expect(fn).toThrow(globalObject.TypeError) + expect(fn).not.toThrow(globalObject.ReferenceError) + expect(fn).not.toThrow(globalObject.EvalError) + + // using cross-realm error instance should also work + expect(fn).toThrow(new globalObject.TypeError('oops')) + expect(fn).not.toThrow(new globalObject.TypeError('message')) + expect(fn).not.toThrow(new globalObject.ReferenceError('oops')) + expect(fn).not.toThrow(new globalObject.EvalError('no way')) + }) }) }) From 8142016cdca0bd31d35df917a0720825c6d7d9e6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 2 Feb 2026 17:39:55 +0900 Subject: [PATCH 2/6] test: tweak --- packages/expect/src/jest-expect.ts | 4 ++-- test/core/test/jest-expect.test.ts | 27 ++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index a6e04ecdea7f..b04274cde0dd 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -844,8 +844,8 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { ]) return this.assert( equal, - 'expected a thrown value #{this} to equal #{exp}', - 'expected a thrown value #{this} not to equal #{exp}', + 'expected a thrown value to equal #{exp}', + 'expected a thrown value not to equal #{exp}', expected, thrown, ) diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index c691141c178b..e9df4f146f76 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -486,6 +486,7 @@ describe('jest-expect', () => { }) it('non Error instance', () => { + // primitives expect(() => { // eslint-disable-next-line no-throw-literal throw 42 @@ -494,14 +495,30 @@ describe('jest-expect', () => { // eslint-disable-next-line no-throw-literal throw 42 }).not.toThrow(43) + expect(() => { + expect(() => { // eslint-disable-next-line no-throw-literal - throw { foo: 'bar' } - }).toThrow({ foo: 'bar' }) + throw 42 + }).toThrow(43) + }).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected a thrown value to equal 43]`) + + // deep equality + expect(() => { + // eslint-disable-next-line no-throw-literal + throw { foo: 'hello world' } + }).toThrow({ foo: expect.stringContaining('hello') }) expect(() => { // eslint-disable-next-line no-throw-literal throw { foo: 'bar' } - }).not.toThrow({ foo: 'baz' }) + }).not.toThrow({ foo: expect.stringContaining('hello') }) + + expect(() => { + expect(() => { + // eslint-disable-next-line no-throw-literal + throw { foo: 'bar' } + }).toThrow({ foo: expect.stringContaining('hello') }) + }).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected a thrown value to equal { foo: StringContaining "hello" }]`) }) it('error from different realm', async () => { @@ -511,12 +528,12 @@ describe('jest-expect', () => { new vm.Script('fn = () => { throw new TypeError("oops") }; globalObject = this').runInContext(context) const { fn, globalObject } = context - // using constructor works + // constructor expect(fn).toThrow(globalObject.TypeError) expect(fn).not.toThrow(globalObject.ReferenceError) expect(fn).not.toThrow(globalObject.EvalError) - // using cross-realm error instance should also work + // instance expect(fn).toThrow(new globalObject.TypeError('oops')) expect(fn).not.toThrow(new globalObject.TypeError('message')) expect(fn).not.toThrow(new globalObject.ReferenceError('oops')) From da3617c321e02816ac1247566cf916dcdec3c961 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 2 Feb 2026 17:57:13 +0900 Subject: [PATCH 3/6] docs: update toThrow/toThrowError documentation and types - Update type signature to accept any value - Document that any value can be compared using deep equality - Add example for non-Error values Co-Authored-By: Claude Opus 4.5 --- docs/api/expect.md | 17 +++++++++++++++-- packages/expect/src/types.ts | 7 ++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/api/expect.md b/docs/api/expect.md index e6d670ee6f92..0105b07ea714 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -779,7 +779,7 @@ test('the number of elements must match exactly', () => { ## toThrowError -- **Type:** `(received: any) => Awaitable` +- **Type:** `(expected?: any) => Awaitable` - **Alias:** `toThrow` @@ -789,7 +789,7 @@ You can provide an optional argument to test that a specific error is thrown: - `RegExp`: error message matches the pattern - `string`: error message includes the substring -- `Error`, `AsymmetricMatcher`: compare with a received object similar to `toEqual(received)` +- any other value: compare with thrown value using deep equality (similar to `toEqual`) :::tip You must wrap the code in a function, otherwise the error will not be caught, and test will fail. @@ -849,6 +849,19 @@ test('throws on pineapples', async () => { ``` ::: +:::tip +You can also test non-Error values that are thrown: + +```ts +test('throws non-Error values', () => { + // eslint-disable-next-line no-throw-literal + expect(() => { throw 42 }).toThrowError(42) + // eslint-disable-next-line no-throw-literal + expect(() => { throw { message: 'error' } }).toThrowError({ message: 'error' }) +}) +``` +::: + ## toMatchSnapshot - **Type:** `(shape?: Partial | string, hint?: string) => void` diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 6cd76442968f..ad8b008e9484 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -8,7 +8,6 @@ import type { Test } from '@vitest/runner' import type { MockInstance } from '@vitest/spy' -import type { Constructable } from '@vitest/utils' import type { Formatter } from 'tinyrainbow' import type { AsymmetricMatcher } from './jest-asymmetric-matchers' import type { diff, getMatcherUtils, stringify } from './jest-matcher-utils' @@ -535,8 +534,9 @@ export interface JestAssertion extends jest.Matchers, CustomMa * @example * expect(() => functionWithError()).toThrow('Error message'); * expect(() => parseJSON('invalid')).toThrow(SyntaxError); + * expect(() => { throw 42 }).toThrow(42); */ - toThrow: (expected?: string | Constructable | RegExp | Error | unknown) => void + toThrow: (expected?: any) => void /** * Used to test that a function throws when it is called. @@ -546,8 +546,9 @@ export interface JestAssertion extends jest.Matchers, CustomMa * @example * expect(() => functionWithError()).toThrowError('Error message'); * expect(() => parseJSON('invalid')).toThrowError(SyntaxError); + * expect(() => { throw 42 }).toThrowError(42); */ - toThrowError: (expected?: string | Constructable | RegExp | Error | unknown) => void + toThrowError: (expected?: any) => void /** * Use to test that the mock function successfully returned (i.e., did not throw an error) at least one time From e6488c6dca11ee3a36544f47d9d60348f1b8cd03 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 2 Feb 2026 18:03:34 +0900 Subject: [PATCH 4/6] chore: disable no-throw-literal for docs Co-Authored-By: Claude Opus 4.5 --- docs/api/expect.md | 2 -- eslint.config.js | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/api/expect.md b/docs/api/expect.md index 0105b07ea714..b0c0a6b7e8e8 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -854,9 +854,7 @@ You can also test non-Error values that are thrown: ```ts test('throws non-Error values', () => { - // eslint-disable-next-line no-throw-literal expect(() => { throw 42 }).toThrowError(42) - // eslint-disable-next-line no-throw-literal expect(() => { throw { message: 'error' } }).toThrowError({ message: 'error' }) }) ``` diff --git a/eslint.config.js b/eslint.config.js index 08519157aa8e..b8c1e6ff4299 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -116,6 +116,7 @@ export default antfu( 'ts/method-signature-style': 'off', 'no-self-compare': 'off', 'import/no-mutable-exports': 'off', + 'no-throw-literal': 'off', }, }, { From aacc027c1fe34617bfb9356b35240939b97e1858 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 2 Feb 2026 18:17:03 +0900 Subject: [PATCH 5/6] fix: loosen class equality --- packages/expect/src/jest-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/expect/src/jest-utils.ts b/packages/expect/src/jest-utils.ts index 30290ce86915..dbfa2a783fae 100644 --- a/packages/expect/src/jest-utils.ts +++ b/packages/expect/src/jest-utils.ts @@ -272,7 +272,7 @@ function isErrorEqual( // - 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) + Object.prototype.toString.call(a) === Object.prototype.toString.call(b) && a.name === b.name && a.message === b.message ) From 697369593733242af7520322a7ca4aa398d54417 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 2 Feb 2026 18:53:56 +0900 Subject: [PATCH 6/6] test: update --- .../test/__snapshots__/jest-expect.test.ts.snap | 2 +- test/core/test/jest-expect.test.ts | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/test/core/test/__snapshots__/jest-expect.test.ts.snap b/test/core/test/__snapshots__/jest-expect.test.ts.snap index 3a49b010891f..5fb6d4f82793 100644 --- a/test/core/test/__snapshots__/jest-expect.test.ts.snap +++ b/test/core/test/__snapshots__/jest-expect.test.ts.snap @@ -538,7 +538,7 @@ exports[`error equality 4`] = ` "custom": "a", }", "expected": "[Error: hello]", - "message": "expected Error: hello { custom: 'a' } to deeply equal Error: hello { custom: 'a' }", + "message": "expected Error: hello { custom: 'a' } to strictly equal Error: hello { custom: 'a' }", } `; diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index e9df4f146f76..7eef8302af9d 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -485,6 +485,18 @@ describe('jest-expect', () => { }).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected function to throw an error, but it didn't]`) }) + it('custom error class', () => { + class Error1 extends Error {}; + class Error2 extends Error {}; + + // underlying `toEqual` doesn't require constructor/prototype equality + expect(() => { + throw new Error1('hi') + }).toThrowError(new Error2('hi')) + expect(new Error1('hi')).toEqual(new Error2('hi')) + expect(new Error1('hi')).not.toStrictEqual(new Error2('hi')) + }) + it('non Error instance', () => { // primitives expect(() => { @@ -1873,9 +1885,8 @@ it('error equality', () => { // 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 + snapshotError(() => expect(e1).toStrictEqual(e2)) + expect(e1).toEqual(e2) assert.deepEqual(e1, e2) nodeAssert.notDeepStrictEqual(e1, e2) }