diff --git a/.vscode/eslint-plugin.code-workspace b/.vscode/eslint-plugin.code-workspace index cc79f66..46e106e 100644 --- a/.vscode/eslint-plugin.code-workspace +++ b/.vscode/eslint-plugin.code-workspace @@ -4,7 +4,4 @@ "path": ".." } ], - "settings": { - "prettier.prettierPath": ".yarn/unplugged/prettier-npm-3.1.0-708d6027b1/node_modules/prettier" - } } diff --git a/docs/rules/enforce-close-testing-module.md b/docs/rules/enforce-close-testing-module.md index e551edc..7131466 100644 --- a/docs/rules/enforce-close-testing-module.md +++ b/docs/rules/enforce-close-testing-module.md @@ -4,6 +4,29 @@ description: 'Ensure NestJS testing modules are closed properly' [Testing modules](https://docs.nestjs.com/fundamentals/testing#testing-utilities) are generally used to mimic the behavior of underlying services and modules, allowing the developer to override and configure them for testing purposes. However, if the testing module is not closed properly, it can cause many issues, such as memory leaks, hanging processes and open database connections. This rule ensures that all testing modules are closed properly - and also closed in the correct hook. +## Options + +This rule accepts an object with two properties: `createAliases` and `closeAliases`. Each property is an array of objects, where each object specifies a `kind` (either 'function' or 'method') and a `name` (the name of the function or method). + +- `createAliases`: Defines functions or methods that behave similarly to `Test.createTestingModule()`. +- `closeAliases`: Defines functions or methods that are equivalent to `TestingModule.close()`. + +### Example of Options + +```json +{ + "nestjs/close-testing-modules": ["error", { + "createAliases": [ + { "kind": "function", "name": "customCreateTestingModule" }, + { "kind": "method", "name": "alternativeCreateMethod" } + ], + "closeAliases": [ + { "kind": "method", "name": "customCloseMethod" } + ] + }] +} +``` + ## Examples ### ❌ Incorrect @@ -78,6 +101,42 @@ describe('Closes the appModule created from the testingModule', () => { await app.close(); }); }); + +describe('Creates and closes the appModule using custom functions', () => { + let app: INestApplication; + beforeEach(async () => { + // defined via the "createAliases" option as { kind: 'function', name: 'createTestingModule' } + const testingModule = await createTestingModule(); + app = testingModule.createNestApplication(); + }); + + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + + afterEach(async () => { + // defined via the "closeAliases" option as { kind: 'function', name: 'closeTestingModule' } + await closeTestingModule(testingModule); + }); +}); + +describe('Creates and closes the appModule using custom methods', () => { + let app: INestApplication; + beforeEach(async () => { + // defined via the "createAliases" option as { kind: 'method', name: 'createTestingModule' } + const testingModule = await testUtils.createTestingModule(); + app = testingModule.createNestApplication(); + }); + + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + + afterEach(async () => { + // defined via the "closeAliases" option as { kind: 'method', name: 'close' } + await testUtils.close(testingModule); + }); +}); ``` ## When Not To Use It diff --git a/src/rules/enforce-close-testing-module.rule.ts b/src/rules/enforce-close-testing-module.rule.ts index 7127ce9..9b8cd90 100644 --- a/src/rules/enforce-close-testing-module.rule.ts +++ b/src/rules/enforce-close-testing-module.rule.ts @@ -18,7 +18,28 @@ function typeOfHook(hookName: TestBeforeHooks | TestAfterHooks): HookType { return hookName.includes('All') ? 'all' : 'each'; } -export default createRule({ +type Alias = { + kind: 'function' | 'method'; + name: string; +}; + +export type Options = [ + { + closeAliases?: Alias[]; + createAliases?: Alias[]; + }, +]; + +const defaultOptions: Options = [ + { + closeAliases: [], + createAliases: [], + }, +]; + +export type MessageIds = 'testModuleNotClosed' | 'testModuleClosedInWrongHook'; + +export default createRule({ name: 'enforce-close-testing-module', meta: { type: 'problem', @@ -27,7 +48,27 @@ export default createRule({ recommended: 'recommended', }, fixable: undefined, - schema: [], // no options + schema: [ + { + type: 'object', + properties: { + closeAliases: { + type: 'array', + items: { + type: 'object', + properties: { + kind: { + type: 'string', + }, + name: { + type: 'string', + }, + }, + }, + }, + }, + }, + ], messages: { testModuleNotClosed: 'A Testing Module was created but not closed, which can cause memory leaks', @@ -35,7 +76,7 @@ export default createRule({ 'A Testing Module was created in {{ created }} but was closed in the wrong hook {{ closed }}', }, }, - defaultOptions: [], + defaultOptions, create(context) { let testModuleCreated = false; let testModuleClosed = false; @@ -75,18 +116,10 @@ export default createRule({ 'MemberExpression[object.name="Test"][property.name="createTestingModule"]': (node: TSESTree.MemberExpression) => { // Check under which hook the module was created - const callExpressions = traverser.getAllParentCallExpressions(node); - const callExpressionWithHook = callExpressions.find( - (expression) => - ASTUtils.isIdentifier(expression.callee) && - ['beforeAll', 'beforeEach'].includes(expression.callee.name) - ); - if ( - callExpressionWithHook && - ASTUtils.isIdentifier(callExpressionWithHook.callee) - ) { - createdInHook = callExpressionWithHook.callee - .name as TestBeforeHooks; + const beforeHook = beforeHookContainingNode(node); + + if (beforeHook) { + createdInHook = beforeHook; } }, 'MemberExpression[property.name="createNestApplication"]': (node) => { @@ -119,25 +152,62 @@ export default createRule({ appModuleClosed = true; } - // Logic to check if module.close() is called in the wrong hook - const callExpressions = traverser.getAllParentCallExpressions(node); - const callExpressionWithHook = callExpressions.find( - (expression) => - ASTUtils.isIdentifier(expression.callee) && - ['afterAll', 'afterEach'].includes(expression.callee.name) - ); + closedInHook = afterHookContainingNode(node); + if ( - callExpressionWithHook && - ASTUtils.isIdentifier(callExpressionWithHook.callee) + moduleClosedInWrongHook( + closedInHook, + createdInHook, + testModuleCreated + ) ) { - closedInHook = callExpressionWithHook.callee.name as TestAfterHooks; + context.report({ + node, + messageId: 'testModuleClosedInWrongHook', + data: { + created: createdInHook, + closed: closedInHook, + }, + }); + } + }, + // Matches any function call to verify if one of the aliases functions was called + // e.g. `customClose();` or `customCreateModule()` + 'CallExpression[callee.type="Identifier"]': ( + node: TSESTree.CallExpression + ) => { + const calleeName = (node.callee as TSESTree.Identifier).name; + const closeFunctionAliases = context.options[0]?.closeAliases?.filter( + (alias) => alias.kind === 'function' + ); + + if (closeFunctionAliases?.some((alias) => alias.name === calleeName)) { + testModuleClosed = true; + closedInHook = afterHookContainingNode(node); + } + + const createFunctionAliases = context.options[0]?.createAliases?.filter( + (alias) => alias.kind === 'function' + ); + + if (createFunctionAliases?.some((alias) => alias.name === calleeName)) { + testModuleCreated = true; + testingModuleCreatedPosition.start = node.loc.start; + testingModuleCreatedPosition.end = node.loc.end; + } + + const beforeHook = beforeHookContainingNode(node); + + if (beforeHook) { + createdInHook = beforeHook; } if ( - closedInHook && - createdInHook && - typeOfHook(closedInHook) !== typeOfHook(createdInHook) && - testModuleCreated + moduleClosedInWrongHook( + closedInHook, + createdInHook, + testModuleCreated + ) ) { context.report({ node, @@ -149,6 +219,54 @@ export default createRule({ }); } }, + // Matches any method call to verify if one of the aliases methods was called + // e.g. `object.customClose();` or `object.customCreateModule()` + 'MemberExpression[property.type="Identifier"]': ( + node: TSESTree.MemberExpression + ) => { + const methodName = (node.property as TSESTree.Identifier).name; + const methodAliases = context.options[0]?.closeAliases?.filter( + (alias) => alias.kind === 'method' + ); + + if (methodAliases?.some((alias) => alias.name === methodName)) { + testModuleClosed = true; + closedInHook = afterHookContainingNode(node); + } + + const createMethodAliases = context.options[0]?.createAliases?.filter( + (alias) => alias.kind === 'method' + ); + + if (createMethodAliases?.some((alias) => alias.name === methodName)) { + testModuleCreated = true; + testingModuleCreatedPosition.start = node.loc.start; + testingModuleCreatedPosition.end = node.loc.end; + + const beforeHook = beforeHookContainingNode(node); + + if (beforeHook) { + createdInHook = beforeHook; + } + + if ( + moduleClosedInWrongHook( + closedInHook, + createdInHook, + testModuleCreated + ) + ) { + context.report({ + node, + messageId: 'testModuleClosedInWrongHook', + data: { + created: createdInHook, + closed: closedInHook, + }, + }); + } + } + }, 'Program:exit': (node) => { if (testModuleCreated && !testModuleClosed && !appModuleClosed) { context.report({ @@ -161,3 +279,54 @@ export default createRule({ }; }, }); + +function moduleClosedInWrongHook( + closedInHook: TestAfterHooks | undefined, + createdInHook: TestBeforeHooks | undefined, + testModuleCreated: boolean +) { + return ( + closedInHook && + createdInHook && + typeOfHook(closedInHook) !== typeOfHook(createdInHook) && + testModuleCreated + ); +} + +function afterHookContainingNode( + node: TSESTree.CallExpression | TSESTree.MemberExpression +): TestAfterHooks | undefined { + let result: TestAfterHooks | undefined; + const callExpressions = traverser.getAllParentCallExpressions(node); + const callExpressionWithHook = callExpressions.find( + (expression) => + ASTUtils.isIdentifier(expression.callee) && + ['afterAll', 'afterEach'].includes(expression.callee.name) + ); + if ( + callExpressionWithHook && + ASTUtils.isIdentifier(callExpressionWithHook.callee) + ) { + result = callExpressionWithHook.callee.name as TestAfterHooks; + } + return result; +} + +function beforeHookContainingNode( + node: TSESTree.CallExpression | TSESTree.MemberExpression +): TestBeforeHooks | undefined { + let result: TestBeforeHooks | undefined; + const callExpressions = traverser.getAllParentCallExpressions(node); + const callExpressionWithHook = callExpressions.find( + (expression) => + ASTUtils.isIdentifier(expression.callee) && + ['beforeAll', 'beforeEach'].includes(expression.callee.name) + ); + if ( + callExpressionWithHook && + ASTUtils.isIdentifier(callExpressionWithHook.callee) + ) { + result = callExpressionWithHook.callee.name as TestBeforeHooks; + } + return result; +} \ No newline at end of file diff --git a/tests/rules/enforce-close-testing-module.spec.ts b/tests/rules/enforce-close-testing-module.spec.ts index c15029c..a08bc81 100644 --- a/tests/rules/enforce-close-testing-module.spec.ts +++ b/tests/rules/enforce-close-testing-module.spec.ts @@ -87,6 +87,247 @@ ruleTester.run('enforce-close-testing-module', enforceCloseTestingModuleRule, { }); `, }, + { + code: ` + describe('Closes the testingModule using a custom function', () => { + let testingModule: TestingModule; + beforeEach(async () => { + testingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + }); + afterEach(async () => { + await customClose(); + }); + `, + options: [ + { + closeAliases: [ + { + kind: 'function', + name: 'customClose', + }, + ], + }, + ], + }, + { + code: ` + import { customCreateTestingModule } from './test-utils'; + describe('Creates the testingModule using the function alias', () => { + let testingModule: TestingModule; + beforeEach(async () => { + testingModule = await customCreateTestingModule(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + }); + afterEach(async () => { + await testingModule.close(); + }); + `, + options: [ + { + createAliases: [ + { + kind: 'function', + name: 'customCreateTestingModule', + }, + ], + }, + ], + }, + { + code: ` + import { customCreateTestingModule } from './test-utils'; + describe('Creates and closes the testingModule using the function alias (beforeEach)', () => { + let testingModule: TestingModule; + beforeEach(async () => { + testingModule = await customCreateTestingModule(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + }); + afterEach(async () => { + await closeTestingModule(testingModule); + }); + `, + options: [ + { + createAliases: [ + { + kind: 'function', + name: 'customCreateTestingModule', + }, + ], + closeAliases: [ + { + kind: 'function', + name: 'closeTestingModule', + }, + ], + }, + ], + }, + { + code: ` + import { customCreateTestingModule } from './test-utils'; + describe('Creates and closes the testingModule using the function alias (beforeAll)', () => { + let testingModule: TestingModule; + beforeAll(async () => { + testingModule = await customCreateTestingModule(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + }); + afterAll(async () => { + await closeTestingModule(testingModule); + }); + `, + options: [ + { + createAliases: [ + { + kind: 'function', + name: 'customCreateTestingModule', + }, + ], + closeAliases: [ + { + kind: 'function', + name: 'closeTestingModule', + }, + ], + }, + ], + }, + { + code: ` + import { testUtils } from './test-utils'; + describe('Creates the testingModule using the method alias (beforeEach)', () => { + let testingModule: TestingModule; + beforeEach(async () => { + testingModule = await testUtils.create(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + }); + afterEach(async () => { + await testingModule.close(); + }); + `, + options: [ + { + createAliases: [ + { + kind: 'method', + name: 'create', + }, + ], + }, + ], + }, + { + code: ` + import { testUtils } from './test-utils'; + describe('Creates the testingModule using the method alias (beforeAll)', () => { + let testingModule: TestingModule; + beforeAll(async () => { + testingModule = await testUtils.create(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + }); + afterAll(async () => { + await testingModule.close(); + }); + `, + options: [ + { + createAliases: [ + { + kind: 'method', + name: 'create', + }, + ], + }, + ], + }, + { + code: ` + import { testUtils } from './test-utils'; + describe('Creates and closes the testingModule using the method alias (beforeEach)', () => { + let testingModule: TestingModule; + beforeEach(async () => { + testingModule = await testUtils.create(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + }); + afterEach(async () => { + await testUtils.close(testingModule); + }); + `, + options: [ + { + createAliases: [ + { + kind: 'method', + name: 'create', + }, + ], + closeAliases: [ + { + kind: 'method', + name: 'close', + }, + ], + }, + ], + }, + { + code: ` + import { testUtils } from './test-utils'; + describe('Creates and closes the testingModule using the method alias (beforeAll)', () => { + let testingModule: TestingModule; + beforeAll(async () => { + testingModule = await testUtils.create(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + }); + afterAll(async () => { + await testUtils.close(testingModule); + }); + `, + options: [ + { + createAliases: [ + { + kind: 'method', + name: 'create', + }, + ], + closeAliases: [ + { + kind: 'method', + name: 'close', + }, + ], + }, + ], + }, ], invalid: [ { @@ -165,5 +406,190 @@ ruleTester.run('enforce-close-testing-module', enforceCloseTestingModuleRule, { }, ], }, + { + code: ` + import { customCreateTestingModule } from './test-utils'; + describe('Creates the testingModule using the function alias but does not close it', () => { + let testingModule: TestingModule; + beforeEach(async () => { + testingModule = await customCreateTestingModule(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + }); + `, + options: [ + { + createAliases: [ + { + kind: 'function', + name: 'createTestingModule', + }, + ], + }, + ], + errors: [ + { + messageId: 'testModuleNotClosed', + }, + ], + }, + { + code: ` + import { customCreateTestingModule } from './test-utils'; + describe('Creates the testingModule using the function alias in the beforeEach scope', () => { + beforeEach(async () => { + const testingModule = await customCreateTestingModule(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + }); + `, + options: [ + { + createAliases: [ + { + kind: 'function', + name: 'customCreateTestingModule', + }, + ], + }, + ], + errors: [ + { + messageId: 'testModuleNotClosed', + }, + ], + }, + { + code: ` + import { customCreateTestingModule } from './test-utils'; + describe('Creates the testingModule using the function alias in the beforeEach scope', () => { + let testingModule: TestingModule; + beforeAll(async () => { + testingModule = await customCreateTestingModule(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + afterEach(async () => { + await testingModule.close(); + }) + }); + `, + options: [ + { + createAliases: [ + { + kind: 'function', + name: 'customCreateTestingModule', + }, + ], + }, + ], + errors: [ + { + messageId: 'testModuleClosedInWrongHook', + }, + ], + }, + { + code: ` + import { customCreateTestingModule } from './test-utils'; + describe('Creates the testingModule using the function alias in the beforeEach scope', () => { + let testingModule: TestingModule; + beforeEach(async () => { + testingModule = await customCreateTestingModule(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + afterAll(async () => { + await testingModule.close(); + }) + }); + `, + options: [ + { + createAliases: [ + { + kind: 'function', + name: 'customCreateTestingModule', + }, + ], + }, + ], + errors: [ + { + messageId: 'testModuleClosedInWrongHook', + }, + ], + }, + { + code: ` + import { testUtils } from './test-utils'; + describe('Creates the testingModule using the method alias in the beforeAll scope', () => { + let testingModule: TestingModule; + beforeAll(async () => { + testingModule = await testUtils.create(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + afterEach(async () => { + await testingModule.close(); + }) + }); + `, + options: [ + { + createAliases: [ + { + kind: 'method', + name: 'create', + }, + ], + }, + ], + errors: [ + { + messageId: 'testModuleClosedInWrongHook', + }, + ], + }, + { + code: ` + import { testUtils } from './test-utils'; + describe('Creates the testingModule using the method alias in the beforeEach scope', () => { + let testingModule: TestingModule; + beforeEach(async () => { + testingModule = await testUtils.create(); + }); + it('should be defined', () => { + expect(testingModule).toBeDefined(); + }); + afterAll(async () => { + await testingModule.close(); + }) + }); + `, + options: [ + { + createAliases: [ + { + kind: 'method', + name: 'create', + }, + ], + }, + ], + errors: [ + { + messageId: 'testModuleClosedInWrongHook', + }, + ], + }, ], });