diff --git a/README.md b/README.md
index 1438ce6f..2bbe9062 100644
--- a/README.md
+++ b/README.md
@@ -137,6 +137,7 @@ To enable this configuration use the `extends` property in your
| [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 | | |
diff --git a/docs/rules/no-multiple-assertions-wait-for.md b/docs/rules/no-multiple-assertions-wait-for.md
index 4259187f..b1f946bf 100644
--- a/docs/rules/no-multiple-assertions-wait-for.md
+++ b/docs/rules/no-multiple-assertions-wait-for.md
@@ -18,9 +18,9 @@ const foo = async () => {
// or
await waitFor(function() {
- expect(a).toEqual('a')
+ expect(a).toEqual('a');
expect(b).toEqual('b');
- })
+ });
};
```
@@ -30,11 +30,11 @@ Examples of **correct** code for this rule:
const foo = async () => {
await waitFor(() => expect(a).toEqual('a'));
expect(b).toEqual('b');
-
+
// or
await waitFor(function() {
- expect(a).toEqual('a')
- })
+ expect(a).toEqual('a');
+ });
expect(b).toEqual('b');
// it only detects expect
diff --git a/docs/rules/no-node-access.md b/docs/rules/no-node-access.md
new file mode 100644
index 00000000..78d775dd
--- /dev/null
+++ b/docs/rules/no-node-access.md
@@ -0,0 +1,60 @@
+# Disallow direct Node access (no-node-access)
+
+The Testing Library already provides methods for querying DOM elements.
+
+## Rule Details
+
+This rule aims to disallow DOM traversal using native HTML methods and properties, such as `closest`, `lastChild` and all that returns another Node element from an HTML tree.
+
+Examples of **incorrect** code for this rule:
+
+```js
+import { screen } from '@testing-library/react';
+
+screen.getByText('Submit').closest('button'); // chaining with Testing Library methods
+```
+
+```js
+import { screen } from '@testing-library/react';
+
+const buttons = screen.getAllByRole('button');
+expect(buttons[1].lastChild).toBeInTheDocument();
+```
+
+```js
+import { screen } from '@testing-library/react';
+
+const buttonText = screen.getByText('Submit');
+const button = buttonText.closest('button');
+```
+
+Examples of **correct** code for this rule:
+
+```js
+import { screen } from '@testing-library/react';
+
+const button = screen.getByRole('button');
+expect(button).toHaveTextContent('submit');
+```
+
+```js
+import { render, within } from '@testing-library/react';
+
+const { getByLabelText } = render();
+const signinModal = getByLabelText('Sign In');
+within(signinModal).getByPlaceholderText('Username');
+```
+
+```js
+// If is not importing a testing-library package
+
+document.getElementById('submit-btn').closest('button');
+```
+
+## Further Reading
+
+### Properties / methods that return another Node
+
+- [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document)
+- [`Element`](https://developer.mozilla.org/en-US/docs/Web/API/Element)
+- [`Node`](https://developer.mozilla.org/en-US/docs/Web/API/Node)
diff --git a/lib/index.ts b/lib/index.ts
index 4b5149f2..12301b65 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -7,6 +7,7 @@ import noContainer from './rules/no-container';
import noDebug from './rules/no-debug';
import noDomImport from './rules/no-dom-import';
import noManualCleanup from './rules/no-manual-cleanup';
+import noNodeAccess from './rules/no-node-access';
import noWaitForEmptyCallback from './rules/no-wait-for-empty-callback';
import noPromiseInFireEvent from './rules/no-promise-in-fire-event';
import preferExplicitAssert from './rules/prefer-explicit-assert';
@@ -27,6 +28,7 @@ const rules = {
'no-dom-import': noDomImport,
'no-manual-cleanup': noManualCleanup,
'no-multiple-assertions-wait-for': noMultipleAssertionsWaitFor,
+ 'no-node-access': noNodeAccess,
'no-promise-in-fire-event': noPromiseInFireEvent,
'no-wait-for-empty-callback': noWaitForEmptyCallback,
'prefer-explicit-assert': preferExplicitAssert,
@@ -51,6 +53,7 @@ const angularRules = {
'testing-library/no-container': 'error',
'testing-library/no-debug': 'warn',
'testing-library/no-dom-import': ['error', 'angular'],
+ 'testing-library/no-node-access': 'error',
};
const reactRules = {
@@ -58,6 +61,7 @@ const reactRules = {
'testing-library/no-container': 'error',
'testing-library/no-debug': 'warn',
'testing-library/no-dom-import': ['error', 'react'],
+ 'testing-library/no-node-access': 'error',
};
const vueRules = {
@@ -66,6 +70,7 @@ const vueRules = {
'testing-library/no-container': 'error',
'testing-library/no-debug': 'warn',
'testing-library/no-dom-import': ['error', 'vue'],
+ 'testing-library/no-node-access': 'error',
};
export = {
diff --git a/lib/rules/no-node-access.ts b/lib/rules/no-node-access.ts
new file mode 100644
index 00000000..e0a12933
--- /dev/null
+++ b/lib/rules/no-node-access.ts
@@ -0,0 +1,49 @@
+import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
+import { getDocsUrl, ALL_RETURNING_NODES } from '../utils';
+import { isIdentifier } from '../node-utils';
+
+export const RULE_NAME = 'no-node-access';
+
+export default ESLintUtils.RuleCreator(getDocsUrl)({
+ name: RULE_NAME,
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'Disallow direct Node access',
+ category: 'Best Practices',
+ recommended: 'error',
+ },
+ messages: {
+ noNodeAccess:
+ 'Avoid direct Node access. Prefer using the methods from Testing Library.',
+ },
+ fixable: null,
+ schema: [],
+ },
+ defaultOptions: [],
+
+ create(context) {
+ let isImportingTestingLibrary = false;
+
+ function checkTestingEnvironment(node: TSESTree.ImportDeclaration) {
+ isImportingTestingLibrary = /testing-library/g.test(node.source.value as string);
+ }
+
+ function showErrorForNodeAccess(node: TSESTree.MemberExpression) {
+ isIdentifier(node.property) &&
+ ALL_RETURNING_NODES.includes(node.property.name) &&
+ isImportingTestingLibrary &&
+ context.report({
+ node: node,
+ loc: node.property.loc.start,
+ messageId: 'noNodeAccess',
+ });
+ }
+
+ return {
+ ['ImportDeclaration']: checkTestingEnvironment,
+ ['ExpressionStatement MemberExpression']: showErrorForNodeAccess,
+ ['VariableDeclarator MemberExpression']: showErrorForNodeAccess,
+ };
+ },
+});
diff --git a/lib/utils.ts b/lib/utils.ts
index 2652aa08..7af670c8 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -63,6 +63,41 @@ const ASYNC_UTILS = [
'waitForDomChange',
];
+const PROPERTIES_RETURNING_NODES = [
+ 'activeElement',
+ 'children',
+ 'firstChild',
+ 'firstElementChild',
+ 'fullscreenElement',
+ 'lastChild',
+ 'lastElementChild',
+ 'nextElementSibling',
+ 'nextSibling',
+ 'parentElement',
+ 'parentNode',
+ 'pointerLockElement',
+ 'previousElementSibling',
+ 'previousSibling',
+ 'rootNode',
+ 'scripts',
+];
+
+const METHODS_RETURNING_NODES = [
+ 'closest',
+ 'getElementById',
+ 'getElementsByClassName',
+ 'getElementsByName',
+ 'getElementsByTagName',
+ 'getElementsByTagNameNS',
+ 'querySelector',
+ 'querySelectorAll',
+];
+
+const ALL_RETURNING_NODES = [
+ ...PROPERTIES_RETURNING_NODES,
+ ...METHODS_RETURNING_NODES,
+];
+
export {
getDocsUrl,
SYNC_QUERIES_VARIANTS,
@@ -74,4 +109,7 @@ export {
ALL_QUERIES_COMBINATIONS,
ASYNC_UTILS,
LIBRARY_MODULES,
+ PROPERTIES_RETURNING_NODES,
+ METHODS_RETURNING_NODES,
+ ALL_RETURNING_NODES,
};
diff --git a/tests/__snapshots__/index.test.ts.snap b/tests/__snapshots__/index.test.ts.snap
index b86f6a5c..250540c3 100644
--- a/tests/__snapshots__/index.test.ts.snap
+++ b/tests/__snapshots__/index.test.ts.snap
@@ -15,6 +15,7 @@ Object {
"error",
"angular",
],
+ "testing-library/no-node-access": "error",
"testing-library/no-promise-in-fire-event": "error",
"testing-library/no-wait-for-empty-callback": "error",
"testing-library/prefer-find-by": "error",
@@ -55,6 +56,7 @@ Object {
"error",
"react",
],
+ "testing-library/no-node-access": "error",
"testing-library/no-promise-in-fire-event": "error",
"testing-library/no-wait-for-empty-callback": "error",
"testing-library/prefer-find-by": "error",
@@ -79,6 +81,7 @@ Object {
"error",
"vue",
],
+ "testing-library/no-node-access": "error",
"testing-library/no-promise-in-fire-event": "error",
"testing-library/no-wait-for-empty-callback": "error",
"testing-library/prefer-find-by": "error",
diff --git a/tests/lib/rules/no-node-access.test.ts b/tests/lib/rules/no-node-access.test.ts
new file mode 100644
index 00000000..ee432767
--- /dev/null
+++ b/tests/lib/rules/no-node-access.test.ts
@@ -0,0 +1,262 @@
+import { createRuleTester } from '../test-utils';
+import rule, { RULE_NAME } from '../../../lib/rules/no-node-access';
+
+const ruleTester = createRuleTester({
+ ecmaFeatures: {
+ jsx: true,
+ },
+});
+
+ruleTester.run(RULE_NAME, rule, {
+ valid: [
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ const buttonText = screen.getByText('submit');
+ `,
+ },
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ const { getByText } = screen
+ const firstChild = getByText('submit');
+ expect(firstChild).toBeInTheDocument()
+ `,
+ },
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ const firstChild = screen.getByText('submit');
+ expect(firstChild).toBeInTheDocument()
+ `,
+ },
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ const { getByText } = screen;
+ const button = getByRole('button');
+ expect(button).toHaveTextContent('submit');
+ `,
+ },
+ {
+ code: `
+ import { render, within } from '@testing-library/react';
+
+ const { getByLabelText } = render();
+ const signinModal = getByLabelText('Sign In');
+ within(signinModal).getByPlaceholderText('Username');
+ `,
+ },
+ {
+ code: `
+ const Component = props => {
+ return
{props.children}
+ }
+ `,
+ },
+ {
+ // Not importing a testing-library package
+ code: `
+ const closestButton = document.getElementById('submit-btn').closest('button');
+ expect(closestButton).toBeInTheDocument();
+ `,
+ },
+ ],
+ invalid: [
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ const button = document.getElementById('submit-btn').closest('button');
+ `,
+ errors: [
+ {
+ messageId: 'noNodeAccess',
+ },
+ {
+ messageId: 'noNodeAccess',
+ },
+ ],
+ },
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ document.getElementById('submit-btn');
+ `,
+ errors: [
+ {
+ messageId: 'noNodeAccess',
+ },
+ ],
+ },
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ screen.getByText('submit').closest('button');
+ `,
+ errors: [
+ {
+ // error points to `closest`
+ line: 4,
+ column: 36,
+ messageId: 'noNodeAccess',
+ },
+ ],
+ },
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ expect(screen.getByText('submit').closest('button').textContent).toBe('Submit');
+ `,
+ errors: [
+ {
+ messageId: 'noNodeAccess',
+ },
+ ],
+ },
+ {
+ code: `
+ import { render } from '@testing-library/react';
+
+ const { getByText } = render()
+ getByText('submit').closest('button');
+ `,
+ errors: [
+ {
+ messageId: 'noNodeAccess',
+ },
+ ],
+ },
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ const buttons = screen.getAllByRole('button');
+ const childA = buttons[1].firstChild;
+ const button = buttons[2];
+ button.lastChild
+ `,
+ errors: [
+ {
+ // error points to `firstChild`
+ line: 5,
+ column: 35,
+ messageId: 'noNodeAccess',
+ },
+ {
+ // error points to `lastChild`
+ line: 7,
+ column: 16,
+ messageId: 'noNodeAccess',
+ },
+ ],
+ },
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ const buttonText = screen.getByText('submit');
+ const button = buttonText.closest('button');
+ `,
+ errors: [
+ {
+ messageId: 'noNodeAccess',
+ },
+ ],
+ },
+ {
+ code: `
+ import { render } from '@testing-library/react';
+
+ const { getByText } = render()
+ const buttonText = getByText('submit');
+ const button = buttonText.closest('button');
+ `,
+ errors: [
+ {
+ messageId: 'noNodeAccess',
+ },
+ ],
+ },
+ {
+ code: `
+ import { render } from '@testing-library/react';
+
+ const { getByText } = render()
+ const button = getByText('submit').closest('button');
+ `,
+ errors: [
+ {
+ messageId: 'noNodeAccess',
+ },
+ ],
+ },
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ function getExampleDOM() {
+ const container = document.createElement('div');
+ container.innerHTML = \`
+
+
+
+
+
+
+
+ \`;
+ return container;
+ };
+ const exampleDOM = getExampleDOM();
+ const buttons = screen.getAllByRole(exampleDOM, 'button');
+ const buttonText = buttons[1].firstChild;
+ `,
+ errors: [
+ {
+ // error points to `firstChild`
+ line: 19,
+ column: 39,
+ messageId: 'noNodeAccess',
+ },
+ ],
+ },
+ {
+ code: `
+ import { screen } from '@testing-library/react';
+
+ function getExampleDOM() {
+ const container = document.createElement('div');
+ container.innerHTML = \`
+
+
+
+
+
+
+
+ \`;
+ return container;
+ };
+ const exampleDOM = getExampleDOM();
+ const submitButton = screen.getByText(exampleDOM, 'Submit');
+ const previousButton = submitButton.previousSibling;
+ `,
+ errors: [
+ {
+ // error points to `previousSibling`
+ line: 19,
+ column: 45,
+ messageId: 'noNodeAccess',
+ },
+ ],
+ },
+ ],
+});