Repository: lo1tuma/eslint-plugin-mocha
Pull request #263 (2020-08-01 10:58:07)

New rule no-exports
~~Depends on #262.~~


Patch:
diff --git a/benchmarks/startup.bench.js b/benchmarks/startup.bench.js
index e8f1c61..f1ca2c2 100644
--- a/benchmarks/startup.bench.js
+++ b/benchmarks/startup.bench.js
@@ -3,18 +3,60 @@
 const os = require('os');
 const { expect } = require('chai');
 const { performance } = require('perf_hooks');
+const times = require('ramda/src/times');
+const median = require('ramda/src/median');
+const map = require('ramda/src/map');
+const prop = require('ramda/src/prop');
 
 const [ { speed: cpuSpeed } ] = os.cpus();
 
+function clearRequireCache() {
+    Object.keys(require.cache).forEach(function (key) {
+        delete require.cache[key];
+    });
+}
+
+function runBenchmark(fn, count) {
+    const results = [];
+
+    times(() => {
+        const startTime = performance.now();
+        const startMemory = process.memoryUsage().rss;
+        fn();
+        const endTime = performance.now();
+        const endMemory = process.memoryUsage().rss;
+        const duration = endTime - startTime;
+        const memory = endMemory - startMemory;
+
+        results.push({ duration, memory });
+    }, count);
+
+    const medianDuration = median(map(prop('duration'), results));
+    const medianMemory = median(map(prop('memory'), results));
+
+    return { medianDuration, medianMemory };
+}
+
 describe('startup / require time', () => {
     it('should not take longer as the defined budget to require the plugin', () => {
         const budget = 85000 / cpuSpeed;
 
-        const startTime = performance.now();
-        require('../index');
-        const endTime = performance.now();
-        const loadTime = endTime - startTime;
+        const { medianDuration } = runBenchmark(() => {
+            clearRequireCache();
+            require('../index');
+        }, 50);
+
+        expect(medianDuration).to.be.below(budget);
+    });
+
+    it('should not consume more memory as the defined budget', () => {
+        const budget = 600000;
+
+        const { medianMemory } = runBenchmark(() => {
+            clearRequireCache();
+            require('../index');
+        }, 50);
 
-        expect(loadTime).to.be.below(budget);
+        expect(medianMemory).to.be.below(budget);
     });
 });
diff --git a/docs/rules/README.md b/docs/rules/README.md
index e13bd4d..f5f9253 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -6,6 +6,7 @@
 |:heavy_check_mark:|| [max-top-level-suites](max-top-level-suites.md) | limit the number of top-level suites in a single file
 |:heavy_check_mark:|:wrench:| [no-async-describe](no-async-describe.md) | disallow async functions passed to describe
 |:heavy_check_mark:|| [no-exclusive-tests](no-exclusive-tests.md) | disallow exclusive mocha tests
+||| [no-exports](no-exports.md) | disallow exports from test files
 |:heavy_check_mark:|| [no-global-tests](no-global-tests.md) | disallow global tests
 ||| [no-hooks](no-hooks.md) | disallow hooks
 |:heavy_check_mark:|| [no-hooks-for-single-case](no-hooks-for-single-case.md) | disallow hooks for a single test or test suite
diff --git a/docs/rules/no-exports.md b/docs/rules/no-exports.md
new file mode 100644
index 0000000..7b17931
--- /dev/null
+++ b/docs/rules/no-exports.md
@@ -0,0 +1,39 @@
+# Disallow exports from test files (no-exports)
+
+Test files should have only one purpose, which is testing a specific unit. Using exports could mean the test file is also used to provide and expose utility or library functionalities, instead those should be moved to separate files.
+
+## Rule Details
+
+This rule looks for CommonJS or ESM export statements and flags them as a problem when the same file also contains a use of a mocha function.
+
+The following patterns are considered warnings:
+
+```js
+describe(function () { /* ... */ });
+module.exports = 'foo';
+
+it('works', function () { /* ... */ });
+exports.foo = 'bar';
+
+beforeEach(function () { /* ... */ });
+export default 'foo';
+
+afterEach(function () { /* ... */ });
+export const foo = 'bar';
+```
+
+These patterns would not be considered warnings:
+
+```js
+describe(function () { /* ... */ });
+
+it('works', function () { /* ... */ });
+
+beforeEach(function () { /* ... */ });
+
+afterEach(function () { /* ... */ });
+```
+
+## When Not To Use It
+
+When you use the [`exports`](https://mochajs.org/#exports) interface it is not recommended to use this rule.
diff --git a/index.js b/index.js
index c965b52..bbf25ad 100644
--- a/index.js
+++ b/index.js
@@ -6,6 +6,7 @@ module.exports = {
         'max-top-level-suites': require('./lib/rules/max-top-level-suites'),
         'no-async-describe': require('./lib/rules/no-async-describe'),
         'no-exclusive-tests': require('./lib/rules/no-exclusive-tests'),
+        'no-exports': require('./lib/rules/no-exports'),
         'no-global-tests': require('./lib/rules/no-global-tests'),
         'no-hooks': require('./lib/rules/no-hooks'),
         'no-hooks-for-single-case': require('./lib/rules/no-hooks-for-single-case'),
@@ -32,6 +33,7 @@ module.exports = {
                 'mocha/max-top-level-suites': [ 'error', { limit: 1 } ],
                 'mocha/no-async-describe': 'error',
                 'mocha/no-exclusive-tests': 'warn',
+                'mocha/no-exports': 'error',
                 'mocha/no-global-tests': 'error',
                 'mocha/no-hooks': 'off',
                 'mocha/no-hooks-for-single-case': 'warn',
diff --git a/lib/rules/no-exports.js b/lib/rules/no-exports.js
new file mode 100644
index 0000000..8308f9f
--- /dev/null
+++ b/lib/rules/no-exports.js
@@ -0,0 +1,56 @@
+'use strict';
+
+const createAstUtils = require('../util/ast');
+
+module.exports = {
+    meta: {
+        docs: {
+            description: 'Disallow exports from test files'
+        },
+        messages: {
+            unexpectedExport: 'Unexpected export from a test file'
+        },
+        type: 'suggestion'
+    },
+    create(context) {
+        const astUtils = createAstUtils(context.settings);
+        const exportNodes = [];
+        let hasTestCase = false;
+
+        function isCommonJsExport(node) {
+            if (node.type === 'MemberExpression') {
+                const name = astUtils.getNodeName(node);
+
+                return name === 'module.exports' || name.startsWith('exports.');
+            }
+
+            return false;
+        }
+
+        return {
+            'Program:exit'() {
+                if (hasTestCase && exportNodes.length > 0) {
+                    for (const node of exportNodes) {
+                        context.report({ node, messageId: 'unexpectedExport' });
+                    }
+                }
+            },
+
+            CallExpression(node) {
+                if (astUtils.isMochaFunctionCall(node, context.getScope())) {
+                    hasTestCase = true;
+                }
+            },
+
+            'ExportNamedDeclaration, ExportDefaultDeclaration, ExportAllDeclaration'(node) {
+                exportNodes.push(node);
+            },
+
+            AssignmentExpression(node) {
+                if (isCommonJsExport(node.left)) {
+                    exportNodes.push(node);
+                }
+            }
+        };
+    }
+};
diff --git a/package.json b/package.json
index b5a215f..ae4ca3f 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
         "test": "npm run test:unit:with-coverage && npm run test:bench",
         "test:unit": "mocha test --recursive --reporter dot",
         "test:unit:with-coverage": "nyc npm run test:unit",
-        "test:bench": "mocha benchmarks",
+        "test:bench": "mocha -t 10000 benchmarks",
         "coveralls": "cat ./build/coverage/lcov.info | coveralls",
         "changelog": "pr-log"
     },


Test patch:
diff --git a/test/rules/no-exports.js b/test/rules/no-exports.js
new file mode 100644
index 0000000..c0816fe
--- /dev/null
+++ b/test/rules/no-exports.js
@@ -0,0 +1,301 @@
+'use strict';
+
+const RuleTester = require('eslint').RuleTester;
+const rules = require('../../').rules;
+const ruleTester = new RuleTester();
+
+ruleTester.run('no-exports', rules['no-exports'], {
+
+    valid: [
+        'describe(function() {});',
+        'context(function() {});',
+        'suite(function() {});',
+        'before(function() {});',
+        'beforeEach(function() {});',
+        'after(function() {});',
+        'afterEach(function() {});',
+        'it("", function() {});',
+        'test("", function() {});',
+        'specify("", function() {});',
+        'it("", function() {}); notModule.exports = "foo"',
+        'notIt("", function() {}); module.exports = "foo"',
+        'it("", function() {}); exports = "foo"',
+        {
+            code: 'describe(function () {})',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' }
+        },
+        {
+            code: 'it("", function () {})',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' }
+        },
+        {
+            code: 'beforeEach(function () {})',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' }
+        }
+    ],
+
+    invalid: [
+        {
+            code: 'describe(function() {}); module.exports = "foo"',
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 26,
+                line: 1
+            } ]
+        },
+        {
+            code: 'describe(function() {}); module["exports"] = "foo"',
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 26,
+                line: 1
+            } ]
+        },
+        {
+            code: 'describe(function() {}); exports.foo = "foo"',
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 26,
+                line: 1
+            } ]
+        },
+        {
+            code: 'describe(function() {}); module.exports = "foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 26,
+                line: 1
+            } ]
+        },
+        {
+            code: 'describe(function() {}); export default "foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 26,
+                line: 1
+            } ]
+        },
+        {
+            code: 'describe(function() {}); export const bar = "foo";',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 26,
+                line: 1
+            } ]
+        },
+        {
+            code: 'describe(function() {}); let foo; export { foo }',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 35,
+                line: 1
+            } ]
+        },
+        {
+            code: 'describe(function() {}); let foo; export { foo as bar }',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 35,
+                line: 1
+            } ]
+        },
+        {
+            code: 'describe(function() {}); export * from "./foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 26,
+                line: 1
+            } ]
+        },
+        {
+            code: 'describe(function() {}); export { bar } from "./foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 26,
+                line: 1
+            } ]
+        },
+        {
+            code: 'it("", function() {}); module.exports = "foo"',
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 24,
+                line: 1
+            } ]
+        },
+        {
+            code: 'it("", function() {}); module["exports"] = "foo"',
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 24,
+                line: 1
+            } ]
+        },
+        {
+            code: 'it("", function() {}); exports.foo = "foo"',
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 24,
+                line: 1
+            } ]
+        },
+        {
+            code: 'it("", function() {}); module.exports = "foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 24,
+                line: 1
+            } ]
+        },
+        {
+            code: 'it("", function() {}); export default "foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 24,
+                line: 1
+            } ]
+        },
+        {
+            code: 'it("", function() {}); export const bar = "foo";',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 24,
+                line: 1
+            } ]
+        },
+        {
+            code: 'it("", function() {}); let foo; export { foo }',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 33,
+                line: 1
+            } ]
+        },
+        {
+            code: 'it("", function() {}); let foo; export { foo as bar }',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 33,
+                line: 1
+            } ]
+        },
+        {
+            code: 'it("", function() {}); export * from "./foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 24,
+                line: 1
+            } ]
+        },
+        {
+            code: 'it("", function() {}); export { bar } from "./foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 24,
+                line: 1
+            } ]
+        },
+        {
+            code: 'beforeEach(function() {}); module.exports = "foo"',
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 28,
+                line: 1
+            } ]
+        },
+        {
+            code: 'beforeEach(function() {}); module["exports"] = "foo"',
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 28,
+                line: 1
+            } ]
+        },
+        {
+            code: 'beforeEach(function() {}); exports.foo = "foo"',
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 28,
+                line: 1
+            } ]
+        },
+        {
+            code: 'beforeEach(function() {}); module.exports = "foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 28,
+                line: 1
+            } ]
+        },
+        {
+            code: 'beforeEach(function() {}); export default "foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 28,
+                line: 1
+            } ]
+        },
+        {
+            code: 'beforeEach(function() {}); export const bar = "foo";',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 28,
+                line: 1
+            } ]
+        },
+        {
+            code: 'beforeEach(function() {}); let foo; export { foo }',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 37,
+                line: 1
+            } ]
+        },
+        {
+            code: 'beforeEach(function() {}); let foo; export { foo as bar }',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 37,
+                line: 1
+            } ]
+        },
+        {
+            code: 'beforeEach(function() {}); export * from "./foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 28,
+                line: 1
+            } ]
+        },
+        {
+            code: 'beforeEach(function() {}); export { bar } from "./foo"',
+            parserOptions: { ecmaVersion: 2019, sourceType: 'module' },
+            errors: [ {
+                message: 'Unexpected export from a test file',
+                column: 28,
+                line: 1
+            } ]
+        }
+    ]
+
+});