diff --git a/docs/api/expect.md b/docs/api/expect.md index e6d670ee6f92..b0c0a6b7e8e8 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,17 @@ test('throws on pineapples', async () => { ``` ::: +:::tip +You can also test non-Error values that are thrown: + +```ts +test('throws non-Error values', () => { + expect(() => { throw 42 }).toThrowError(42) + expect(() => { throw { message: 'error' } }).toThrowError({ message: 'error' }) +}) +``` +::: + ## toMatchSnapshot - **Type:** `(shape?: Partial | string, hint?: string) => void` 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', }, }, { diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index 4adecb219248..b04274cde0dd 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 to equal #{exp}', + 'expected a thrown value not to equal #{exp}', + expected, + thrown, ) }, ) diff --git a/packages/expect/src/jest-utils.ts b/packages/expect/src/jest-utils.ts index 8b0f9b337c9f..dbfa2a783fae 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) } @@ -257,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 ) @@ -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..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) => 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) => 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 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 81a4ed20daed..e5f47d9bd689 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -484,6 +484,73 @@ describe('jest-expect', () => { }).toThrow(Error) }).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(() => { + // 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(() => { + expect(() => { + // eslint-disable-next-line no-throw-literal + 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: 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 () => { + 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 + + // constructor + expect(fn).toThrow(globalObject.TypeError) + expect(fn).not.toThrow(globalObject.ReferenceError) + expect(fn).not.toThrow(globalObject.EvalError) + + // instance + 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')) + }) }) }) @@ -1892,9 +1959,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) }