Repository: lo1tuma/eslint-plugin-mocha
Pull request #105 (2016-09-28 17:43:11)

Add 'one-suite-per-file' rule (#103)
This is for the issue #103. Please review.

I think I have not described the motivation for this rule well enough. Please see if you can think of a better way or more reasons to have this rule enabled. Thanks!


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 72da6d6..0407771 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -15,3 +15,4 @@
 * [no-hooks-for-single-case](no-hooks-for-single-case.md) - disallow hooks for a single test or test suite
 * [no-top-level-hooks](no-top-level-hooks.md) - disallow top-level hooks
 * [no-identical-title](no-identical-title.md) - disallow identical titles
+* [max-top-level-suites](max-top-level-suites.md) - limit the number of top-level suites in a single file
diff --git a/docs/rules/max-top-level-suites.md b/docs/rules/max-top-level-suites.md
new file mode 100644
index 0000000..43a3597
--- /dev/null
+++ b/docs/rules/max-top-level-suites.md
@@ -0,0 +1,42 @@
+# Limit the number of top-level suites in a single file (max-top-level-suites)
+
+This rule enforces having a limited amount of top-level suites in a file - by default a single suite per file is allowed.
+
+Multiple `describe` blocks is often a sign that a test should be broken down to multiple files.
+One of the possible problems if having multiple suites is that, if you are, for example, going through tests one by one and focusing them (using `describe.only`), you might not notice `describe` block(s) that are down there at the bottom of a file which may lead to tests being unintentionally skipped.
+
+## Rule Details
+
+The rule supports "describe", "context" and "suite" suite function names and different valid suite name prefixes like "skip" or "only".
+
+The following patterns are considered warnings:
+
+```js
+describe('foo', function () {
+    it('should do foo', function() {});
+});
+
+describe('bar', function() {
+    it('should do bar', function() {});
+});
+```
+
+These patterns would not be considered warnings:
+
+```js
+describe('foo', function () {
+    it('should do foo', function() {});
+
+    describe('bar', function() {
+        it('should do bar', function() {});
+    });
+});
+```
+
+If you want to change the suite limit to, for instance, 2 suites per file:
+
+```js
+rules: {
+   "mocha/max-top-level-suites": ["warning", {limit: 2}]
+},
+```
diff --git a/index.js b/index.js
index 308489b..bc75223 100644
--- a/index.js
+++ b/index.js
@@ -16,7 +16,8 @@ module.exports = {
         'no-hooks-for-single-case': require('./lib/rules/no-hooks-for-single-case'),
         'no-sibling-hooks': require('./lib/rules/no-sibling-hooks'),
         'no-top-level-hooks': require('./lib/rules/no-top-level-hooks'),
-        'no-identical-title': require('./lib/rules/no-identical-title')
+        'no-identical-title': require('./lib/rules/no-identical-title'),
+        'max-top-level-suites': require('./lib/rules/max-top-level-suites')
     },
     configs: {
         recommended: {
diff --git a/lib/rules/max-top-level-suites.js b/lib/rules/max-top-level-suites.js
new file mode 100644
index 0000000..94cc53c
--- /dev/null
+++ b/lib/rules/max-top-level-suites.js
@@ -0,0 +1,50 @@
+'use strict';
+
+/**
+ * @fileoverview Limit the number of top-level suites in a single file
+ * @author Alexander Afanasyev
+ */
+
+var R = require('ramda'),
+    astUtil = require('../util/ast'),
+    defaultSuiteLimit = 1;
+
+module.exports = function (context) {
+    var stack = [],
+        topLevelDescribes = [],
+        options = context.options[0] || {},
+        suiteLimit;
+
+    if (R.isNil(options.limit)) {
+        suiteLimit = defaultSuiteLimit;
+    } else {
+        suiteLimit = options.limit;
+    }
+
+    return {
+        CallExpression: function (node) {
+            if (astUtil.isDescribe(node)) {
+                stack.push(node);
+            }
+        },
+
+        'CallExpression:exit': function (node) {
+            if (astUtil.isDescribe(node)) {
+                if (stack.length === 1) {
+                    topLevelDescribes.push(node);
+                }
+
+                stack.pop(node);
+            }
+        },
+
+        'Program:exit': function () {
+            if (topLevelDescribes.length > suiteLimit) {
+                context.report({
+                    node: topLevelDescribes[suiteLimit],
+                    message: 'The number of top-level suites is more than ' + suiteLimit + '.'
+                });
+            }
+        }
+    };
+};
diff --git a/lib/util/ast.js b/lib/util/ast.js
index fe238fb..50b47bb 100644
--- a/lib/util/ast.js
+++ b/lib/util/ast.js
@@ -2,21 +2,23 @@
 
 'use strict';
 
-var describeAliases = [ 'describe', 'xdescribe', 'context', 'xcontext' ],
+var describeAliases = [ 'describe', 'xdescribe', 'describe.only', 'describe.skip',
+                        'context', 'xcontext', 'context.only', 'context.skip',
+                        'suite', 'xsuite', 'suite.only', 'suite.skip' ],
     hooks = [ 'before', 'after', 'beforeEach', 'afterEach' ],
     testCaseNames = [ 'it', 'it.only', 'test', 'test.only', 'specify', 'specify.only' ];
 
-function isDescribeIdentifier(node) {
-    return node.type === 'Identifier' && describeAliases.indexOf(node.name) !== -1;
+function getNodeName(node) {
+    if (node.type === 'MemberExpression') {
+        return node.object.name + '.' + node.property.name;
+    }
+    return node.name;
 }
 
 function isDescribe(node) {
   return node
       && node.type === 'CallExpression'
-      && (isDescribeIdentifier(node.callee)
-          // eslint-disable-next-line no-extra-parens
-          || (node.callee.type === 'MemberExpression' && isDescribeIdentifier(node.callee.object))
-      );
+      && describeAliases.indexOf(getNodeName(node.callee)) > -1;
 }
 
 function isHookIdentifier(node) {
@@ -25,13 +27,6 @@ function isHookIdentifier(node) {
       && hooks.indexOf(node.name) !== -1;
 }
 
-function getNodeName(node) {
-    if (node.type === 'MemberExpression') {
-        return node.object.name + '.' + node.property.name;
-    }
-    return node.name;
-}
-
 function isTestCase(node) {
     return node
         && node.type === 'CallExpression'
@@ -40,7 +35,6 @@ function isTestCase(node) {
 
 module.exports = {
   isDescribe: isDescribe,
-  isDescribeIdentifier: isDescribeIdentifier,
   isHookIdentifier: isHookIdentifier,
   isTestCase: isTestCase
 };


Test patch:
diff --git a/test/rules/max-top-level-suites.js b/test/rules/max-top-level-suites.js
new file mode 100644
index 0000000..d4a0efc
--- /dev/null
+++ b/test/rules/max-top-level-suites.js
@@ -0,0 +1,156 @@
+'use strict';
+
+var RuleTester = require('eslint').RuleTester,
+    rules = require('../../').rules,
+    ruleTester = new RuleTester();
+
+ruleTester.run('max-top-level-suites', rules['max-top-level-suites'], {
+    valid: [
+        {
+            code: 'describe("This is a test", function () { });'
+        },
+        {
+            code: 'context("This is a test", function () { });'
+        },
+        {
+            code: 'suite("This is a test", function () { });'
+        },
+        {
+            code: 'describe("This is a test", function () { describe("This is a different test", function () { }) });'
+        },
+        {
+            code: 'context("This is a test", function () { context("This is a different test", function () { }) });'
+        },
+        {
+            code: 'suite("This is a test", function () { suite("This is a different test", function () { }) });'
+        },
+        {
+            options: [ { limit: 2 } ],
+            code: 'describe("This is a test", function () { });'
+        },
+        {
+            options: [ { limit: 1 } ],
+            code: 'someOtherFunction();'
+        },
+        {
+            options: [ { limit: 0 } ],
+            code: 'someOtherFunction();'
+        },
+        {
+            options: [ { } ],
+            code: 'someOtherFunction();'
+        },
+        'someOtherFunction();'
+    ],
+
+    invalid: [
+        {
+            code: 'describe("this is a test", function () { });' +
+                  'describe("this is a different test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 1.' }
+            ]
+        },
+        {
+            code: 'describe("this is a test", function () { });' +
+                  'describe("this is a different test", function () { });' +
+                  'describe("this is an another different test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 1.' }
+            ]
+        },
+        {
+            code: 'context("this is a test", function () { });' +
+                  'context("this is a different test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 1.' }
+            ]
+        },
+        {
+            code: 'suite("this is a test", function () { });' +
+                  'suite("this is a different test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 1.' }
+            ]
+        },
+        {
+            code: 'describe("this is a test", function () { }); context("this is a test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 1.' }
+            ]
+        },
+        {
+            code: 'suite("this is a test", function () { }); context("this is a test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 1.' }
+            ]
+        },
+        {
+            code: 'describe("this is a test", function () { });' +
+                  'someOtherFunction();' +
+                  'describe("this is a different test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 1.' }
+            ]
+        },
+        {
+            code: 'someOtherFunction();' +
+                  'describe("this is a test", function () { });' +
+                  'describe("this is a different test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 1.' }
+            ]
+        },
+        {
+            options: [ { limit: 2 } ],
+            code: 'describe.skip("this is a test", function () { });' +
+                  'describe.only("this is a different test", function () { });' +
+                  'describe("this is a whole different test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 2.' }
+            ]
+        },
+        {
+            options: [ { limit: 1 } ],
+            code: 'xdescribe("this is a test", function () { });' +
+                  'describe.only("this is a different test", function () { });' +
+                  'describe("this is a whole different test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 1.' }
+            ]
+        },
+        {
+            options: [ { limit: 2 } ],
+            code: 'suite.skip("this is a test", function () { });' +
+                  'suite.only("this is a different test", function () { });' +
+                  'suite("this is a whole different test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 2.' }
+            ]
+        },
+        {
+            options: [ { limit: 2 } ],
+            code: 'context.skip("this is a test", function () { });' +
+                  'context.only("this is a different test", function () { });' +
+                  'context("this is a whole different test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 2.' }
+            ]
+        },
+        {
+            options: [ { limit: 0 } ],
+            code: 'describe("this is a test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 0.' }
+            ]
+        },
+        {
+            options: [ { } ],
+            code: 'describe("this is a test", function () { });' +
+                  'describe.only("this is a different test", function () { });',
+            errors: [
+                { message: 'The number of top-level suites is more than 1.' }
+            ]
+        }
+    ]
+});