Skip to content

Commit

Permalink
feat: add render-result-naming-convention rule (#200)
Browse files Browse the repository at this point in the history
* feat: rule skeleton

* test: first round

* feat: rule implementation round 1

* refactor: move hasTestingLibraryImportModule

* test: fix invalid lines

* feat: check imported module

* feat: check imported render renamed

* feat: check custom render

* test: add more valid tests for custom render functions

* test: update tests for render wrapper functions

* docs: add rule docs

* test: increase coverage up to 100%

* fix: add rule meta description

* docs: update rule details to mention screen object

* refactor: return as soon as conditions are not met

* feat: check wildcard imports

* refactor: rename default import

* docs: include render result link
  • Loading branch information
Belco90 committed Jul 27, 2020
1 parent 17e3cfe commit 3b9d5b5
Show file tree
Hide file tree
Showing 7 changed files with 602 additions and 23 deletions.
41 changes: 21 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,27 +125,28 @@ To enable this configuration use the `extends` property in your

## Supported Rules

| Rule | Description | Configurations | Fixable |
| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------ |
| [await-async-query](docs/rules/await-async-query.md) | Enforce async queries to have proper `await` | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [await-async-utils](docs/rules/await-async-utils.md) | Enforce async utils to be awaited properly | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [await-fire-event](docs/rules/await-fire-event.md) | Enforce async fire event methods to be awaited | ![vue-badge][] | |
| [consistent-data-testid](docs/rules/consistent-data-testid.md) | Ensure `data-testid` values match a provided regex. | | |
| [no-await-sync-query](docs/rules/no-await-sync-query.md) | Disallow unnecessary `await` for sync queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-container](docs/rules/no-container.md) | Disallow the use of `container` methods | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-debug](docs/rules/no-debug.md) | Disallow the use of `debug` | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
| [no-manual-cleanup](docs/rules/no-manual-cleanup.md) | Disallow the use of `cleanup` | | |
| [no-multiple-assertions-wait-for](docs/rules/no-multiple-assertions-wait-for.md) | Disallow the use of multiple expect inside `waitFor` | | |
| [no-node-access](docs/rules/no-node-access.md) | Disallow direct Node access | ![angular-badge][] ![react-badge][] ![vue-badge][] | | |
| [no-promise-in-fire-event](docs/rules/no-promise-in-fire-event.md) | Disallow the use of promises passed to a `fireEvent` method | | |
| [no-wait-for-empty-callback](docs/rules/no-wait-for-empty-callback.md) | Disallow empty callbacks for `waitFor` and `waitForElementToBeRemoved` | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | |
| [prefer-find-by](docs/rules/prefer-find-by.md) | Suggest using `findBy*` methods instead of the `waitFor` + `getBy` queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
| [prefer-presence-queries](docs/rules/prefer-presence-queries.md) | Enforce specific queries when checking element is present or not | | |
| Rule | Description | Configurations | Fixable |
| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------ |
| [await-async-query](docs/rules/await-async-query.md) | Enforce async queries to have proper `await` | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [await-async-utils](docs/rules/await-async-utils.md) | Enforce async utils to be awaited properly | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [await-fire-event](docs/rules/await-fire-event.md) | Enforce async fire event methods to be awaited | ![vue-badge][] | |
| [consistent-data-testid](docs/rules/consistent-data-testid.md) | Ensure `data-testid` values match a provided regex. | | |
| [no-await-sync-query](docs/rules/no-await-sync-query.md) | Disallow unnecessary `await` for sync queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-container](docs/rules/no-container.md) | Disallow the use of `container` methods | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-debug](docs/rules/no-debug.md) | Disallow the use of `debug` | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
| [no-manual-cleanup](docs/rules/no-manual-cleanup.md) | Disallow the use of `cleanup` | | |
| [no-multiple-assertions-wait-for](docs/rules/no-multiple-assertions-wait-for.md) | Disallow the use of multiple expect inside `waitFor` | | |
| [no-node-access](docs/rules/no-node-access.md) | Disallow direct Node access | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-promise-in-fire-event](docs/rules/no-promise-in-fire-event.md) | Disallow the use of promises passed to a `fireEvent` method | | |
| [no-wait-for-empty-callback](docs/rules/no-wait-for-empty-callback.md) | Disallow empty callbacks for `waitFor` and `waitForElementToBeRemoved` | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | |
| [prefer-find-by](docs/rules/prefer-find-by.md) | Suggest using `findBy*` methods instead of the `waitFor` + `getBy` queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
| [prefer-presence-queries](docs/rules/prefer-presence-queries.md) | Enforce specific queries when checking element is present or not | | |
| [prefer-user-event](docs/rules/prefer-user-event.md) | Suggest using `userEvent` library instead of `fireEvent` for simulating user interaction | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [prefer-screen-queries](docs/rules/prefer-screen-queries.md) | Suggest using screen while using queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [prefer-wait-for](docs/rules/prefer-wait-for.md) | Use `waitFor` instead of deprecated wait methods | | ![fixable-badge][] |
| [prefer-screen-queries](docs/rules/prefer-screen-queries.md) | Suggest using screen while using queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [prefer-wait-for](docs/rules/prefer-wait-for.md) | Use `waitFor` instead of deprecated wait methods | | ![fixable-badge][] |
| [render-result-naming-convention](docs/rules/render-result-naming-convention.md) | Enforce a valid naming for return value from `render` | ![angular-badge][] ![react-badge][] ![vue-badge][] | |

[build-badge]: https://img.shields.io/travis/testing-library/eslint-plugin-testing-library?style=flat-square
[build-url]: https://travis-ci.org/testing-library/eslint-plugin-testing-library
Expand Down
78 changes: 78 additions & 0 deletions docs/rules/render-result-naming-convention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Enforce a valid naming for return value from `render` (render-result-naming-convention)

> The name `wrapper` is old cruft from `enzyme` and we don't need that here. The return value from `render` is not "wrapping" anything. It's simply a collection of utilities that you should actually not often need anyway.
## Rule Details

This rule aims to ensure the return value from `render` is named properly.

Ideally, you should destructure the minimum utils that you need from `render`, combined with using queries from [`screen` object](https://github.com/testing-library/eslint-plugin-testing-library/blob/master/docs/rules/prefer-screen-queries.md). In case you need to save the collection of utils returned in a variable, its name should be either `view` or `utils`, as `render` is not wrapping anything: it's just returning a collection of utilities. Every other name for that variable will be considered invalid.

To sum up these rules, the allowed naming convention for return value from `render` is:

- destructuring
- `view`
- `utils`

Examples of **incorrect** code for this rule:

```javascript
import { render } from '@testing-library/framework';

// ...

// return value from `render` shouldn't be kept in a var called "wrapper"
const wrapper = render(<SomeComponent />);
```

```javascript
import { render } from '@testing-library/framework';

// ...

// return value from `render` shouldn't be kept in a var called "component"
const component = render(<SomeComponent />);
```

```javascript
import { render } from '@testing-library/framework';

// ...

// to sum up: return value from `render` shouldn't be kept in a var called other than "view" or "utils"
const somethingElse = render(<SomeComponent />);
```

Examples of **correct** code for this rule:

```javascript
import { render } from '@testing-library/framework';

// ...

// destructuring return value from `render` is correct
const { unmount, rerender } = render(<SomeComponent />);
```

```javascript
import { render } from '@testing-library/framework';

// ...

// keeping return value from `render` in a var called "view" is correct
const view = render(<SomeComponent />);
```

```javascript
import { render } from '@testing-library/framework';

// ...

// keeping return value from `render` in a var called "utils" is correct
const utils = render(<SomeComponent />);
```

## Further Reading

- [Common Mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#using-wrapper-as-the-variable-name-for-the-return-value-from-render)
- [`render` Result](https://testing-library.com/docs/react-testing-library/api#render-result)
7 changes: 6 additions & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import preferPresenceQueries from './rules/prefer-presence-queries';
import preferScreenQueries from './rules/prefer-screen-queries';
import preferUserEvent from './rules/prefer-user-event';
import preferWaitFor from './rules/prefer-wait-for';
import noMultipleAssertionsWaitFor from './rules/no-multiple-assertions-wait-for'
import noMultipleAssertionsWaitFor from './rules/no-multiple-assertions-wait-for';
import preferFindBy from './rules/prefer-find-by';
import renderResultNamingConvention from './rules/render-result-naming-convention';

const rules = {
'await-async-query': awaitAsyncQuery,
Expand All @@ -38,6 +39,7 @@ const rules = {
'prefer-screen-queries': preferScreenQueries,
'prefer-user-event': preferUserEvent,
'prefer-wait-for': preferWaitFor,
'render-result-naming-convention': renderResultNamingConvention,
};

const domRules = {
Expand All @@ -57,6 +59,7 @@ const angularRules = {
'testing-library/no-debug': 'warn',
'testing-library/no-dom-import': ['error', 'angular'],
'testing-library/no-node-access': 'error',
'testing-library/render-result-naming-convention': 'error',
};

const reactRules = {
Expand All @@ -65,6 +68,7 @@ const reactRules = {
'testing-library/no-debug': 'warn',
'testing-library/no-dom-import': ['error', 'react'],
'testing-library/no-node-access': 'error',
'testing-library/render-result-naming-convention': 'error',
};

const vueRules = {
Expand All @@ -74,6 +78,7 @@ const vueRules = {
'testing-library/no-debug': 'warn',
'testing-library/no-dom-import': ['error', 'vue'],
'testing-library/no-node-access': 'error',
'testing-library/render-result-naming-convention': 'error',
};

export = {
Expand Down
145 changes: 145 additions & 0 deletions lib/rules/render-result-naming-convention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
import { getDocsUrl, hasTestingLibraryImportModule } from '../utils';
import {
isCallExpression,
isIdentifier,
isImportSpecifier,
isMemberExpression,
isObjectPattern,
isRenderVariableDeclarator,
} from '../node-utils';

export const RULE_NAME = 'render-result-naming-convention';

const ALLOWED_VAR_NAMES = ['view', 'utils'];
const ALLOWED_VAR_NAMES_TEXT = ALLOWED_VAR_NAMES.map(
name => '`' + name + '`'
).join(', ');

export default ESLintUtils.RuleCreator(getDocsUrl)({
name: RULE_NAME,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce a valid naming for return value from `render`',
category: 'Best Practices',
recommended: false,
},
messages: {
invalidRenderResultName: `\`{{ varName }}\` is not a recommended name for \`render\` returned value. Instead, you should destructure it, or call it using one of the valid choices: ${ALLOWED_VAR_NAMES_TEXT}`,
},
fixable: null,
schema: [
{
type: 'object',
properties: {
renderFunctions: {
type: 'array',
},
},
},
],
},
defaultOptions: [
{
renderFunctions: [],
},
],

create(context, [options]) {
const { renderFunctions } = options;
let renderAlias: string | undefined;
let wildcardImportName: string | undefined;

return {
// check named imports
ImportDeclaration(node: TSESTree.ImportDeclaration) {
if (!hasTestingLibraryImportModule(node)) {
return;
}
const renderImport = node.specifiers.find(
node => isImportSpecifier(node) && node.imported.name === 'render'
);

if (!renderImport) {
return;
}

renderAlias = renderImport.local.name;
},
// check wildcard imports
'ImportDeclaration ImportNamespaceSpecifier'(
node: TSESTree.ImportNamespaceSpecifier
) {
if (
!hasTestingLibraryImportModule(
node.parent as TSESTree.ImportDeclaration
)
) {
return;
}

wildcardImportName = node.local.name;
},
VariableDeclarator(node: TSESTree.VariableDeclarator) {
// check if destructuring return value from render
if (isObjectPattern(node.id)) {
return;
}

const isValidRenderDeclarator = isRenderVariableDeclarator(node, [
...renderFunctions,
renderAlias,
]);
const isValidWildcardImport = !!wildcardImportName;

// check if is a Testing Library related import
if (!isValidRenderDeclarator && !isValidWildcardImport) {
return;
}

const renderFunctionName =
isCallExpression(node.init) &&
isIdentifier(node.init.callee) &&
node.init.callee.name;

const renderFunctionObjectName =
isCallExpression(node.init) &&
isMemberExpression(node.init.callee) &&
isIdentifier(node.init.callee.property) &&
isIdentifier(node.init.callee.object) &&
node.init.callee.property.name === 'render' &&
node.init.callee.object.name;

const isRenderAlias = !!renderAlias;
const isCustomRender = renderFunctions.includes(renderFunctionName);
const isWildCardRender =
renderFunctionObjectName &&
renderFunctionObjectName === wildcardImportName;

// check if is a qualified render function
if (!isRenderAlias && !isCustomRender && !isWildCardRender) {
return;
}

const renderResultName = isIdentifier(node.id) && node.id.name;
const isAllowedRenderResultName = ALLOWED_VAR_NAMES.includes(
renderResultName
);

// check if return value var name is allowed
if (isAllowedRenderResultName) {
return;
}

context.report({
node,
messageId: 'invalidRenderResultName',
data: {
varName: renderResultName,
},
});
},
};
},
});
4 changes: 2 additions & 2 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ const LIBRARY_MODULES = [
];

const hasTestingLibraryImportModule = (node: TSESTree.ImportDeclaration) => {
return LIBRARY_MODULES.includes(node.source.value.toString())
}
return LIBRARY_MODULES.includes(node.source.value.toString());
};

const SYNC_QUERIES_VARIANTS = ['getBy', 'getAllBy', 'queryBy', 'queryAllBy'];
const ASYNC_QUERIES_VARIANTS = ['findBy', 'findAllBy'];
Expand Down

0 comments on commit 3b9d5b5

Please sign in to comment.