diff --git a/docs/rules/prefer-t-throws.md b/docs/rules/prefer-t-throws.md new file mode 100644 index 0000000..d940734 --- /dev/null +++ b/docs/rules/prefer-t-throws.md @@ -0,0 +1,56 @@ +# Prefer using `t.throws()` or `t.throwsAsync()` over try/catch + +This rule will enforce the use of `t.throws()` or `t.throwsAsync()` when possible. + +## Fail + +```js +const test = require('ava'); + +test('some test', async t => { + try { + await throwingFunction(); + t.fail(); + } catch (error) { + t.is(error.message, 'Unicorn overload'); + } +}); +``` + +```js +const test = require('ava'); + +test('some test', async t => { + try { + await potentiallyThrowingFunction(); + await anotherPromise; + await timeout(100, 'Unicorn timeout'); + t.fail(); + } catch (error) { + t.ok(error.message.startsWith('Unicorn')); + } +}); +``` + +```js +const test = require('ava'); + +test('some test', async t => { + try { + synchronousThrowingFunction(); + t.fail(); + } catch (error) { + t.is(error.message, 'Missing Unicorn argument'); + } +}); +``` + +## Pass + +```js +const test = require('ava'); + +test('some test', async t => { + await t.throwsAsync(asyncThrowingFunction(), {message: 'Unicorn overload'}); +}); +``` diff --git a/index.js b/index.js index f671c8f..72070a6 100644 --- a/index.js +++ b/index.js @@ -41,6 +41,7 @@ module.exports = { 'ava/prefer-async-await': 'error', 'ava/prefer-power-assert': 'off', 'ava/prefer-t-regex': 'error', + 'ava/prefer-t-throws': 'error', 'ava/test-title': 'error', 'ava/test-title-format': 'off', 'ava/use-t-well': 'error', diff --git a/readme.md b/readme.md index 5d8383f..db8f671 100644 --- a/readme.md +++ b/readme.md @@ -56,6 +56,7 @@ Configure it in `package.json`. "ava/prefer-async-await": "error", "ava/prefer-power-assert": "off", "ava/prefer-t-regex": "error", + "ava/prefer-t-throws": "error", "ava/test-title": "error", "ava/test-title-format": "off", "ava/use-t": "error", @@ -92,6 +93,7 @@ The rules will only activate in test files. - [prefer-async-await](docs/rules/prefer-async-await.md) - Prefer using async/await instead of returning a Promise. - [prefer-power-assert](docs/rules/prefer-power-assert.md) - Allow only use of the asserts that have no [power-assert](https://github.com/power-assert-js/power-assert) alternative. - [prefer-t-regex](docs/rules/prefer-t-regex.md) - Prefer using `t.regex()` to test regular expressions. *(fixable)* +- [prefer-t-throws](docs/rules/prefer-t-throws.md) - Prefer using `t.throws()` or `t.throwsAsync()` over try/catch. - [test-title](docs/rules/test-title.md) - Ensure tests have a title. - [test-title-format](docs/rules/test-title-format.md) - Ensure test titles have a certain format. - [use-t](docs/rules/use-t.md) - Ensure test functions use `t` as their parameter. diff --git a/rules/prefer-t-throws.js b/rules/prefer-t-throws.js new file mode 100644 index 0000000..fad9982 --- /dev/null +++ b/rules/prefer-t-throws.js @@ -0,0 +1,140 @@ +'use strict'; + +const {visitIf} = require('enhance-visitors'); +const createAvaRule = require('../create-ava-rule'); +const util = require('../util'); + +// This function checks if there is an AwaitExpression, which is not inside another function. +// +// TODO: find a simpler way to do this +function hasAwaitExpression(nodes) { + if (!nodes) { + return false; + } + + for (const node of nodes) { + if (!node) { + continue; + } + + if (node.type === 'ExpressionStatement' && hasAwaitExpression([node.expression])) { + return true; + } + + if (node.type === 'AwaitExpression') { + return true; + } + + if (node.expressions && hasAwaitExpression(node.expressions)) { + return true; + } + + if (node.type === 'BlockStatement' && hasAwaitExpression(node.body)) { + return true; + } + + if (node.type === 'MemberExpression' && hasAwaitExpression([node.object, node.property])) { + return true; + } + + if ((node.type === 'CallExpression' || node.type === 'NewExpression') + && hasAwaitExpression([...node.arguments, node.callee])) { + return true; + } + + if (node.left && node.right && hasAwaitExpression([node.left, node.right])) { + return true; + } + + if (node.type === 'SequenceExpression' && hasAwaitExpression(node.expressions)) { + return true; + } + + if (node.type === 'VariableDeclaration' + && hasAwaitExpression(node.declarations.map(declaration => declaration.init))) { + return true; + } + + if (node.type === 'ThrowStatement' && hasAwaitExpression([node.argument])) { + return true; + } + + if (node.type === 'IfStatement' && hasAwaitExpression([node.test, node.consequent, node.alternate])) { + return true; + } + + if (node.type === 'SwitchStatement' + // eslint-disable-next-line unicorn/prefer-spread + && hasAwaitExpression([node.discriminant, ...node.cases.flatMap(caseNode => [caseNode.test].concat(caseNode.consequent))])) { + return true; + } + + if (node.type.endsWith('WhileStatement') && hasAwaitExpression([node.test, node.body])) { + return true; + } + + if (node.type === 'ForStatement' && hasAwaitExpression([node.init, node.test, node.update, node.body])) { + return true; + } + + if (node.type === 'ForInStatement' && hasAwaitExpression([node.right, node.body])) { + return true; + } + + if (node.type === 'ForOfStatement' && (node.await || hasAwaitExpression([node.right, node.body]))) { + return true; + } + + if (node.type === 'WithStatement' && hasAwaitExpression([node.object, node.body])) { + return true; + } + } + + return false; +} + +const create = context => { + const ava = createAvaRule(); + + return ava.merge({ + TryStatement: visitIf([ + ava.isInTestFile, + ava.isInTestNode, + ])(node => { + const nodes = node.block.body; + if (nodes.length < 2) { + return; + } + + const tFailIndex = [...nodes].reverse().findIndex(node => node.type === 'ExpressionStatement' + && node.expression.type === 'CallExpression' + && node.expression.callee.object + && node.expression.callee.object.name === 't' + && node.expression.callee.property + && node.expression.callee.property.name === 'fail'); + + // Return if there is no t.fail() or if it's the first node + if (tFailIndex === -1 || tFailIndex === nodes.length - 1) { + return; + } + + const beforeNodes = nodes.slice(0, nodes.length - 1 - tFailIndex); + + context.report({ + node, + message: `Prefer using the \`t.throws${hasAwaitExpression(beforeNodes) ? 'Async' : ''}()\` assertion.`, + }); + }), + }); +}; + +module.exports = { + create, + meta: { + type: 'suggestion', + docs: { + url: util.getDocsUrl(__filename), + }, + schema: [], + }, +}; diff --git a/test/prefer-t-throws.js b/test/prefer-t-throws.js new file mode 100644 index 0000000..4ac4ac7 --- /dev/null +++ b/test/prefer-t-throws.js @@ -0,0 +1,68 @@ +'use strict'; + +const test = require('ava'); +const avaRuleTester = require('eslint-ava-rule-tester'); +const rule = require('../rules/prefer-t-throws'); + +const ruleTester = avaRuleTester(test, { + parserOptions: { + ecmaVersion: 'latest', + }, +}); + +const header = 'const test = require(\'ava\');\n'; + +ruleTester.run('prefer-t-throws', rule, { + valid: [ + `${header}test(async t => { const error = await t.throwsAsync(promise); t.is(error, 'error'); });`, + `${header}test(t => { const error = t.throws(fn()); t.is(error, 'error'); });`, + `${header}test(async t => { try { t.fail(); unicorn(); } catch (error) { t.is(error, 'error'); } });`, + `${header}test(async t => { try { await promise; } catch (error) { t.is(error, 'error'); } });`, + ], + invalid: [ + { + code: `${header}test(async t => { try { async function unicorn() { throw await Promise.resolve('error') }; unicorn(); t.fail(); } catch (error) { t.is(error, 'error'); } });`, + errors: [{message: 'Prefer using the `t.throws()` assertion.'}], + }, + { + code: `${header}test(async t => { try { await Promise.reject('error'); t.fail(); } catch (error) { t.is(error, 'error'); } });`, + errors: [{message: 'Prefer using the `t.throwsAsync()` assertion.'}], + }, + { + code: `${header}test(async t => { try { if (await promise); t.fail(); } catch (error) { t.is(error, 'error'); } });`, + errors: [{message: 'Prefer using the `t.throwsAsync()` assertion.'}], + }, + { + code: `${header}test(async t => { try { (await 1) > 2; t.fail(); } catch (error) { t.is(error, 'error'); } });`, + errors: [{message: 'Prefer using the `t.throwsAsync()` assertion.'}], + }, + { + code: `${header}test(async t => { try { (await getArray())[0]; t.fail(); } catch (error) { t.is(error, 'error'); } });`, + errors: [{message: 'Prefer using the `t.throwsAsync()` assertion.'}], + }, + { + code: `${header}test(async t => { try { getArraySync(await 20)[0]; t.fail(); } catch (error) { t.is(error, 'error'); } });`, + errors: [{message: 'Prefer using the `t.throwsAsync()` assertion.'}], + }, + { + code: `${header}test(async t => { try { getArraySync()[await 0]; t.fail(); } catch (error) { t.is(error, 'error'); } });`, + errors: [{message: 'Prefer using the `t.throwsAsync()` assertion.'}], + }, + { + code: `${header}test(async t => { try { new (await cl())(1); t.fail(); } catch (error) { t.is(error, 'error'); } });`, + errors: [{message: 'Prefer using the `t.throwsAsync()` assertion.'}], + }, + { + code: `${header}test(async t => { try { if (false) { await promise; }; t.fail(); } catch (error) { t.is(error, 'error'); } });`, + errors: [{message: 'Prefer using the `t.throwsAsync()` assertion.'}], + }, + { + code: `${header}test(t => { try { undefined(); t.fail(); } catch (error) { t.ok(error instanceof TypeError); } });`, + errors: [{message: 'Prefer using the `t.throws()` assertion.'}], + }, + { + code: `${header}test(async t => { try { undefined(); t.fail(); } catch (error) { t.ok(error instanceof TypeError); } });`, + errors: [{message: 'Prefer using the `t.throws()` assertion.'}], + }, + ], +});