diff --git a/docs/rules/no-node-access.md b/docs/rules/no-node-access.md index 638f06eb..95f1d952 100644 --- a/docs/rules/no-node-access.md +++ b/docs/rules/no-node-access.md @@ -51,6 +51,25 @@ within(signinModal).getByPlaceholderText('Username'); document.getElementById('submit-btn').closest('button'); ``` +## Options + +This rule has one option: + +- `allowContainerFirstChild`: **disabled by default**. When we have container + with rendered content then the easiest way to access content itself is [by using + `firstChild` property](https://testing-library.com/docs/react-testing-library/api/#container-1). Use this option in cases when this is hardly avoidable. + + ```js + "testing-library/no-node-access": ["error", {"allowContainerFirstChild": true}] + ``` + +Correct: + +```jsx +const { container } = render(); +expect(container.firstChild).toMatchSnapshot(); +``` + ## Further Reading ### Properties / methods that return another Node diff --git a/lib/rules/no-node-access.ts b/lib/rules/no-node-access.ts index 7777463f..cd5b7ea8 100644 --- a/lib/rules/no-node-access.ts +++ b/lib/rules/no-node-access.ts @@ -5,7 +5,7 @@ import { ALL_RETURNING_NODES } from '../utils'; export const RULE_NAME = 'no-node-access'; export type MessageIds = 'noNodeAccess'; -type Options = []; +export type Options = [{ allowContainerFirstChild: boolean }]; export default createTestingLibraryRule({ name: RULE_NAME, @@ -25,11 +25,24 @@ export default createTestingLibraryRule({ noNodeAccess: 'Avoid direct Node access. Prefer using the methods from Testing Library.', }, - schema: [], + schema: [ + { + type: 'object', + properties: { + allowContainerFirstChild: { + type: 'boolean', + }, + }, + }, + ], }, - defaultOptions: [], + defaultOptions: [ + { + allowContainerFirstChild: false, + }, + ], - create(context, _, helpers) { + create(context, [{ allowContainerFirstChild = false }], helpers) { function showErrorForNodeAccess(node: TSESTree.MemberExpression) { // This rule is so aggressive that can cause tons of false positives outside test files when Aggressive Reporting // is enabled. Because of that, this rule will skip this mechanism and report only if some Testing Library package @@ -42,6 +55,10 @@ export default createTestingLibraryRule({ ASTUtils.isIdentifier(node.property) && ALL_RETURNING_NODES.includes(node.property.name) ) { + if (allowContainerFirstChild && node.property.name === 'firstChild') { + return; + } + context.report({ node, loc: node.property.loc.start, diff --git a/tests/lib/rules/no-node-access.test.ts b/tests/lib/rules/no-node-access.test.ts index 64777483..d08139a2 100644 --- a/tests/lib/rules/no-node-access.test.ts +++ b/tests/lib/rules/no-node-access.test.ts @@ -1,8 +1,12 @@ -import rule, { RULE_NAME } from '../../../lib/rules/no-node-access'; +import type { TSESLint } from '@typescript-eslint/utils'; + +import rule, { RULE_NAME, Options } from '../../../lib/rules/no-node-access'; import { createRuleTester } from '../test-utils'; const ruleTester = createRuleTester(); +type ValidTestCase = TSESLint.ValidTestCase; + const SUPPORTED_TESTING_FRAMEWORKS = [ '@testing-library/angular', '@testing-library/react', @@ -11,51 +15,52 @@ const SUPPORTED_TESTING_FRAMEWORKS = [ ]; ruleTester.run(RULE_NAME, rule, { - valid: SUPPORTED_TESTING_FRAMEWORKS.flatMap((testingFramework) => [ - { - code: ` + valid: SUPPORTED_TESTING_FRAMEWORKS.flatMap( + (testingFramework) => [ + { + code: ` import { screen } from '${testingFramework}'; const buttonText = screen.getByText('submit'); `, - }, - { - code: ` + }, + { + code: ` import { screen } from '${testingFramework}'; const { getByText } = screen const firstChild = getByText('submit'); expect(firstChild).toBeInTheDocument() `, - }, - { - code: ` + }, + { + code: ` import { screen } from '${testingFramework}'; const firstChild = screen.getByText('submit'); expect(firstChild).toBeInTheDocument() `, - }, - { - code: ` + }, + { + code: ` import { screen } from '${testingFramework}'; const { getByText } = screen; const button = getByRole('button'); expect(button).toHaveTextContent('submit'); `, - }, - { - code: ` + }, + { + code: ` import { render, within } from '${testingFramework}'; const { getByLabelText } = render(); const signInModal = getByLabelText('Sign In'); within(signInModal).getByPlaceholderText('Username'); `, - }, - { - code: ` + }, + { + code: ` // case: code not related to testing library at all ReactDOM.render( @@ -70,25 +75,36 @@ ruleTester.run(RULE_NAME, rule, { document.getElementById('root') ); `, - }, - { - settings: { - 'testing-library/utils-module': 'test-utils', }, - code: ` + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` // case: custom module set but not imported (aggressive reporting limited) const closestButton = document.getElementById('submit-btn').closest('button'); expect(closestButton).toBeInTheDocument(); `, - }, - { - code: ` + }, + { + code: ` // case: without importing TL (aggressive reporting skipped) const closestButton = document.getElementById('submit-btn') expect(closestButton).toBeInTheDocument(); `, - }, - ]), + }, + { + options: [{ allowContainerFirstChild: true }], + code: ` + import { render } from '${testingFramework}'; + + const { container } = render() + + expect(container.firstChild).toMatchSnapshot() + `, + }, + ] + ), invalid: SUPPORTED_TESTING_FRAMEWORKS.flatMap((testingFramework) => [ { settings: { @@ -291,5 +307,22 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + code: ` + import { render } from '${testingFramework}'; + + const { container } = render() + + expect(container.firstChild).toMatchSnapshot() + `, + errors: [ + { + // error points to `firstChild` + line: 6, + column: 26, + messageId: 'noNodeAccess', + }, + ], + }, ]), });