diff --git a/docs/03-assertions.md b/docs/03-assertions.md index cb04dcfdb..4dcebeecd 100644 --- a/docs/03-assertions.md +++ b/docs/03-assertions.md @@ -172,10 +172,11 @@ Finally, this returns a boolean indicating whether the assertion passed. ### `.throws(fn, expectation?, message?)` -Assert that an error is thrown. `fn` must be a function which should throw. The thrown value *must* be an error. It is returned so you can run more assertions against it. If the assertion fails then `undefined` is returned. +Assert that an error is thrown. `fn` must be a function which should throw. By default, the thrown value *must* be an error. It is returned so you can run more assertions against it. If the assertion fails then `undefined` is returned. `expectation` can be an object with one or more of the following properties: +* `any`: a boolean only available in AVA 6, if `true` then the thrown value does not need to be an error. Defaults to `false` * `instanceOf`: a constructor, the thrown error must be an instance of * `is`: the thrown error must be strictly equal to `expectation.is` * `message`: the following types are valid: @@ -207,10 +208,11 @@ test('throws', t => { Assert that an error is thrown. `thrower` can be an async function which should throw, or a promise that should reject. This assertion must be awaited. -The thrown value *must* be an error. It is returned so you can run more assertions against it. If the assertion fails then `undefined` is returned. +By default, the thrown value *must* be an error. It is returned so you can run more assertions against it. If the assertion fails then `undefined` is returned. `expectation` can be an object with one or more of the following properties: +* `any`: a boolean only available in AVA 6, if `true` then the thrown value does not need to be an error. Defaults to `false` * `instanceOf`: a constructor, the thrown error must be an instance of * `is`: the thrown error must be strictly equal to `expectation.is` * `message`: the following types are valid: diff --git a/docs/08-common-pitfalls.md b/docs/08-common-pitfalls.md index 9ee2330d2..e99fb0b82 100644 --- a/docs/08-common-pitfalls.md +++ b/docs/08-common-pitfalls.md @@ -14,7 +14,12 @@ Note that the following is not a native error: const error = Object.create(Error.prototype); ``` -This can be surprising, since `error instanceof Error` returns `true`. +This can be surprising, since `error instanceof Error` returns `true`. You can set `any: true` in the expectations to handle these values: + +```js +const error = Object.create(Error.prototype); +t.throws(() => { throw error }, {any: true}); +``` ## AVA in Docker diff --git a/lib/assert.js b/lib/assert.js index 4638c42f9..afffa87d9 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -127,13 +127,21 @@ function validateExpectations(assertion, expectations, numberArgs) { // eslint-d }); } + if (Object.hasOwn(expectations, 'any') && typeof expectations.any !== 'boolean') { + throw new AssertionError(`The \`any\` property of the second argument to \`${assertion}\` must be a boolean`, { + assertion, + formattedDetails: [formatWithLabel('Called with:', expectations)], + }); + } + for (const key of Object.keys(expectations)) { switch (key) { case 'instanceOf': case 'is': case 'message': case 'name': - case 'code': { + case 'code': + case 'any': { continue; } @@ -153,7 +161,8 @@ function validateExpectations(assertion, expectations, numberArgs) { // eslint-d // Note: this function *must* throw exceptions, since it can be used // as part of a pending assertion for promises. function assertExpectations({actual, expectations, message, prefix, assertion, assertionStack}) { - if (!isNativeError(actual)) { + const allowThrowAnything = Object.hasOwn(expectations, 'any') && expectations.any; + if (!isNativeError(actual) && !allowThrowAnything) { throw new AssertionError(message, { assertion, assertionStack, diff --git a/test-tap/assert.js b/test-tap/assert.js index a333717df..00aed409d 100644 --- a/test-tap/assert.js +++ b/test-tap/assert.js @@ -822,6 +822,11 @@ test('.throws()', gather(t => { throw new Error('foo'); })); + // Passes when string is thrown, only when any is set to true. + passes(t, () => assertions.throws(() => { + throw 'foo'; // eslint-disable-line no-throw-literal + }, {any: true})); + // Passes because the correct error is thrown. passes(t, () => { const error = new Error('foo'); @@ -1023,9 +1028,19 @@ test('.throwsAsync()', gather(t => { formattedDetails: [{label: 'Returned promise resolved with:', formatted: /'foo'/}], }); + // Fails because the function returned a promise that rejected, but not with an error. + throwsAsyncFails(t, () => assertions.throwsAsync(() => Promise.reject('foo')), { // eslint-disable-line prefer-promise-reject-errors + assertion: 't.throwsAsync()', + message: '', + formattedDetails: [{label: 'Returned promise rejected with exception that is not an error:', formatted: /'foo'/}], + }); + // Passes because the promise was rejected with an error. throwsAsyncPasses(t, () => assertions.throwsAsync(Promise.reject(new Error()))); + // Passes because the promise was rejected with an with an non-error exception, & set `any` to true in expectation. + throwsAsyncPasses(t, () => assertions.throwsAsync(Promise.reject('foo'), {any: true})); // eslint-disable-line prefer-promise-reject-errors + // Passes because the function returned a promise rejected with an error. throwsAsyncPasses(t, () => assertions.throwsAsync(() => Promise.reject(new Error()))); @@ -1134,6 +1149,12 @@ test('.throws() fails if passed a bad expectation', t => { formattedDetails: [{label: 'Called with:', formatted: /\[]/}], }); + failsWith(t, () => assertions.throws(() => {}, {any: {}}), { + assertion: 't.throws()', + message: 'The `any` property of the second argument to `t.throws()` must be a boolean', + formattedDetails: [{label: 'Called with:', formatted: /any: {}/}], + }); + failsWith(t, () => assertions.throws(() => {}, {code: {}}), { assertion: 't.throws()', message: 'The `code` property of the second argument to `t.throws()` must be a string or number', @@ -1204,6 +1225,12 @@ test('.throwsAsync() fails if passed a bad expectation', t => { formattedDetails: [{label: 'Called with:', formatted: /\[]/}], }, {expectBoolean: false}); + failsWith(t, () => assertions.throwsAsync(() => {}, {any: {}}), { + assertion: 't.throwsAsync()', + message: 'The `any` property of the second argument to `t.throwsAsync()` must be a boolean', + formattedDetails: [{label: 'Called with:', formatted: /any: {}/}], + }, {expectBoolean: false}); + failsWith(t, () => assertions.throwsAsync(() => {}, {code: {}}), { assertion: 't.throwsAsync()', message: 'The `code` property of the second argument to `t.throwsAsync()` must be a string or number', diff --git a/test-tap/reporters/tap.failfast.v16.log b/test-tap/reporters/tap.failfast.v16.log index 7092b0590..f709d4a0e 100644 --- a/test-tap/reporters/tap.failfast.v16.log +++ b/test-tap/reporters/tap.failfast.v16.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator diff --git a/test-tap/reporters/tap.failfast.v18.log b/test-tap/reporters/tap.failfast.v18.log index 7092b0590..f709d4a0e 100644 --- a/test-tap/reporters/tap.failfast.v18.log +++ b/test-tap/reporters/tap.failfast.v18.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator diff --git a/test-tap/reporters/tap.failfast.v20.log b/test-tap/reporters/tap.failfast.v20.log index 7092b0590..f709d4a0e 100644 --- a/test-tap/reporters/tap.failfast.v20.log +++ b/test-tap/reporters/tap.failfast.v20.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator diff --git a/test-tap/reporters/tap.failfast2.v16.log b/test-tap/reporters/tap.failfast2.v16.log index 9c78edb09..ca639266e 100644 --- a/test-tap/reporters/tap.failfast2.v16.log +++ b/test-tap/reporters/tap.failfast2.v16.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator # 1 test remaining in a.cjs diff --git a/test-tap/reporters/tap.failfast2.v18.log b/test-tap/reporters/tap.failfast2.v18.log index 9c78edb09..ca639266e 100644 --- a/test-tap/reporters/tap.failfast2.v18.log +++ b/test-tap/reporters/tap.failfast2.v18.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator # 1 test remaining in a.cjs diff --git a/test-tap/reporters/tap.failfast2.v20.log b/test-tap/reporters/tap.failfast2.v20.log index 9c78edb09..ca639266e 100644 --- a/test-tap/reporters/tap.failfast2.v20.log +++ b/test-tap/reporters/tap.failfast2.v20.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator # 1 test remaining in a.cjs diff --git a/test-tap/reporters/tap.regular.v16.log b/test-tap/reporters/tap.regular.v16.log index 08a91e9cc..b1ab5e641 100644 --- a/test-tap/reporters/tap.regular.v16.log +++ b/test-tap/reporters/tap.regular.v16.log @@ -30,7 +30,7 @@ not ok 3 - nested-objects › format with max depth 4 + }, } message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:351:9)' + at: 'ExecutionContext.deepEqual (/lib/assert.js:360:9)' ... ---tty-stream-chunk-separator not ok 4 - nested-objects › format like with max depth 4 @@ -48,7 +48,7 @@ not ok 4 - nested-objects › format like with max depth 4 }, } message: '' - at: 'ExecutionContext.like (/lib/assert.js:413:9)' + at: 'ExecutionContext.like (/lib/assert.js:422:9)' ... ---tty-stream-chunk-separator # output-in-hook › before hook @@ -72,7 +72,7 @@ not ok 6 - output-in-hook › failing test name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator # output-in-hook › afterEach hook for passing test @@ -102,7 +102,7 @@ not ok 10 - test › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator ok 11 - test › known failure @@ -123,7 +123,7 @@ not ok 13 - test › logs name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator not ok 14 - test › formatted @@ -135,7 +135,7 @@ not ok 14 - test › formatted - 'foo' + 'bar' message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:351:9)' + at: 'ExecutionContext.deepEqual (/lib/assert.js:360:9)' ... ---tty-stream-chunk-separator not ok 15 - test › implementation throws non-error diff --git a/test-tap/reporters/tap.regular.v18.log b/test-tap/reporters/tap.regular.v18.log index 08a91e9cc..b1ab5e641 100644 --- a/test-tap/reporters/tap.regular.v18.log +++ b/test-tap/reporters/tap.regular.v18.log @@ -30,7 +30,7 @@ not ok 3 - nested-objects › format with max depth 4 + }, } message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:351:9)' + at: 'ExecutionContext.deepEqual (/lib/assert.js:360:9)' ... ---tty-stream-chunk-separator not ok 4 - nested-objects › format like with max depth 4 @@ -48,7 +48,7 @@ not ok 4 - nested-objects › format like with max depth 4 }, } message: '' - at: 'ExecutionContext.like (/lib/assert.js:413:9)' + at: 'ExecutionContext.like (/lib/assert.js:422:9)' ... ---tty-stream-chunk-separator # output-in-hook › before hook @@ -72,7 +72,7 @@ not ok 6 - output-in-hook › failing test name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator # output-in-hook › afterEach hook for passing test @@ -102,7 +102,7 @@ not ok 10 - test › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator ok 11 - test › known failure @@ -123,7 +123,7 @@ not ok 13 - test › logs name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator not ok 14 - test › formatted @@ -135,7 +135,7 @@ not ok 14 - test › formatted - 'foo' + 'bar' message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:351:9)' + at: 'ExecutionContext.deepEqual (/lib/assert.js:360:9)' ... ---tty-stream-chunk-separator not ok 15 - test › implementation throws non-error diff --git a/test-tap/reporters/tap.regular.v20.log b/test-tap/reporters/tap.regular.v20.log index 08a91e9cc..b1ab5e641 100644 --- a/test-tap/reporters/tap.regular.v20.log +++ b/test-tap/reporters/tap.regular.v20.log @@ -30,7 +30,7 @@ not ok 3 - nested-objects › format with max depth 4 + }, } message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:351:9)' + at: 'ExecutionContext.deepEqual (/lib/assert.js:360:9)' ... ---tty-stream-chunk-separator not ok 4 - nested-objects › format like with max depth 4 @@ -48,7 +48,7 @@ not ok 4 - nested-objects › format like with max depth 4 }, } message: '' - at: 'ExecutionContext.like (/lib/assert.js:413:9)' + at: 'ExecutionContext.like (/lib/assert.js:422:9)' ... ---tty-stream-chunk-separator # output-in-hook › before hook @@ -72,7 +72,7 @@ not ok 6 - output-in-hook › failing test name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator # output-in-hook › afterEach hook for passing test @@ -102,7 +102,7 @@ not ok 10 - test › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator ok 11 - test › known failure @@ -123,7 +123,7 @@ not ok 13 - test › logs name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:285:9)' + at: 'ExecutionContext.fail (/lib/assert.js:294:9)' ... ---tty-stream-chunk-separator not ok 14 - test › formatted @@ -135,7 +135,7 @@ not ok 14 - test › formatted - 'foo' + 'bar' message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:351:9)' + at: 'ExecutionContext.deepEqual (/lib/assert.js:360:9)' ... ---tty-stream-chunk-separator not ok 15 - test › implementation throws non-error diff --git a/test-types/import-in-cts/throws.cts b/test-types/import-in-cts/throws.cts index 6bae10904..606c1cc15 100644 --- a/test-types/import-in-cts/throws.cts +++ b/test-types/import-in-cts/throws.cts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import test from '../../entrypoints/main.cjs'; -import {expectType} from 'tsd'; +import {expectError, expectType} from 'tsd'; class CustomError extends Error { foo: string; @@ -23,6 +23,10 @@ test('throws', t => { expectType(error4); const error5 = t.throws(() => {}, {instanceOf: CustomError, is: new CustomError()}); expectType(error5); + const error6 = t.throws(() => { throw 'foo' }, {any: true}); + expectType(error6); + // @ts-expect-error TS2769 + expectError(t.throws(() => { throw 'foo' }, {instanceOf: String, is: 'foo'})); }); test('throwsAsync', async t => { @@ -38,4 +42,8 @@ test('throwsAsync', async t => { expectType(error4); const error5 = await t.throwsAsync(async () => {}, {instanceOf: CustomError, is: new CustomError()}); expectType(error5); + const error6 = await t.throwsAsync(async () => { throw 'foo' }, {any: true}); + expectType(error6); + // @ts-expect-error TS2769 + expectError(t.throwsAsync(async () => { throw 'foo' }, {instanceOf: String, is: 'foo'})); }); diff --git a/test-types/module/throws.ts b/test-types/module/throws.ts index 76c1ce47f..79f3aaaa7 100644 --- a/test-types/module/throws.ts +++ b/test-types/module/throws.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import {expectType} from 'tsd'; +import {expectError, expectType} from 'tsd'; import test from '../../entrypoints/main.mjs'; @@ -24,6 +24,14 @@ test('throws', t => { expectType(error4); const error5 = t.throws(() => {}, {instanceOf: CustomError, is: new CustomError()}); expectType(error5); + const error6 = t.throws(() => { + throw 'foo'; // eslint-disable-line @typescript-eslint/no-throw-literal + }, {any: true}); + expectType(error6); + expectError(t.throws(() => { + throw 'foo'; // eslint-disable-line @typescript-eslint/no-throw-literal + // @ts-expect-error TS2769 + }, {instanceOf: String, is: 'foo'})); }); test('throwsAsync', async t => { @@ -39,4 +47,12 @@ test('throwsAsync', async t => { expectType(error4); const error5 = await t.throwsAsync(async () => {}, {instanceOf: CustomError, is: new CustomError()}); expectType(error5); + const error6 = await t.throwsAsync(async () => { + throw 'foo'; // eslint-disable-line @typescript-eslint/no-throw-literal + }, {any: true}); + expectType(error6); + // @ts-expect-error TS2769 + expectError(t.throwsAsync(async () => { + throw 'foo'; // eslint-disable-line @typescript-eslint/no-throw-literal + }, {instanceOf: String, is: 'foo'})); }); diff --git a/types/assertions.d.cts b/types/assertions.d.cts index 59a284af0..8d7e9a510 100644 --- a/types/assertions.d.cts +++ b/types/assertions.d.cts @@ -7,6 +7,9 @@ export type ThrownError = ErrorType /** Specify one or more expectations the thrown error must satisfy. */ export type ThrowsExpectation = { + /** If true, the thrown error is not required to be a native error. */ + any?: false; + /** The thrown error must have a code that equals the given string or number. */ code?: string | number; @@ -23,11 +26,22 @@ export type ThrowsExpectation = { name?: string; }; +export type ThrowsAnyExpectation = Omit, 'any' | 'instanceOf' | 'is'> & { + /** If true, the thrown error is not required to be a native error. */ + any: true; + + /** The thrown error must be an instance of this constructor. */ + instanceOf?: new (...args: any[]) => any; + + /** The thrown error must be strictly equal to this value. */ + is?: any; +} + export type Assertions = { /** * Assert that `actual` is [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), returning a boolean * indicating whether the assertion passed. - * + * * Note: An `else` clause using this as a type guard will be subtly incorrect for `string` and `number` types and will not give `0` or `''` as a potential value in an `else` clause. */ assert: AssertAssertion; @@ -123,7 +137,7 @@ export type Assertions = { /** * Assert that `actual` is [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), returning a boolean * indicating whether the assertion passed. - * + * * Note: An `else` clause using this as a type guard will be subtly incorrect for `string` and `number` types and will not give `0` or `''` as a potential value in an `else` clause. */ truthy: TruthyAssertion; @@ -136,7 +150,7 @@ export type AssertAssertion = { /** * Assert that `actual` is [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), returning a boolean * indicating whether the assertion passed. - * + * * Note: An `else` clause using this as a type guard will be subtly incorrect for `string` and `number` types and will not give `0` or `''` as a potential value in an `else` clause. */ (actual: T, message?: string): actual is T extends Falsy ? never : T; @@ -309,24 +323,45 @@ export type ThrowsAssertion = { */ (fn: () => any, expectations?: ThrowsExpectation, message?: string): ThrownError | undefined; + /** + * Assert that the function throws. If so, returns the error value. + * The error must satisfy all expectations. Returns undefined when the assertion fails. + */ + (fn: () => any, expectations?: ThrowsAnyExpectation, message?: string): unknown; + /** Skip this assertion. */ skip(fn: () => any, expectations?: any, message?: string): void; }; export type ThrowsAsyncAssertion = { /** - * Assert that the async function throws a native error. If so, returns the error - * value. Returns undefined when the assertion fails. You must await the result. The error must satisfy all expectations. + * Assert that the async function throws a native error. If so, returns the + * error value. Returns undefined when the assertion fails. You must await the + * result. The error must satisfy all expectations. */ (fn: () => PromiseLike, expectations?: ThrowsExpectation, message?: string): Promise | undefined>; /** * Assert that the promise rejects with a native error. If so, returns the - * rejection reason. Returns undefined when the assertion fails. You must await the result. The error must satisfy all - * expectations. + * rejection reason. Returns undefined when the assertion fails. You must + * await the result. The error must satisfy all expectations. */ (promise: PromiseLike, expectations?: ThrowsExpectation, message?: string): Promise | undefined>; + /** + * Assert that the async function throws. If so, returns the error value. + * Returns undefined when the assertion fails. You must await the result. The + * error must satisfy all expectations. + */ + (fn: () => PromiseLike, expectations?: ThrowsAnyExpectation, message?: string): Promise; + + /** + * Assert that the promise rejects. If so, returns the rejection reason. + * Returns undefined when the assertion fails. You must await the result. The + * error must satisfy all expectations. + */ + (promise: PromiseLike, expectations?: ThrowsAnyExpectation, message?: string): Promise; + /** Skip this assertion. */ skip(thrower: any, expectations?: any, message?: string): void; }; @@ -345,7 +380,7 @@ export type TruthyAssertion = { /** * Assert that `actual` is [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), returning a boolean * indicating whether the assertion passed. - * + * * Note: An `else` clause using this as a type guard will be subtly incorrect for `string` and `number` types and will not give `0` or `''` as a potential value in an `else` clause. */ (actual: T, message?: string): actual is T extends Falsy ? never : T;