diff --git a/README.md b/README.md index b370f1a50..6063691dd 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ https://github.com/dangreenisrael/eslint-plugin-jest-formatting [lowercase-name]: docs/rules/lowercase-name.md [no-alias-methods]: docs/rules/no-alias-methods.md [no-disabled-tests]: docs/rules/no-disabled-tests.md +[no-duplicate-hooks]: docs/rules/no-duplicate-hooks.md [no-commented-out-tests]: docs/rules/no-commented-out-tests.md [no-empty-title]: docs/rules/no-empty-title.md [no-focused-tests]: docs/rules/no-focused-tests.md diff --git a/docs/rules/no-duplicate-hooks.md b/docs/rules/no-duplicate-hooks.md new file mode 100644 index 000000000..c11388e12 --- /dev/null +++ b/docs/rules/no-duplicate-hooks.md @@ -0,0 +1,75 @@ +# Disallow duplicate setup and teardown hooks (no-duplicate-hooks) + +A describe block should not contain duplicate hooks. + +## Rule Details + +Examples of **incorrect** code for this rule + +```js +/* eslint jest/no-duplicate-hooks: "error" */ + +describe('foo', () => { + beforeEach(() => { + // some setup + }); + beforeEach(() => { + // some setup + }); + test('foo_test', () => { + // some test + }); +}); + +// Nested describe scenario +describe('foo', () => { + beforeEach(() => { + // some setup + }); + test('foo_test', () => { + // some test + }); + describe('bar', () => { + test('bar_test', () => { + afterAll(() => { + // some teardown + }); + afterAll(() => { + // some teardown + }); + }); + }); +}); +``` + +Examples of **correct** code for this rule + +```js +/* eslint jest/no-duplicate-hooks: "error" */ + +describe('foo', () => { + beforeEach(() => { + // some setup + }); + test('foo_test', () => { + // some test + }); +}); + +// Nested describe scenario +describe('foo', () => { + beforeEach(() => { + // some setup + }); + test('foo_test', () => { + // some test + }); + describe('bar', () => { + test('bar_test', () => { + beforeEach(() => { + // some setup + }); + }); + }); +}); +``` diff --git a/src/__tests__/rules.test.js b/src/__tests__/rules.test.js index defda6016..47a359833 100644 --- a/src/__tests__/rules.test.js +++ b/src/__tests__/rules.test.js @@ -5,7 +5,7 @@ const path = require('path'); const { rules } = require('../'); const ruleNames = Object.keys(rules); -const numberOfRules = 32; +const numberOfRules = 33; describe('rules', () => { it('should have a corresponding doc for each rule', () => { diff --git a/src/rules/__tests__/no-duplicate-hooks.test.js b/src/rules/__tests__/no-duplicate-hooks.test.js new file mode 100644 index 000000000..07c70d331 --- /dev/null +++ b/src/rules/__tests__/no-duplicate-hooks.test.js @@ -0,0 +1,311 @@ +'use strict'; + +const { RuleTester } = require('eslint'); +const rule = require('../no-duplicate-hooks'); + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 6, + }, +}); + +ruleTester.run('basic describe block', rule, { + valid: [ + [ + 'describe("foo", () => {', + ' beforeEach(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + '})', + ].join('\n'), + [ + 'beforeEach(() => {', + '}),', + 'test("bar", () => {', + ' some_fn();', + '})', + ].join('\n'), + ], + + invalid: [ + { + code: [ + 'describe("foo", () => {', + ' beforeEach(() => {', + ' }),', + ' beforeEach(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + '})', + ].join('\n'), + errors: [ + { + messageId: 'noDuplicateHook', + data: { hook: 'beforeEach' }, + column: 3, + line: 4, + }, + ], + }, + { + code: [ + 'describe.skip("foo", () => {', + ' beforeEach(() => {', + ' }),', + ' beforeAll(() => {', + ' }),', + ' beforeAll(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + '})', + ].join('\n'), + errors: [ + { + messageId: 'noDuplicateHook', + data: { hook: 'beforeAll' }, + column: 3, + line: 6, + }, + ], + }, + { + code: [ + 'describe.skip("foo", () => {', + ' afterEach(() => {', + ' }),', + ' afterEach(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + '})', + ].join('\n'), + errors: [ + { + messageId: 'noDuplicateHook', + data: { hook: 'afterEach' }, + column: 3, + line: 4, + }, + ], + }, + { + code: [ + 'describe.skip("foo", () => {', + ' afterAll(() => {', + ' }),', + ' afterAll(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + '})', + ].join('\n'), + errors: [ + { + messageId: 'noDuplicateHook', + data: { hook: 'afterAll' }, + column: 3, + line: 4, + }, + ], + }, + { + code: [ + 'afterAll(() => {', + '}),', + 'afterAll(() => {', + '}),', + 'test("bar", () => {', + ' some_fn();', + '})', + ].join('\n'), + errors: [ + { + messageId: 'noDuplicateHook', + data: { hook: 'afterAll' }, + column: 1, + line: 3, + }, + ], + }, + { + code: [ + 'describe("foo", () => {', + ' beforeEach(() => {', + ' }),', + ' beforeEach(() => {', + ' }),', + ' beforeEach(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + '})', + ].join('\n'), + errors: [ + { + messageId: 'noDuplicateHook', + data: { hook: 'beforeEach' }, + column: 3, + line: 4, + }, + { + messageId: 'noDuplicateHook', + data: { hook: 'beforeEach' }, + column: 3, + line: 6, + }, + ], + }, + { + code: [ + 'describe.skip("foo", () => {', + ' afterAll(() => {', + ' }),', + ' afterAll(() => {', + ' }),', + ' beforeAll(() => {', + ' }),', + ' beforeAll(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + '})', + ].join('\n'), + errors: [ + { + messageId: 'noDuplicateHook', + data: { hook: 'afterAll' }, + column: 3, + line: 4, + }, + { + messageId: 'noDuplicateHook', + data: { hook: 'beforeAll' }, + column: 3, + line: 8, + }, + ], + }, + ], +}); + +ruleTester.run('multiple describe blocks', rule, { + valid: [ + [ + 'describe.skip("foo", () => {', + ' beforeEach(() => {', + ' }),', + ' beforeAll(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + '})', + 'describe("foo", () => {', + ' beforeEach(() => {', + ' }),', + ' beforeAll(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + '})', + ].join('\n'), + ], + + invalid: [ + { + code: [ + 'describe.skip("foo", () => {', + ' beforeEach(() => {', + ' }),', + ' beforeAll(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + '})', + 'describe("foo", () => {', + ' beforeEach(() => {', + ' }),', + ' beforeEach(() => {', + ' }),', + ' beforeAll(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + '})', + ].join('\n'), + errors: [ + { + messageId: 'noDuplicateHook', + data: { hook: 'beforeEach' }, + column: 3, + line: 13, + }, + ], + }, + ], +}); + +ruleTester.run('nested describe blocks', rule, { + valid: [ + [ + 'describe("foo", () => {', + ' beforeEach(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + ' describe("inner_foo", () => {', + ' beforeEach(() => {', + ' })', + ' test("inner bar", () => {', + ' some_fn();', + ' })', + ' })', + '})', + ].join('\n'), + ], + + invalid: [ + { + code: [ + 'describe("foo", () => {', + ' beforeAll(() => {', + ' }),', + ' test("bar", () => {', + ' some_fn();', + ' })', + ' describe("inner_foo", () => {', + ' beforeEach(() => {', + ' })', + ' beforeEach(() => {', + ' })', + ' test("inner bar", () => {', + ' some_fn();', + ' })', + ' })', + '})', + ].join('\n'), + errors: [ + { + messageId: 'noDuplicateHook', + data: { hook: 'beforeEach' }, + column: 5, + line: 10, + }, + ], + }, + ], +}); diff --git a/src/rules/no-duplicate-hooks.js b/src/rules/no-duplicate-hooks.js new file mode 100644 index 000000000..2bdf04007 --- /dev/null +++ b/src/rules/no-duplicate-hooks.js @@ -0,0 +1,48 @@ +'use strict'; + +const { getDocsUrl, isDescribe, isHook } = require('./util'); + +const newHookContext = () => ({ + beforeAll: 0, + beforeEach: 0, + afterAll: 0, + afterEach: 0, +}); + +module.exports = { + meta: { + docs: { + url: getDocsUrl(__filename), + }, + messages: { + noDuplicateHook: 'Duplicate {{hook}} in describe block', + }, + }, + create(context) { + const hookContexts = [newHookContext()]; + return { + CallExpression(node) { + if (isDescribe(node)) { + hookContexts.push(newHookContext()); + } + + if (isHook(node)) { + const currentLayer = hookContexts[hookContexts.length - 1]; + currentLayer[node.callee.name] += 1; + if (currentLayer[node.callee.name] > 1) { + context.report({ + messageId: 'noDuplicateHook', + data: { hook: node.callee.name }, + node, + }); + } + } + }, + 'CallExpression:exit'(node) { + if (isDescribe(node)) { + hookContexts.pop(); + } + }, + }; + }, +};