Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .vscode/eslint-plugin.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,4 @@
"path": ".."
}
],
"settings": {
"prettier.prettierPath": ".yarn/unplugged/prettier-npm-3.1.0-708d6027b1/node_modules/prettier"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was removed because we've reverted yarn to not use pnp (plug and play), so we leverage node_modules.

}
}
59 changes: 59 additions & 0 deletions docs/rules/enforce-close-testing-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
227 changes: 198 additions & 29 deletions src/rules/enforce-close-testing-module.rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Options, MessageIds>({
name: 'enforce-close-testing-module',
meta: {
type: 'problem',
Expand All @@ -27,15 +48,35 @@ 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',
testModuleClosedInWrongHook:
'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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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;
}
Loading