diff --git a/README.md b/README.md index d77d48c1f..0c6fd93b3 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ for more information about extending configuration files. | [valid-describe][] | Enforce valid `describe()` callback | ![recommended][] | | | [valid-expect-in-promise][] | Enforce having return statement when testing with promises | ![recommended][] | | | [valid-expect][] | Enforce valid `expect()` usage | ![recommended][] | | +| [prefer-todo][] | Suggest using `test.todo()` | | ![fixable-green][] | ## Credit @@ -151,6 +152,7 @@ for more information about extending configuration files. [valid-describe]: docs/rules/valid-describe.md [valid-expect-in-promise]: docs/rules/valid-expect-in-promise.md [valid-expect]: docs/rules/valid-expect.md +[prefer-todo]: docs/rules/prefer-todo.md [fixable-green]: https://img.shields.io/badge/-fixable-green.svg [fixable-yellow]: https://img.shields.io/badge/-fixable-yellow.svg [recommended]: https://img.shields.io/badge/-recommended-lightgrey.svg diff --git a/docs/rules/prefer-todo.md b/docs/rules/prefer-todo.md new file mode 100644 index 000000000..322fec17e --- /dev/null +++ b/docs/rules/prefer-todo.md @@ -0,0 +1,28 @@ +# Suggest using `test.todo` (prefer-todo) + +When test cases are empty then it is better to mark them as `test.todo` as it +will be highlighted in the summary output. + +## Rule details + +This rule triggers a warning if empty test case is used without 'test.todo'. + +```js +test('i need to write this test'); +``` + +### Default configuration + +The following pattern is considered warning: + +```js +test('i need to write this test'); // Unimplemented test case +test('i need to write this test', () => {}); // Empty test case body +test.skip('i need to write this test', () => {}); // Empty test case body +``` + +The following pattern is not warning: + +```js +test.todo('i need to write this test'); +``` diff --git a/index.js b/index.js index bff8518c6..fb3afbfcf 100644 --- a/index.js +++ b/index.js @@ -27,6 +27,7 @@ const requireTothrowMessage = require('./rules/require-tothrow-message'); const noAliasMethods = require('./rules/no-alias-methods'); const noTestCallback = require('./rules/no-test-callback'); const noTruthyFalsy = require('./rules/no-truthy-falsy'); +const preferTodo = require('./rules/prefer-todo'); const snapshotProcessor = require('./processors/snapshot-processor'); @@ -114,5 +115,6 @@ module.exports = { 'no-alias-methods': noAliasMethods, 'no-test-callback': noTestCallback, 'no-truthy-falsy': noTruthyFalsy, + 'prefer-todo': preferTodo, }, }; diff --git a/rules/__tests__/prefer-todo.test.js b/rules/__tests__/prefer-todo.test.js new file mode 100644 index 000000000..d35e34b2f --- /dev/null +++ b/rules/__tests__/prefer-todo.test.js @@ -0,0 +1,59 @@ +'use strict'; + +const { RuleTester } = require('eslint'); +const rule = require('../prefer-todo'); + +const ruleTester = new RuleTester({ + parserOptions: { ecmaVersion: 2015 }, +}); + +ruleTester.run('prefer-todo', rule, { + valid: [ + 'test.todo("i need to write this test");', + 'test(obj)', + 'fit("foo")', + 'xit("foo")', + 'test("stub", () => expect(1).toBe(1));', + ` + supportsDone && params.length < test.length + ? done => test(...params, done) + : () => test(...params); + `, + ], + invalid: [ + { + code: `test("i need to write this test");`, + errors: [ + { message: 'Prefer todo test case over unimplemented test case' }, + ], + output: 'test.todo("i need to write this test");', + }, + { + code: 'test(`i need to write this test`);', + errors: [ + { message: 'Prefer todo test case over unimplemented test case' }, + ], + output: 'test.todo(`i need to write this test`);', + }, + { + code: 'it("foo", function () {})', + errors: ['Prefer todo test case over empty test case'], + output: 'it.todo("foo")', + }, + { + code: 'it("foo", () => {})', + errors: ['Prefer todo test case over empty test case'], + output: 'it.todo("foo")', + }, + { + code: `test.skip("i need to write this test", () => {});`, + errors: ['Prefer todo test case over empty test case'], + output: 'test.todo("i need to write this test");', + }, + { + code: `test.skip("i need to write this test", function() {});`, + errors: ['Prefer todo test case over empty test case'], + output: 'test.todo("i need to write this test");', + }, + ], +}); diff --git a/rules/prefer-todo.js b/rules/prefer-todo.js new file mode 100644 index 000000000..f75e74570 --- /dev/null +++ b/rules/prefer-todo.js @@ -0,0 +1,78 @@ +'use strict'; + +const { + getDocsUrl, + isFunction, + composeFixers, + getNodeName, + isString, +} = require('./util'); + +function isOnlyTestTitle(node) { + return node.arguments.length === 1; +} + +function isFunctionBodyEmpty(node) { + return node.body.body && !node.body.body.length; +} + +function isTestBodyEmpty(node) { + const fn = node.arguments[1]; // eslint-disable-line prefer-destructuring + return fn && isFunction(fn) && isFunctionBodyEmpty(fn); +} + +function addTodo(node, fixer) { + const testName = getNodeName(node.callee) + .split('.') + .shift(); + return fixer.replaceText(node.callee, `${testName}.todo`); +} + +function removeSecondArg({ arguments: [first, second] }, fixer) { + return fixer.removeRange([first.range[1], second.range[1]]); +} + +function isFirstArgString({ arguments: [firstArg] }) { + return firstArg && isString(firstArg); +} + +const isTestCase = node => + node && + node.type === 'CallExpression' && + ['it', 'test', 'it.skip', 'test.skip'].includes(getNodeName(node.callee)); + +function create(context) { + return { + CallExpression(node) { + if (isTestCase(node) && isFirstArgString(node)) { + const combineFixers = composeFixers(node); + + if (isTestBodyEmpty(node)) { + context.report({ + message: 'Prefer todo test case over empty test case', + node, + fix: combineFixers(removeSecondArg, addTodo), + }); + } + + if (isOnlyTestTitle(node)) { + context.report({ + message: 'Prefer todo test case over unimplemented test case', + node, + fix: combineFixers(addTodo), + }); + } + } + }, + }; +} + +module.exports = { + create, + meta: { + docs: { + url: getDocsUrl(__filename), + }, + fixable: 'code', + }, +}; diff --git a/rules/util.js b/rules/util.js index c3141e0fa..2db81c0d6 100644 --- a/rules/util.js +++ b/rules/util.js @@ -130,6 +130,10 @@ const isDescribe = node => const isFunction = node => node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression'; +const isString = node => + (node.type === 'Literal' && typeof node.value === 'string') || + node.type === 'TemplateLiteral'; + /** * Generates the URL to documentation for the given rule name. It uses the * package version to build the link to a tagged version of the @@ -182,6 +186,14 @@ const scopeHasLocalReference = (scope, referenceName) => { ); }; +function composeFixers(node) { + return (...fixers) => { + return fixerApi => { + return fixers.reduce((all, fixer) => [...all, fixer(node, fixerApi)], []); + }; + }; +} + module.exports = { method, method2, @@ -199,6 +211,8 @@ module.exports = { isDescribe, isFunction, isTestCase, + isString, getDocsUrl, scopeHasLocalReference, + composeFixers, };