diff --git a/.lintstagedrc b/.lintstagedrc index 59919a0f..b8a24a6f 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,6 +1,6 @@ { "*.{js,ts}": [ - "eslint --fix", + "eslint --max-warnings 0 --fix", "prettier --write", "jest --findRelatedTests" ], diff --git a/lib/detect-testing-library-utils.ts b/lib/detect-testing-library-utils.ts index 2e0ca930..c3bf5401 100644 --- a/lib/detect-testing-library-utils.ts +++ b/lib/detect-testing-library-utils.ts @@ -1,14 +1,19 @@ -import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; +import { + ASTUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/experimental-utils'; import { getImportModuleName, + getAssertNodeInfo, isLiteral, ImportModuleNode, isImportDeclaration, isImportNamespaceSpecifier, isImportSpecifier, - isIdentifier, isProperty, } from './node-utils'; +import { ABSENCE_MATCHERS, PRESENCE_MATCHERS } from './utils'; export type TestingLibrarySettings = { 'testing-library/module'?: string; @@ -41,6 +46,11 @@ export type DetectionHelpers = { getCustomModuleImportName: () => string | undefined; getIsTestingLibraryImported: () => boolean; getIsValidFilename: () => boolean; + isGetByQuery: (node: TSESTree.Identifier) => boolean; + isQueryByQuery: (node: TSESTree.Identifier) => boolean; + isSyncQuery: (node: TSESTree.Identifier) => boolean; + isPresenceAssert: (node: TSESTree.MemberExpression) => boolean; + isAbsenceAssert: (node: TSESTree.MemberExpression) => boolean; canReportErrors: () => boolean; findImportedUtilSpecifier: ( specifierName: string @@ -85,7 +95,8 @@ export function detectTestingLibraryUtils< return getImportModuleName(importedCustomModuleNode); }, /** - * Gets if Testing Library is considered as imported or not. + * Determines whether Testing Library utils are imported or not for + * current file being analyzed. * * By default, it is ALWAYS considered as imported. This is what we call * "aggressive reporting" so we don't miss TL utils reexported from @@ -105,9 +116,8 @@ export function detectTestingLibraryUtils< }, /** - * Gets if filename being analyzed is valid or not. - * - * This is based on "testing-library/filename-pattern" setting. + * Determines whether filename is valid or not for current file + * being analyzed based on "testing-library/filename-pattern" setting. */ getIsValidFilename() { const fileName = context.getFilename(); @@ -115,7 +125,66 @@ export function detectTestingLibraryUtils< }, /** - * Wraps all conditions that must be met to report rules. + * Determines whether a given node is `getBy*` or `getAllBy*` query variant or not. + */ + isGetByQuery(node) { + return !!node.name.match(/^get(All)?By.+$/); + }, + + /** + * Determines whether a given node is `queryBy*` or `queryAllBy*` query variant or not. + */ + isQueryByQuery(node) { + return !!node.name.match(/^query(All)?By.+$/); + }, + + /** + * Determines whether a given node is sync query or not. + */ + isSyncQuery(node) { + return this.isGetByQuery(node) || this.isQueryByQuery(node); + }, + + /** + * Determines whether a given MemberExpression node is a presence assert + * + * Presence asserts could have shape of: + * - expect(element).toBeInTheDocument() + * - expect(element).not.toBeNull() + */ + isPresenceAssert(node) { + const { matcher, isNegated } = getAssertNodeInfo(node); + + if (!matcher) { + return false; + } + + return isNegated + ? ABSENCE_MATCHERS.includes(matcher) + : PRESENCE_MATCHERS.includes(matcher); + }, + + /** + * Determines whether a given MemberExpression node is an absence assert + * + * Absence asserts could have shape of: + * - expect(element).toBeNull() + * - expect(element).not.toBeInTheDocument() + */ + isAbsenceAssert(node) { + const { matcher, isNegated } = getAssertNodeInfo(node); + + if (!matcher) { + return false; + } + + return isNegated + ? PRESENCE_MATCHERS.includes(matcher) + : ABSENCE_MATCHERS.includes(matcher); + }, + + /** + * Determines if file inspected meets all conditions to be reported by rules or not. */ canReportErrors() { return ( @@ -144,7 +213,7 @@ export function detectTestingLibraryUtils< return node.specifiers.find((n) => isImportNamespaceSpecifier(n)); } else { const requireNode = node.parent as TSESTree.VariableDeclarator; - if (isIdentifier(requireNode.id)) { + if (ASTUtils.isIdentifier(requireNode.id)) { // this is const rtl = require('foo') return requireNode.id; } @@ -153,7 +222,7 @@ export function detectTestingLibraryUtils< const property = destructuring.properties.find( (n) => isProperty(n) && - isIdentifier(n.key) && + ASTUtils.isIdentifier(n.key) && n.key.name === specifierName ); return (property as TSESTree.Property).key as TSESTree.Identifier; diff --git a/lib/node-utils.ts b/lib/node-utils.ts index a7c90332..b187e4e0 100644 --- a/lib/node-utils.ts +++ b/lib/node-utils.ts @@ -1,5 +1,6 @@ import { AST_NODE_TYPES, + ASTUtils, TSESLint, TSESTree, } from '@typescript-eslint/experimental-utils'; @@ -253,3 +254,41 @@ export function getImportModuleName( return node.arguments[0].value; } } + +type AssertNodeInfo = { + matcher: string | null; + isNegated: boolean; +}; +/** + * Extracts matcher info from MemberExpression node representing an assert. + */ +export function getAssertNodeInfo( + node: TSESTree.MemberExpression +): AssertNodeInfo { + const emptyInfo = { matcher: null, isNegated: false } as AssertNodeInfo; + + if ( + !isCallExpression(node.object) || + !ASTUtils.isIdentifier(node.object.callee) + ) { + return emptyInfo; + } + + if (node.object.callee.name !== 'expect') { + return emptyInfo; + } + + let matcher = ASTUtils.getPropertyName(node); + const isNegated = matcher === 'not'; + if (isNegated) { + matcher = isMemberExpression(node.parent) + ? ASTUtils.getPropertyName(node.parent) + : null; + } + + if (!matcher) { + return emptyInfo; + } + + return { matcher, isNegated }; +} diff --git a/lib/rules/prefer-presence-queries.ts b/lib/rules/prefer-presence-queries.ts index d9cf0825..222398e1 100644 --- a/lib/rules/prefer-presence-queries.ts +++ b/lib/rules/prefer-presence-queries.ts @@ -1,29 +1,12 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { - getDocsUrl, - ALL_QUERIES_METHODS, - PRESENCE_MATCHERS, - ABSENCE_MATCHERS, -} from '../utils'; -import { - findClosestCallNode, - isMemberExpression, - isIdentifier, -} from '../node-utils'; +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { findClosestCallNode, isMemberExpression } from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'prefer-presence-queries'; -export type MessageIds = 'presenceQuery' | 'absenceQuery' | 'expectQueryBy'; +export type MessageIds = 'wrongPresenceQuery' | 'wrongAbsenceQuery'; type Options = []; -const QUERIES_REGEXP = new RegExp( - `^(get|query)(All)?(${ALL_QUERIES_METHODS.join('|')})$` -); - -function isThrowingQuery(node: TSESTree.Identifier) { - return node.name.startsWith('get'); -} - -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { docs: { @@ -33,12 +16,10 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ recommended: 'error', }, messages: { - presenceQuery: + wrongPresenceQuery: 'Use `getBy*` queries rather than `queryBy*` for checking element is present', - absenceQuery: + wrongAbsenceQuery: 'Use `queryBy*` queries rather than `getBy*` for checking element is NOT present', - expectQueryBy: - 'Use `getBy*` only when checking elements are present, otherwise use `queryBy*`', }, schema: [], type: 'suggestion', @@ -46,49 +27,36 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ }, defaultOptions: [], - create(context) { + create(context, _, helpers) { return { - [`CallExpression Identifier[name=${QUERIES_REGEXP}]`]( - node: TSESTree.Identifier - ) { + 'CallExpression Identifier'(node: TSESTree.Identifier) { const expectCallNode = findClosestCallNode(node, 'expect'); - if (expectCallNode && isMemberExpression(expectCallNode.parent)) { - const expectStatement = expectCallNode.parent; - const property = expectStatement.property as TSESTree.Identifier; - let matcher = property.name; - let isNegatedMatcher = false; + if (!expectCallNode || !isMemberExpression(expectCallNode.parent)) { + return; + } - if ( - matcher === 'not' && - isMemberExpression(expectStatement.parent) && - isIdentifier(expectStatement.parent.property) - ) { - isNegatedMatcher = true; - matcher = expectStatement.parent.property.name; - } + // Sync queries (getBy and queryBy) are corresponding ones used + // to check presence or absence. If none found, stop the rule. + if (!helpers.isSyncQuery(node)) { + return; + } - const validMatchers = isThrowingQuery(node) - ? PRESENCE_MATCHERS - : ABSENCE_MATCHERS; + const isPresenceQuery = helpers.isGetByQuery(node); + const expectStatement = expectCallNode.parent; + const isPresenceAssert = helpers.isPresenceAssert(expectStatement); + const isAbsenceAssert = helpers.isAbsenceAssert(expectStatement); - const invalidMatchers = isThrowingQuery(node) - ? ABSENCE_MATCHERS - : PRESENCE_MATCHERS; + if (!isPresenceAssert && !isAbsenceAssert) { + return; + } - const messageId = isThrowingQuery(node) - ? 'absenceQuery' - : 'presenceQuery'; + if (isPresenceAssert && !isPresenceQuery) { + return context.report({ node, messageId: 'wrongPresenceQuery' }); + } - if ( - (!isNegatedMatcher && invalidMatchers.includes(matcher)) || - (isNegatedMatcher && validMatchers.includes(matcher)) - ) { - return context.report({ - node, - messageId, - }); - } + if (isAbsenceAssert && isPresenceQuery) { + return context.report({ node, messageId: 'wrongAbsenceQuery' }); } }, }; diff --git a/lib/utils.ts b/lib/utils.ts index da9f5c00..a6d0fa68 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -24,6 +24,7 @@ const LIBRARY_MODULES = [ '@testing-library/svelte', ]; +// TODO: should be deleted after all rules are migrated to v4 const hasTestingLibraryImportModule = ( node: TSESTree.ImportDeclaration ): boolean => { diff --git a/package.json b/package.json index 1ec8caa7..dda19f92 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "scripts": { "build": "tsc", "postbuild": "cpy README.md ./dist && cpy package.json ./dist && cpy LICENSE ./dist", - "lint": "eslint . --ext .js,.ts", + "lint": "eslint . --max-warnings 0 --ext .js,.ts", "lint:fix": "npm run lint -- --fix", "format": "prettier --write README.md \"{lib,docs,tests}/**/*.{js,ts,md}\"", "format:check": "prettier --check README.md \"{lib,docs,tests}/**/*.{js,json,yml,ts,md}\"", diff --git a/tests/create-testing-library-rule.test.ts b/tests/create-testing-library-rule.test.ts index 6ca48128..426a1601 100644 --- a/tests/create-testing-library-rule.test.ts +++ b/tests/create-testing-library-rule.test.ts @@ -89,6 +89,102 @@ ruleTester.run(RULE_NAME, rule, { import { foo } from 'custom-module-forced-report' `, }, + + // Test Cases for all settings mixed + { + settings: { + 'testing-library/module': 'test-utils', + 'testing-library/filename-pattern': 'testing-library\\.js', + }, + code: ` + // case: matching custom settings partially - module but not filename + import { render } from 'test-utils' + import { somethingElse } from 'another-module' + const foo = require('bar') + + const utils = render(); + `, + }, + { + settings: { + 'testing-library/module': 'test-utils', + 'testing-library/filename-pattern': 'testing-library\\.js', + }, + filename: 'MyComponent.testing-library.js', + code: ` + // case: matching custom settings partially - filename but not module + import { render } from 'other-utils' + import { somethingElse } from 'another-module' + const foo = require('bar') + + const utils = render(); + `, + }, + + // Test Cases for presence/absence assertions + // cases: asserts not related to presence/absence + 'expect(element).toBeDisabled()', + 'expect(element).toBeEnabled()', + + // cases: presence/absence matcher not related to assert + 'element.toBeInTheDocument()', + 'element.not.toBeInTheDocument()', + + // cases: weird scenarios to check guard against parent nodes + 'expect(element).not()', + 'expect(element).not()', + + // Test Cases for Queries and Aggressive Queries Reporting + { + code: ` + // case: custom method not matching "getBy*" variant pattern + getSomeElement('button') + `, + }, + { + code: ` + // case: custom method not matching "queryBy*" variant pattern + querySomeElement('button') + `, + }, + { + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + // case: built-in "getBy*" query not reported because custom module not imported + import { render } from 'other-module' + getByRole('button') + `, + }, + { + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + // case: built-in "queryBy*" query not reported because custom module not imported + import { render } from 'other-module' + queryByRole('button') + `, + }, + { + settings: { + 'testing-library/filename-pattern': 'testing-library\\.js', + }, + code: ` + // case: built-in "getBy*" query not reported because custom filename doesn't match + getByRole('button') + `, + }, + { + settings: { + 'testing-library/filename-pattern': 'testing-library\\.js', + }, + code: ` + // case: built-in "queryBy*" query not reported because custom filename doesn't match + queryByRole('button') + `, + }, ], invalid: [ // Test Cases for Imports & Filename @@ -260,5 +356,165 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ line: 3, column: 7, messageId: 'fakeError' }], }, + + // Test Cases for all settings mixed + { + settings: { + 'testing-library/module': 'test-utils', + 'testing-library/filename-pattern': 'testing-library\\.js', + }, + filename: 'MyComponent.testing-library.js', + code: ` + // case: matching all custom settings + import { render } from 'test-utils' + import { somethingElse } from 'another-module' + const foo = require('bar') + + const utils = render(); + `, + errors: [{ line: 7, column: 21, messageId: 'fakeError' }], + }, + + // Test Cases for presence/absence assertions + { + code: ` + // case: presence matcher .toBeInTheDocument forced to be reported + expect(element).toBeInTheDocument() + `, + errors: [{ line: 3, column: 7, messageId: 'presenceAssertError' }], + }, + { + code: ` + // case: absence matcher .not.toBeInTheDocument forced to be reported + expect(element).not.toBeInTheDocument() + `, + errors: [{ line: 3, column: 7, messageId: 'absenceAssertError' }], + }, + { + code: ` + // case: presence matcher .not.toBeNull forced to be reported + expect(element).not.toBeNull() + `, + errors: [{ line: 3, column: 7, messageId: 'presenceAssertError' }], + }, + { + code: ` + // case: absence matcher .toBeNull forced to be reported + expect(element).toBeNull() + `, + errors: [{ line: 3, column: 7, messageId: 'absenceAssertError' }], + }, + + // Test Cases for Queries and Aggressive Queries Reporting + { + code: ` + // case: built-in "getBy*" query reported without import (aggressive reporting) + getByRole('button') + `, + errors: [{ line: 3, column: 7, messageId: 'getByError' }], + }, + { + code: ` + // case: built-in "queryBy*" query reported without import (aggressive reporting) + queryByRole('button') + `, + errors: [{ line: 3, column: 7, messageId: 'queryByError' }], + }, + { + filename: 'MyComponent.spec.js', + code: ` + // case: custom "getBy*" query reported without import (aggressive reporting) + getByIcon('search') + `, + errors: [{ line: 3, column: 7, messageId: 'getByError' }], + }, + { + code: ` + // case: custom "queryBy*" query reported without import (aggressive reporting) + queryByIcon('search') + `, + errors: [{ line: 3, column: 7, messageId: 'queryByError' }], + }, + { + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + // case: built-in "getBy*" query reported with custom module + Testing Library package import + import { render } from '@testing-library/react' + getByRole('button') + `, + errors: [{ line: 4, column: 7, messageId: 'getByError' }], + }, + { + filename: 'MyComponent.spec.js', + code: ` + // case: built-in "queryBy*" query reported with custom module + Testing Library package import + import { render } from '@testing-library/framework' + queryByRole('button') + `, + errors: [{ line: 4, column: 7, messageId: 'queryByError' }], + }, + { + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + // case: built-in "getBy*" query reported with custom module + custom module import + import { render } from 'test-utils' + getByRole('button') + `, + errors: [{ line: 4, column: 7, messageId: 'getByError' }], + }, + { + filename: 'MyComponent.spec.js', + code: ` + // case: built-in "queryBy*" query reported with custom module + custom module import + import { render } from 'test-utils' + queryByRole('button') + `, + errors: [{ line: 4, column: 7, messageId: 'queryByError' }], + }, + + { + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + // case: custom "getBy*" query reported with custom module + Testing Library package import + import { render } from '@testing-library/react' + getByIcon('search') + `, + errors: [{ line: 4, column: 7, messageId: 'getByError' }], + }, + { + filename: 'MyComponent.spec.js', + code: ` + // case: custom "queryBy*" query reported with custom module + Testing Library package import + import { render } from '@testing-library/framework' + queryByIcon('search') + `, + errors: [{ line: 4, column: 7, messageId: 'queryByError' }], + }, + { + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + // case: custom "getBy*" query reported with custom module + custom module import + import { render } from 'test-utils' + getByIcon('search') + `, + errors: [{ line: 4, column: 7, messageId: 'getByError' }], + }, + { + filename: 'MyComponent.spec.js', + code: ` + // case: custom "queryBy*" query reported with custom module + custom module import + import { render } from 'test-utils' + queryByIcon('search') + `, + errors: [{ line: 4, column: 7, messageId: 'queryByError' }], + }, ], }); diff --git a/tests/fake-rule.ts b/tests/fake-rule.ts index 829940c3..6bcc18ba 100644 --- a/tests/fake-rule.ts +++ b/tests/fake-rule.ts @@ -7,7 +7,12 @@ import { createTestingLibraryRule } from '../lib/create-testing-library-rule'; export const RULE_NAME = 'fake-rule'; type Options = []; -type MessageIds = 'fakeError'; +type MessageIds = + | 'fakeError' + | 'getByError' + | 'queryByError' + | 'presenceAssertError' + | 'absenceAssertError'; export default createTestingLibraryRule({ name: RULE_NAME, @@ -20,36 +25,55 @@ export default createTestingLibraryRule({ }, messages: { fakeError: 'fake error reported', + getByError: 'some error related to getBy reported', + queryByError: 'some error related to queryBy reported', + presenceAssertError: 'some error related to presence assert reported', + absenceAssertError: 'some error related to absence assert reported', }, fixable: null, schema: [], }, defaultOptions: [], create(context, _, helpers) { - const reportRenderIdentifier = (node: TSESTree.Identifier) => { + const reportCallExpressionIdentifier = (node: TSESTree.Identifier) => { + // force "render" to be reported if (node.name === 'render') { - context.report({ - node, - messageId: 'fakeError', - }); + return context.report({ node, messageId: 'fakeError' }); + } + + // force queries to be reported + if (helpers.isGetByQuery(node)) { + return context.report({ node, messageId: 'getByError' }); + } + + if (helpers.isQueryByQuery(node)) { + return context.report({ node, messageId: 'queryByError' }); + } + }; + + const reportMemberExpression = (node: TSESTree.MemberExpression) => { + if (helpers.isPresenceAssert(node)) { + return context.report({ node, messageId: 'presenceAssertError' }); + } + + if (helpers.isAbsenceAssert(node)) { + return context.report({ node, messageId: 'absenceAssertError' }); } }; - const checkImportDeclaration = (node: TSESTree.ImportDeclaration) => { + const reportImportDeclaration = (node: TSESTree.ImportDeclaration) => { // This is just to check that defining an `ImportDeclaration` doesn't // override `ImportDeclaration` from `detectTestingLibraryUtils` if (node.source.value === 'report-me') { - context.report({ - node, - messageId: 'fakeError', - }); + context.report({ node, messageId: 'fakeError' }); } }; return { - 'CallExpression Identifier': reportRenderIdentifier, - ImportDeclaration: checkImportDeclaration, + 'CallExpression Identifier': reportCallExpressionIdentifier, + MemberExpression: reportMemberExpression, + ImportDeclaration: reportImportDeclaration, 'Program:exit'() { const importNode = helpers.getCustomModuleImportNode(); const importName = helpers.getCustomModuleImportName(); diff --git a/tests/lib/rules/prefer-presence-queries.test.ts b/tests/lib/rules/prefer-presence-queries.test.ts index fbc26df0..637d1aec 100644 --- a/tests/lib/rules/prefer-presence-queries.test.ts +++ b/tests/lib/rules/prefer-presence-queries.test.ts @@ -14,81 +14,329 @@ const queryAllByQueries = ALL_QUERIES_METHODS.map( (method) => `queryAll${method}` ); -const allQueryUseInAssertion = (queryName: string) => [ - queryName, - `screen.${queryName}`, -]; +type AssertionFnParams = { + query: string; + matcher: string; + messageId: MessageIds; + shouldUseScreen?: boolean; +}; -const getValidAssertion = (query: string, matcher: string) => - allQueryUseInAssertion(query).map((query) => ({ - code: `expect(${query}('Hello'))${matcher}`, - })); +const getValidAssertion = ({ + query, + matcher, + shouldUseScreen = false, +}: Omit) => { + const finalQuery = shouldUseScreen ? `screen.${query}` : query; + return { + code: `expect(${finalQuery}('Hello'))${matcher}`, + }; +}; -const getInvalidAssertion = ( - query: string, - matcher: string, - messageId: MessageIds -) => - allQueryUseInAssertion(query).map((query) => ({ - code: `expect(${query}('Hello'))${matcher}`, - errors: [{ messageId }], - })); +const getInvalidAssertion = ({ + query, + matcher, + messageId, + shouldUseScreen = false, +}: AssertionFnParams) => { + const finalQuery = shouldUseScreen ? `screen.${query}` : query; + return { + code: `expect(${finalQuery}('Hello'))${matcher}`, + errors: [{ messageId, line: 1, column: shouldUseScreen ? 15 : 8 }], + }; +}; ruleTester.run(RULE_NAME, rule, { valid: [ + // cases: methods not matching Testing Library queries pattern + `expect(queryElement('foo')).toBeInTheDocument()`, + `expect(getElement('foo')).not.toBeInTheDocument()`, + { + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + // case: invalid presence assert but not reported because custom module is not imported + expect(queryByRole('button')).toBeInTheDocument() + `, + }, + { + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + // case: invalid absence assert but not reported because custom module is not imported + expect(getByRole('button')).not.toBeInTheDocument() + `, + }, + // cases: asserting presence correctly with `getBy*` queries + ...getByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getValidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + }), + getValidAssertion({ query: queryName, matcher: '.toBeTruthy()' }), + getValidAssertion({ query: queryName, matcher: '.toBeDefined()' }), + getValidAssertion({ query: queryName, matcher: '.toBe("foo")' }), + getValidAssertion({ query: queryName, matcher: '.toEqual("World")' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeFalsy()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeNull()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeDisabled()' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + }), + ], + [] + ), + // cases: asserting presence correctly with `screen.getBy*` queries ...getByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getValidAssertion(queryName, '.toBeInTheDocument()'), - ...getValidAssertion(queryName, '.toBeTruthy()'), - ...getValidAssertion(queryName, '.toBeDefined()'), - ...getValidAssertion(queryName, '.toBe("foo")'), - ...getValidAssertion(queryName, '.toEqual("World")'), - ...getValidAssertion(queryName, '.not.toBeFalsy()'), - ...getValidAssertion(queryName, '.not.toBeNull()'), - ...getValidAssertion(queryName, '.not.toBeDisabled()'), - ...getValidAssertion(queryName, '.not.toHaveClass("btn")'), + getValidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBe("foo")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toEqual("World")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeDisabled()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + shouldUseScreen: true, + }), ], [] ), + // cases: asserting presence correctly with `getAllBy*` queries ...getAllByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getValidAssertion(queryName, '.toBeInTheDocument()'), - ...getValidAssertion(queryName, '.toBeTruthy()'), - ...getValidAssertion(queryName, '.toBeDefined()'), - ...getValidAssertion(queryName, '.toBe("foo")'), - ...getValidAssertion(queryName, '.toEqual("World")'), - ...getValidAssertion(queryName, '.not.toBeFalsy()'), - ...getValidAssertion(queryName, '.not.toBeNull()'), - ...getValidAssertion(queryName, '.not.toBeDisabled()'), - ...getValidAssertion(queryName, '.not.toHaveClass("btn")'), + getValidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + }), + getValidAssertion({ query: queryName, matcher: '.toBeTruthy()' }), + getValidAssertion({ query: queryName, matcher: '.toBeDefined()' }), + getValidAssertion({ query: queryName, matcher: '.toBe("foo")' }), + getValidAssertion({ query: queryName, matcher: '.toEqual("World")' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeFalsy()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeNull()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeDisabled()' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + }), + ], + [] + ), + // cases: asserting presence correctly with `screen.getAllBy*` queries + ...getAllByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getValidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBe("foo")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toEqual("World")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeDisabled()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + shouldUseScreen: true, + }), + ], + [] + ), + // cases: asserting absence correctly with `queryBy*` queries + ...queryByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getValidAssertion({ query: queryName, matcher: '.toBeNull()' }), + getValidAssertion({ query: queryName, matcher: '.toBeFalsy()' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + }), + getValidAssertion({ query: queryName, matcher: '.not.toBeTruthy()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeDefined()' }), + getValidAssertion({ query: queryName, matcher: '.toEqual("World")' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + }), ], [] ), + // cases: asserting absence correctly with `screen.queryBy*` queries ...queryByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getValidAssertion(queryName, '.toBeNull()'), - ...getValidAssertion(queryName, '.toBeFalsy()'), - ...getValidAssertion(queryName, '.not.toBeInTheDocument()'), - ...getValidAssertion(queryName, '.not.toBeTruthy()'), - ...getValidAssertion(queryName, '.not.toBeDefined()'), - ...getValidAssertion(queryName, '.toEqual("World")'), - ...getValidAssertion(queryName, '.not.toHaveClass("btn")'), + getValidAssertion({ + query: queryName, + matcher: '.toBeNull()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toEqual("World")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + shouldUseScreen: true, + }), + ], + [] + ), + // cases: asserting absence correctly with `queryAllBy*` queries + ...queryAllByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getValidAssertion({ query: queryName, matcher: '.toBeNull()' }), + getValidAssertion({ query: queryName, matcher: '.toBeFalsy()' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + }), + getValidAssertion({ query: queryName, matcher: '.not.toBeTruthy()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeDefined()' }), + getValidAssertion({ query: queryName, matcher: '.toEqual("World")' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + }), ], [] ), + // cases: asserting absence correctly with `screen.queryAllBy*` queries ...queryAllByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getValidAssertion(queryName, '.toBeNull()'), - ...getValidAssertion(queryName, '.toBeFalsy()'), - ...getValidAssertion(queryName, '.not.toBeInTheDocument()'), - ...getValidAssertion(queryName, '.not.toBeTruthy()'), - ...getValidAssertion(queryName, '.not.toBeDefined()'), - ...getValidAssertion(queryName, '.toEqual("World")'), - ...getValidAssertion(queryName, '.not.toHaveClass("btn")'), + getValidAssertion({ + query: queryName, + matcher: '.toBeNull()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toEqual("World")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + shouldUseScreen: true, + }), ], [] ), @@ -98,96 +346,343 @@ ruleTester.run(RULE_NAME, rule, { { code: 'const el = queryByText("button")', }, - { - code: - 'expect(getByNonTestingLibraryQuery("button")).not.toBeInTheDocument()', - }, - { - code: - 'expect(queryByNonTestingLibraryQuery("button")).toBeInTheDocument()', - }, { code: `async () => { const el = await findByText('button') expect(el).toBeInTheDocument() }`, }, - // some weird examples after here to check guard against parent nodes - { - code: 'expect(getByText("button")).not()', - }, - { - code: 'expect(queryByText("button")).not()', - }, + `// case: query an element with getBy but then check its absence after doing + // some action which makes it disappear. + + // submit button exists + const submitButton = screen.getByRole('button') + fireEvent.click(submitButton) + + // right after clicking submit button it disappears + expect(submitButton).not.toBeInTheDocument() + `, ], invalid: [ + // cases: asserting absence incorrectly with `getBy*` queries ...getByQueries.reduce( (invalidRules, queryName) => [ ...invalidRules, - ...getInvalidAssertion(queryName, '.toBeNull()', 'absenceQuery'), - ...getInvalidAssertion(queryName, '.toBeFalsy()', 'absenceQuery'), - ...getInvalidAssertion( - queryName, - '.not.toBeInTheDocument()', - 'absenceQuery' - ), - ...getInvalidAssertion(queryName, '.not.toBeTruthy()', 'absenceQuery'), - ...getInvalidAssertion(queryName, '.not.toBeDefined()', 'absenceQuery'), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeNull()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + messageId: 'wrongAbsenceQuery', + }), ], [] ), + // cases: asserting absence incorrectly with `screen.getBy*` queries + ...getByQueries.reduce( + (invalidRules, queryName) => [ + ...invalidRules, + getInvalidAssertion({ + query: queryName, + matcher: '.toBeNull()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + ], + [] + ), + // cases: asserting absence incorrectly with `getAllBy*` queries ...getAllByQueries.reduce( (invalidRules, queryName) => [ ...invalidRules, - ...getInvalidAssertion(queryName, '.toBeNull()', 'absenceQuery'), - ...getInvalidAssertion(queryName, '.toBeFalsy()', 'absenceQuery'), - ...getInvalidAssertion( - queryName, - '.not.toBeInTheDocument()', - 'absenceQuery' - ), - ...getInvalidAssertion(queryName, '.not.toBeTruthy()', 'absenceQuery'), - ...getInvalidAssertion(queryName, '.not.toBeDefined()', 'absenceQuery'), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeNull()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + messageId: 'wrongAbsenceQuery', + }), ], [] ), - { - code: 'expect(screen.getAllByText("button")[1]).not.toBeInTheDocument()', - errors: [{ messageId: 'absenceQuery' }], - }, + // cases: asserting absence incorrectly with `screen.getAllBy*` queries + ...getAllByQueries.reduce( + (invalidRules, queryName) => [ + ...invalidRules, + getInvalidAssertion({ + query: queryName, + matcher: '.toBeNull()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + ], + [] + ), + // cases: asserting presence incorrectly with `queryBy*` queries ...queryByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getInvalidAssertion(queryName, '.toBeTruthy()', 'presenceQuery'), - ...getInvalidAssertion(queryName, '.toBeDefined()', 'presenceQuery'), - ...getInvalidAssertion( - queryName, - '.toBeInTheDocument()', - 'presenceQuery' - ), - ...getInvalidAssertion(queryName, '.not.toBeFalsy()', 'presenceQuery'), - ...getInvalidAssertion(queryName, '.not.toBeNull()', 'presenceQuery'), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + messageId: 'wrongPresenceQuery', + }), ], [] ), + // cases: asserting presence incorrectly with `screen.queryBy*` queries + ...queryByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getInvalidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + ], + [] + ), + // cases: asserting presence incorrectly with `queryAllBy*` queries ...queryAllByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getInvalidAssertion(queryName, '.toBeTruthy()', 'presenceQuery'), - ...getInvalidAssertion(queryName, '.toBeDefined()', 'presenceQuery'), - ...getInvalidAssertion( - queryName, - '.toBeInTheDocument()', - 'presenceQuery' - ), - ...getInvalidAssertion(queryName, '.not.toBeFalsy()', 'presenceQuery'), - ...getInvalidAssertion(queryName, '.not.toBeNull()', 'presenceQuery'), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + messageId: 'wrongPresenceQuery', + }), ], [] ), + // cases: asserting presence incorrectly with `screen.queryAllBy*` queries + ...queryAllByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getInvalidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + ], + [] + ), + { + code: 'expect(screen.getAllByText("button")[1]).not.toBeInTheDocument()', + errors: [{ messageId: 'wrongAbsenceQuery', line: 1, column: 15 }], + }, { code: 'expect(screen.queryAllByText("button")[1]).toBeInTheDocument()', - errors: [{ messageId: 'presenceQuery' }], + errors: [{ messageId: 'wrongPresenceQuery', line: 1, column: 15 }], + }, + { + code: ` + // case: asserting presence incorrectly with custom queryBy* query + expect(queryByCustomQuery("button")).toBeInTheDocument() + `, + errors: [{ messageId: 'wrongPresenceQuery', line: 3, column: 16 }], + }, + { + code: ` + // case: asserting absence incorrectly with custom getBy* query + expect(getByCustomQuery("button")).not.toBeInTheDocument() + `, + errors: [{ messageId: 'wrongAbsenceQuery', line: 3, column: 16 }], + }, + { + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + // case: asserting presence incorrectly importing custom module + import 'test-utils' + expect(queryByRole("button")).toBeInTheDocument() + `, + errors: [{ line: 4, column: 14, messageId: 'wrongPresenceQuery' }], + }, + { + settings: { + 'testing-library/module': 'test-utils', + }, + code: ` + // case: asserting absence incorrectly importing custom module + import 'test-utils' + expect(getByRole("button")).not.toBeInTheDocument() + `, + errors: [{ line: 4, column: 14, messageId: 'wrongAbsenceQuery' }], }, ], });