Skip to content

Commit

Permalink
Add prefer-t-throws rule
Browse files Browse the repository at this point in the history
Fixes: avajs#156
  • Loading branch information
Mesteery committed Feb 15, 2022
1 parent 33dbbc7 commit 1561b35
Show file tree
Hide file tree
Showing 5 changed files with 267 additions and 0 deletions.
56 changes: 56 additions & 0 deletions docs/rules/prefer-t-throws.md
Original file line number Diff line number Diff line change
@@ -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'});
});
```
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down
140 changes: 140 additions & 0 deletions rules/prefer-t-throws.js
Original file line number Diff line number Diff line change
@@ -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: [],
},
};
68 changes: 68 additions & 0 deletions test/prefer-t-throws.js
Original file line number Diff line number Diff line change
@@ -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.'}],
},
],
});

0 comments on commit 1561b35

Please sign in to comment.