diff --git a/README.md b/README.md index a7a4ba9f..f0acf10d 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,7 @@ module.exports = [ | [prefer-query-matchers](docs/rules/prefer-query-matchers.md) | Ensure the configured `get*`/`query*` query is used with the corresponding matchers | | | | | [prefer-screen-queries](docs/rules/prefer-screen-queries.md) | Suggest using `screen` while querying | ![badge-angular][] ![badge-dom][] ![badge-marko][] ![badge-react][] ![badge-svelte][] ![badge-vue][] | | | | [prefer-user-event](docs/rules/prefer-user-event.md) | Suggest using `userEvent` over `fireEvent` for simulating user interactions | | | | +| [prefer-user-event-setup](docs/rules/prefer-user-event-setup.md) | Suggest using userEvent with setup() instead of direct methods | | | | | [render-result-naming-convention](docs/rules/render-result-naming-convention.md) | Enforce a valid naming for return value from `render` | ![badge-angular][] ![badge-marko][] ![badge-react][] ![badge-svelte][] ![badge-vue][] | | | diff --git a/docs/rules/prefer-user-event-setup.md b/docs/rules/prefer-user-event-setup.md new file mode 100644 index 00000000..aa6f74a4 --- /dev/null +++ b/docs/rules/prefer-user-event-setup.md @@ -0,0 +1,148 @@ +# Suggest using userEvent with setup() instead of direct methods (`testing-library/prefer-user-event-setup`) + + + +## Rule Details + +This rule encourages using methods on instances returned by `userEvent.setup()` instead of calling methods directly on the `userEvent` object. The setup pattern is the [recommended approach](https://testing-library.com/docs/user-event/intro/#writing-tests-with-userevent) in the official user-event documentation. + +Using `userEvent.setup()` provides several benefits: + +- Ensures proper initialization of the user-event system +- Better reflects real user interactions with proper event sequencing +- Provides consistent timing behavior across different environments +- Allows configuration of delays and other options + +### Why Use setup()? + +Starting with user-event v14, the library recommends calling `userEvent.setup()` before rendering your component and using the returned instance for all user interactions. This ensures that the event system is properly initialized and that all events are fired in the correct order. + +## Examples + +Examples of **incorrect** code for this rule: + +```js +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; + +test('clicking a button', async () => { + render(); + // ❌ Direct call without setup() + await userEvent.click(screen.getByRole('button')); +}); + +test('typing in input', async () => { + render(); + // ❌ Direct call without setup() + await userEvent.type(screen.getByRole('textbox'), 'Hello'); +}); + +test('multiple interactions', async () => { + render(); + // ❌ Multiple direct calls + await userEvent.type(screen.getByRole('textbox'), 'Hello'); + await userEvent.click(screen.getByRole('button')); +}); +``` + +Examples of **correct** code for this rule: + +```js +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; + +test('clicking a button', async () => { + // ✅ Create user instance with setup() + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button')); +}); + +test('typing in input', async () => { + // ✅ Create user instance with setup() + const user = userEvent.setup(); + render(); + await user.type(screen.getByRole('textbox'), 'Hello'); +}); + +test('multiple interactions', async () => { + // ✅ Use the same user instance for all interactions + const user = userEvent.setup(); + render(); + await user.type(screen.getByRole('textbox'), 'Hello'); + await user.click(screen.getByRole('button')); +}); + +// ✅ Using a setup function pattern +function setup(jsx) { + return { + user: userEvent.setup(), + ...render(jsx), + }; +} + +test('with custom setup function', async () => { + const { user, getByRole } = setup(); + await user.click(getByRole('button')); +}); +``` + +### Custom Render Functions + +A common pattern is to create a custom render function that includes the user-event setup: + +```js +import userEvent from '@testing-library/user-event'; +import { render } from '@testing-library/react'; + +function renderWithUser(ui, options) { + return { + user: userEvent.setup(), + ...render(ui, options), + }; +} + +test('using custom render', async () => { + const { user, getByRole } = renderWithUser(); + await user.click(getByRole('button')); +}); +``` + +## When Not To Use This Rule + +You may want to disable this rule in the following situations: + +### Using older user-event versions + +If you're using an older version of user-event (< v14) that doesn't support or require the setup pattern. + +### Custom render functions in external files + +If your project uses a custom render function that calls `userEvent.setup()` in a separate test utilities file (e.g., `test-utils.ts`), this rule may produce false positives because it cannot detect the setup call outside the current file. + +For example: + +```js +// test-utils.js +export function renderWithUser(ui) { + return { + user: userEvent.setup(), // setup() called here + ...render(ui), + }; +} + +// MyComponent.test.js +import { renderWithUser } from './test-utils'; + +test('example', async () => { + const { user } = renderWithUser(); + await user.click(...); // ✅ This is correct, but the rule cannot detect it +}); +``` + +In this case, you should disable the rule for your project since it cannot track setup calls across files. + +## Further Reading + +- [user-event documentation - Writing tests with userEvent](https://testing-library.com/docs/user-event/intro/#writing-tests-with-userevent) +- [user-event setup() API](https://testing-library.com/docs/user-event/setup) diff --git a/lib/rules/index.ts b/lib/rules/index.ts index 49a221a9..8dac8636 100644 --- a/lib/rules/index.ts +++ b/lib/rules/index.ts @@ -25,6 +25,7 @@ import preferQueryByDisappearance from './prefer-query-by-disappearance'; import preferQueryMatchers from './prefer-query-matchers'; import preferScreenQueries from './prefer-screen-queries'; import preferUserEvent from './prefer-user-event'; +import preferUserEventSetup from './prefer-user-event-setup'; import renderResultNamingConvention from './render-result-naming-convention'; export default { @@ -55,5 +56,6 @@ export default { 'prefer-query-matchers': preferQueryMatchers, 'prefer-screen-queries': preferScreenQueries, 'prefer-user-event': preferUserEvent, + 'prefer-user-event-setup': preferUserEventSetup, 'render-result-naming-convention': renderResultNamingConvention, }; diff --git a/lib/rules/prefer-user-event-setup.ts b/lib/rules/prefer-user-event-setup.ts new file mode 100644 index 00000000..41822836 --- /dev/null +++ b/lib/rules/prefer-user-event-setup.ts @@ -0,0 +1,194 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import { createTestingLibraryRule } from '../create-testing-library-rule'; + +import type { TSESTree } from '@typescript-eslint/utils'; + +export const RULE_NAME = 'prefer-user-event-setup'; + +export type MessageIds = 'preferUserEventSetup'; +export type Options = []; + +const USER_EVENT_PACKAGE = '@testing-library/user-event'; +const USER_EVENT_NAME = 'userEvent'; +const SETUP_METHOD_NAME = 'setup'; + +export default createTestingLibraryRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: + 'Suggest using userEvent with setup() instead of direct methods', + recommendedConfig: { + dom: false, + angular: false, + react: false, + vue: false, + svelte: false, + marko: false, + }, + }, + messages: { + preferUserEventSetup: + 'Prefer using userEvent with setup() instead of direct {{method}}() call. Use: const user = userEvent.setup(); await user.{{method}}(...)', + }, + schema: [], + }, + defaultOptions: [], + + create(context, options, helpers) { + // Track variables assigned from userEvent.setup() + const userEventSetupVars = new Set(); + + // Track functions that return userEvent.setup() instances + const setupFunctions = new Map>(); + + // Track imported userEvent identifier (could be aliased) + let userEventIdentifier: string | null = null; + + function isUserEventSetupCall(node: TSESTree.Node): boolean { + return ( + node.type === AST_NODE_TYPES.CallExpression && + node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.object.type === AST_NODE_TYPES.Identifier && + node.callee.object.name === userEventIdentifier && + node.callee.property.type === AST_NODE_TYPES.Identifier && + node.callee.property.name === SETUP_METHOD_NAME + ); + } + + return { + // Track userEvent imports + ImportDeclaration(node: TSESTree.ImportDeclaration) { + if (node.source.value === USER_EVENT_PACKAGE) { + // Default import: import userEvent from '@testing-library/user-event' + const defaultImport = node.specifiers.find( + (spec) => spec.type === AST_NODE_TYPES.ImportDefaultSpecifier + ); + if (defaultImport) { + userEventIdentifier = defaultImport.local.name; + } + + // Named import: import { userEvent } from '@testing-library/user-event' + const namedImport = node.specifiers.find( + (spec) => + spec.type === AST_NODE_TYPES.ImportSpecifier && + spec.imported.type === AST_NODE_TYPES.Identifier && + spec.imported.name === USER_EVENT_NAME + ); + if ( + namedImport && + namedImport.type === AST_NODE_TYPES.ImportSpecifier + ) { + userEventIdentifier = namedImport.local.name; + } + } + }, + + // Track variables assigned from userEvent.setup() + VariableDeclarator(node: TSESTree.VariableDeclarator) { + if (!userEventIdentifier || !node.init) return; + + // Direct assignment: const user = userEvent.setup() + if ( + isUserEventSetupCall(node.init) && + node.id.type === AST_NODE_TYPES.Identifier + ) { + userEventSetupVars.add(node.id.name); + } + + // Destructuring from a setup function + if ( + node.id.type === AST_NODE_TYPES.ObjectPattern && + node.init.type === AST_NODE_TYPES.CallExpression && + node.init.callee.type === AST_NODE_TYPES.Identifier + ) { + const functionName = node.init.callee.name; + const setupProps = setupFunctions.get(functionName); + + if (setupProps) { + for (const prop of node.id.properties) { + if ( + prop.type === AST_NODE_TYPES.Property && + prop.key.type === AST_NODE_TYPES.Identifier && + setupProps.has(prop.key.name) && + prop.value.type === AST_NODE_TYPES.Identifier + ) { + userEventSetupVars.add(prop.value.name); + } + } + } + } + }, + + // Track functions that return objects with userEvent.setup() + // Note: This simplified implementation only checks direct return statements + // in the function body, not nested functions or complex flows + FunctionDeclaration(node: TSESTree.FunctionDeclaration) { + if (!userEventIdentifier || !node.id) return; + + // For simplicity, only check direct return statements in the function body + if (node.body && node.body.type === AST_NODE_TYPES.BlockStatement) { + for (const statement of node.body.body) { + if (statement.type === AST_NODE_TYPES.ReturnStatement) { + const ret = statement; + if ( + ret.argument && + ret.argument.type === AST_NODE_TYPES.ObjectExpression + ) { + const props = new Set(); + for (const prop of ret.argument.properties) { + if ( + prop.type === AST_NODE_TYPES.Property && + prop.key.type === AST_NODE_TYPES.Identifier && + prop.value && + isUserEventSetupCall(prop.value) + ) { + props.add(prop.key.name); + } + } + if (props.size > 0) { + setupFunctions.set(node.id.name, props); + } + } + } + } + } + }, + + // Check for direct userEvent method calls + CallExpression(node: TSESTree.CallExpression) { + if (!userEventIdentifier) return; + + if ( + node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.property.type === AST_NODE_TYPES.Identifier && + helpers.isUserEventMethod(node.callee.property) + ) { + const methodName = node.callee.property.name; + + // Exclude setup() method + if (methodName === SETUP_METHOD_NAME) { + return; + } + + // Check if this is called on a setup instance + const isSetupInstance = + node.callee.object.type === AST_NODE_TYPES.Identifier && + userEventSetupVars.has(node.callee.object.name); + + if (!isSetupInstance) { + context.report({ + node: node.callee, + messageId: 'preferUserEventSetup', + data: { + method: methodName, + }, + }); + } + } + }, + }; + }, +}); diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 08faa9a2..065a15b9 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -124,6 +124,25 @@ const METHODS_RETURNING_NODES = [ const EVENT_HANDLER_METHODS = ['click', 'select', 'submit'] as const; +const USER_EVENT_METHODS = [ + 'clear', + 'click', + 'copy', + 'cut', + 'dblClick', + 'deselectOptions', + 'hover', + 'keyboard', + 'pointer', + 'paste', + 'selectOptions', + 'tripleClick', + 'type', + 'unhover', + 'upload', + 'tab', +] as const; + const ALL_RETURNING_NODES = [ ...PROPERTIES_RETURNING_NODES, ...METHODS_RETURNING_NODES, @@ -158,6 +177,7 @@ export { PRESENCE_MATCHERS, ABSENCE_MATCHERS, EVENT_HANDLER_METHODS, + USER_EVENT_METHODS, USER_EVENT_MODULE, OLD_LIBRARY_MODULES, }; diff --git a/tests/lib/rules/await-async-events.test.ts b/tests/lib/rules/await-async-events.test.ts index 3aa400bd..4690b008 100644 --- a/tests/lib/rules/await-async-events.test.ts +++ b/tests/lib/rules/await-async-events.test.ts @@ -1,4 +1,5 @@ import rule, { RULE_NAME } from '../../../lib/rules/await-async-events'; +import { USER_EVENT_METHODS } from '../../../lib/utils'; import { createRuleTester } from '../test-utils'; import type { Options } from '../../../lib/rules/await-async-events'; @@ -12,24 +13,7 @@ const FIRE_EVENT_ASYNC_FUNCTIONS = [ 'blur', 'keyDown', ] as const; -const USER_EVENT_ASYNC_FUNCTIONS = [ - 'click', - 'dblClick', - 'tripleClick', - 'hover', - 'unhover', - 'tab', - 'keyboard', - 'copy', - 'cut', - 'paste', - 'pointer', - 'clear', - 'deselectOptions', - 'selectOptions', - 'type', - 'upload', -] as const; +const USER_EVENT_ASYNC_FUNCTIONS = USER_EVENT_METHODS; const FIRE_EVENT_ASYNC_FRAMEWORKS = [ '@testing-library/vue', '@marko/testing-library', diff --git a/tests/lib/rules/prefer-user-event-setup.test.ts b/tests/lib/rules/prefer-user-event-setup.test.ts new file mode 100644 index 00000000..1a50b40c --- /dev/null +++ b/tests/lib/rules/prefer-user-event-setup.test.ts @@ -0,0 +1,315 @@ +import rule, { RULE_NAME } from '../../../lib/rules/prefer-user-event-setup'; +import { USER_EVENT_METHODS } from '../../../lib/utils'; +import { createRuleTester } from '../test-utils'; + +import type { MessageIds } from '../../../lib/rules/prefer-user-event-setup'; + +const ruleTester = createRuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + // Using userEvent.setup() correctly + { + code: ` + import userEvent from '@testing-library/user-event'; + + test('example', async () => { + const user = userEvent.setup(); + await user.click(element); + }); + `, + }, + { + code: ` + import userEvent from '@testing-library/user-event'; + + test('example', async () => { + const user = userEvent.setup(); + await user.type(input, 'hello'); + await user.click(button); + }); + `, + }, + // Setup function pattern + { + code: ` + import userEvent from '@testing-library/user-event'; + import { render } from '@testing-library/react'; + + function setup(jsx) { + return { + user: userEvent.setup(), + ...render(jsx), + }; + } + + test('example', async () => { + const { user, getByRole } = setup(); + await user.click(getByRole('button')); + }); + `, + }, + // Different variable names + { + code: ` + import userEvent from '@testing-library/user-event'; + + test('example', async () => { + const myUser = userEvent.setup(); + await myUser.click(element); + }); + `, + }, + // Destructuring from setup function + { + code: ` + import userEvent from '@testing-library/user-event'; + + function renderWithUser(component) { + return { + user: userEvent.setup(), + component, + }; + } + + test('example', async () => { + const { user } = renderWithUser(); + await user.type(input, 'text'); + }); + `, + }, + // All valid methods with setup (skip 'click' as it's already tested above) + ...USER_EVENT_METHODS.filter((m) => m !== 'click' && m !== 'type').map( + (method) => ({ + code: ` + import userEvent from '@testing-library/user-event'; + + test('example', async () => { + const user = userEvent.setup(); + await user.${method}(element); + }); + `, + }) + ), + // No userEvent import + { + code: ` + test('example', () => { + fireEvent.click(element); + }); + `, + }, + // userEvent aliased + { + code: ` + import userEventLib from '@testing-library/user-event'; + + test('example', async () => { + const user = userEventLib.setup(); + await user.click(element); + }); + `, + }, + // Named import (if supported) + { + code: ` + import { userEvent } from '@testing-library/user-event'; + + test('example', async () => { + const user = userEvent.setup(); + await user.click(element); + }); + `, + }, + ], + + invalid: [ + // Direct userEvent method calls + { + code: ` + import userEvent from '@testing-library/user-event'; + + test('example', async () => { + await userEvent.click(element); + }); + `, + errors: [ + { + line: 5, + column: 12, + messageId: 'preferUserEventSetup', + data: { method: 'click' }, + }, + ], + }, + { + code: ` + import userEvent from '@testing-library/user-event'; + + test('example', async () => { + await userEvent.type(input, 'hello'); + }); + `, + errors: [ + { + line: 5, + column: 12, + messageId: 'preferUserEventSetup', + data: { method: 'type' }, + }, + ], + }, + // Multiple direct calls + { + code: ` + import userEvent from '@testing-library/user-event'; + + test('example', async () => { + await userEvent.type(input, 'hello'); + await userEvent.click(button); + }); + `, + errors: [ + { + line: 5, + column: 12, + messageId: 'preferUserEventSetup', + data: { method: 'type' }, + }, + { + line: 6, + column: 12, + messageId: 'preferUserEventSetup', + data: { method: 'click' }, + }, + ], + }, + // All methods should error when called directly (skip those already tested) + ...USER_EVENT_METHODS.filter((m) => m !== 'click' && m !== 'type').map( + (method) => ({ + code: ` + import userEvent from '@testing-library/user-event'; + + test('example', async () => { + await userEvent.${method}(element); + }); + `, + errors: [ + { + line: 5, + column: 12, + messageId: 'preferUserEventSetup' as MessageIds, + data: { method }, + }, + ], + }) + ), + // Aliased userEvent + { + code: ` + import userEventLib from '@testing-library/user-event'; + + test('example', async () => { + await userEventLib.click(element); + }); + `, + errors: [ + { + line: 5, + column: 12, + messageId: 'preferUserEventSetup', + data: { method: 'click' }, + }, + ], + }, + // Mixed correct and incorrect usage + { + code: ` + import userEvent from '@testing-library/user-event'; + + test('example', async () => { + const user = userEvent.setup(); + await user.click(button1); + await userEvent.type(input, 'hello'); // This should error + }); + `, + errors: [ + { + line: 7, + column: 12, + messageId: 'preferUserEventSetup', + data: { method: 'type' }, + }, + ], + }, + // Named import with direct call + { + code: ` + import { userEvent } from '@testing-library/user-event'; + + test('example', async () => { + await userEvent.click(element); + }); + `, + errors: [ + { + line: 5, + column: 12, + messageId: 'preferUserEventSetup', + data: { method: 'click' }, + }, + ], + }, + // userEvent.setup() called but not used + { + code: ` + import userEvent from '@testing-library/user-event'; + + test('example', async () => { + userEvent.setup(); // setup called but result not used + await userEvent.click(element); + }); + `, + errors: [ + { + line: 6, + column: 12, + messageId: 'preferUserEventSetup', + data: { method: 'click' }, + }, + ], + }, + // Direct calls in different scopes + { + code: ` + import userEvent from '@testing-library/user-event'; + + describe('suite', () => { + test('test 1', async () => { + await userEvent.click(element); + }); + + test('test 2', async () => { + const user = userEvent.setup(); + await user.type(input, 'hello'); // This is correct + await userEvent.dblClick(element); // This should error + }); + }); + `, + errors: [ + { + line: 6, + column: 13, + messageId: 'preferUserEventSetup', + data: { method: 'click' }, + }, + { + line: 12, + column: 13, + messageId: 'preferUserEventSetup', + data: { method: 'dblClick' }, + }, + ], + }, + ], +});