Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 13 additions & 2 deletions docs/api/expect.md
Original file line number Diff line number Diff line change
Expand Up @@ -779,7 +779,7 @@ test('the number of elements must match exactly', () => {

## toThrowError

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

- **Alias:** `toThrow`

Expand All @@ -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.
Expand Down Expand Up @@ -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:** `<T>(shape?: Partial<T> | string, hint?: string) => void`
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
{
Expand Down
15 changes: 12 additions & 3 deletions packages/expect/src/jest-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
arrayBufferEquality,
generateToBeMessage,
getObjectSubset,
isError,
iterableEquality,
equals as jestEquals,
sparseArrayEquality,
Expand Down Expand Up @@ -808,7 +809,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
)
}

if (expected instanceof Error) {
if (isError(expected)) {
const equal = jestEquals(thrown, expected, [
...customTesters,
iterableEquality,
Expand Down Expand Up @@ -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,
)
},
)
Expand Down
21 changes: 18 additions & 3 deletions packages/expect/src/jest-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,21 @@ function asymmetricMatch(a: any, b: any, customTesters: Array<Tester>) {
}
}

// 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(
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This prototype check is loosened to match our default toEqual behavior. This also becomes our toThrowError equality, so that got loosened as seen in test/core/test/jest-expect.test.ts. I think it's expected that users would need to do toStrictEqual to opt-in stricter check (though that's not directly possible via toThrowError). I consider this change as a valid fix.

&& a.name === b.name
&& a.message === b.message
)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -535,8 +534,9 @@ export interface JestAssertion<T = any> extends jest.Matchers<void, T>, 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.
Expand All @@ -546,8 +546,9 @@ export interface JestAssertion<T = any> extends jest.Matchers<void, T>, 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
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The documentation already had any, so I changed the type entirely to any.


/**
* Use to test that the mock function successfully returned (i.e., did not throw an error) at least one time
Expand Down
2 changes: 1 addition & 1 deletion test/core/test/__snapshots__/jest-expect.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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' }",
}
`;

Expand Down
72 changes: 69 additions & 3 deletions test/core/test/jest-expect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
})
})
})

Expand Down Expand Up @@ -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)
}
Expand Down
Loading