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',
+ },
+ ],
+ },
]),
});