From 64c97b95e856d4c73b4e9d4b521ba221281d15af Mon Sep 17 00:00:00 2001 From: Atsushi Ishida Date: Wed, 10 Dec 2025 09:14:30 +0900 Subject: [PATCH 01/10] feat(prefer-user-event-setup): adding new rule --- docs/rules/prefer-user-event-setup.md | 118 +++++++ lib/rules/prefer-user-event-setup.ts | 221 +++++++++++++ .../lib/rules/prefer-user-event-setup.test.ts | 311 ++++++++++++++++++ 3 files changed, 650 insertions(+) create mode 100644 docs/rules/prefer-user-event-setup.md create mode 100644 lib/rules/prefer-user-event-setup.ts create mode 100644 tests/lib/rules/prefer-user-event-setup.test.ts diff --git a/docs/rules/prefer-user-event-setup.md b/docs/rules/prefer-user-event-setup.md new file mode 100644 index 00000000..3bf1ac19 --- /dev/null +++ b/docs/rules/prefer-user-event-setup.md @@ -0,0 +1,118 @@ +# Suggest using userEvent with setup() instead of direct methods (`testing-library/prefer-user-event-setup`) + + + +## Rule Details + +This rule enforces 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 + +If you're using an older version of user-event (< v14) that doesn't support or require the setup pattern, you may want to disable this rule. + +## 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/prefer-user-event-setup.ts b/lib/rules/prefer-user-event-setup.ts new file mode 100644 index 00000000..e50c8227 --- /dev/null +++ b/lib/rules/prefer-user-event-setup.ts @@ -0,0 +1,221 @@ +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'; + +// All userEvent methods that should use setup() +const USER_EVENT_METHODS = [ + 'clear', + 'click', + 'copy', + 'cut', + 'dblClick', + 'deselectOptions', + 'hover', + 'keyboard', + 'pointer', + 'paste', + 'selectOptions', + 'tripleClick', + 'type', + 'unhover', + 'upload', + 'tab', +] as const; + +export default createTestingLibraryRule({ + name: RULE_NAME, + meta: { + type: 'problem', + 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) { + // 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 + ); + } + + function isDirectUserEventMethodCall( + node: TSESTree.MemberExpression + ): boolean { + return ( + node.object.type === AST_NODE_TYPES.Identifier && + node.object.name === userEventIdentifier && + node.property.type === AST_NODE_TYPES.Identifier && + USER_EVENT_METHODS.includes( + node.property.name as (typeof USER_EVENT_METHODS)[number] + ) + ); + } + + 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 && + isDirectUserEventMethodCall(node.callee) + ) { + const methodName = (node.callee.property as TSESTree.Identifier).name; + + // 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/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..f2b4d79e --- /dev/null +++ b/tests/lib/rules/prefer-user-event-setup.test.ts @@ -0,0 +1,311 @@ +import rule, { RULE_NAME } from '../../../lib/rules/prefer-user-event-setup'; +import { createRuleTester } from '../test-utils'; + +import type { MessageIds } from '../../../lib/rules/prefer-user-event-setup'; + +const ruleTester = createRuleTester(); + +const USER_EVENT_METHODS = [ + 'clear', + 'click', + 'copy', + 'cut', + 'dblClick', + 'deselectOptions', + 'hover', + 'keyboard', + 'pointer', + 'paste', + 'selectOptions', + 'tripleClick', + 'type', + 'unhover', + 'upload', + 'tab', +]; + +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: [ + { + messageId: 'preferUserEventSetup', + data: { method: 'click' }, + }, + ], + }, + { + code: ` + import userEvent from '@testing-library/user-event'; + + test('example', async () => { + await userEvent.type(input, 'hello'); + }); + `, + errors: [ + { + 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: [ + { + messageId: 'preferUserEventSetup', + data: { method: 'type' }, + }, + { + 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: [ + { + messageId: 'preferUserEventSetup' as MessageIds, + data: { method }, + }, + ], + }) + ), + // Aliased userEvent + { + code: ` + import userEventLib from '@testing-library/user-event'; + + test('example', async () => { + await userEventLib.click(element); + }); + `, + errors: [ + { + 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: [ + { + 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: [ + { + 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: [ + { + 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: [ + { + messageId: 'preferUserEventSetup', + data: { method: 'click' }, + }, + { + messageId: 'preferUserEventSetup', + data: { method: 'dblClick' }, + }, + ], + }, + ], +}); From fca0cb45631565a3701fb0b304798432f600e42f Mon Sep 17 00:00:00 2001 From: Atsushi Ishida Date: Wed, 10 Dec 2025 09:23:13 +0900 Subject: [PATCH 02/10] feat(prefer-user-event-setup): add prefer-user-event-setup rule Enforces using userEvent.setup() pattern instead of direct method calls as recommended in the official user-event documentation. --- README.md | 1 + 1 file changed, 1 insertion(+) 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][] | | | From f89ad18496f60603828d6a61df291e1b7fb23c02 Mon Sep 17 00:00:00 2001 From: Atsushi Ishida Date: Wed, 10 Dec 2025 09:42:09 +0900 Subject: [PATCH 03/10] fix(prefer-user-event-setup): docs fixes --- docs/rules/prefer-user-event-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rules/prefer-user-event-setup.md b/docs/rules/prefer-user-event-setup.md index 3bf1ac19..749152a9 100644 --- a/docs/rules/prefer-user-event-setup.md +++ b/docs/rules/prefer-user-event-setup.md @@ -4,7 +4,7 @@ ## Rule Details -This rule enforces 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. +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: From c3b59d565a7cb35f7e583ff070d0a5043e07bbdf Mon Sep 17 00:00:00 2001 From: Atsushi Ishida Date: Wed, 10 Dec 2025 09:55:25 +0900 Subject: [PATCH 04/10] fix(prefer-user-event-setup): fix meta.type --- lib/rules/prefer-user-event-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rules/prefer-user-event-setup.ts b/lib/rules/prefer-user-event-setup.ts index e50c8227..451e290c 100644 --- a/lib/rules/prefer-user-event-setup.ts +++ b/lib/rules/prefer-user-event-setup.ts @@ -36,7 +36,7 @@ const USER_EVENT_METHODS = [ export default createTestingLibraryRule({ name: RULE_NAME, meta: { - type: 'problem', + type: 'suggestion', docs: { description: 'Suggest using userEvent with setup() instead of direct methods', From 1f873f19c53ca82d739acf306e58b692e9bdc8de Mon Sep 17 00:00:00 2001 From: Atsushi Ishida Date: Wed, 10 Dec 2025 18:31:06 +0900 Subject: [PATCH 05/10] test: update number of rules to 29 for prefer-user-event-setup rule --- tests/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/index.test.ts b/tests/index.test.ts index a5f4fcdc..93040c10 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -3,7 +3,7 @@ import { resolve } from 'path'; import plugin from '../lib'; -const numberOfRules = 28; +const numberOfRules = 29; const ruleNames = Object.keys(plugin.rules); // eslint-disable-next-line jest/expect-expect From f75b0f8787556ef9f1821e42871a8eccdd57bfab Mon Sep 17 00:00:00 2001 From: Atsushi Ishida Date: Mon, 15 Dec 2025 01:18:32 +0900 Subject: [PATCH 06/10] refactor(prefer-user-event-setup): reuse isUserEventMethod helper --- lib/rules/prefer-user-event-setup.ts | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/lib/rules/prefer-user-event-setup.ts b/lib/rules/prefer-user-event-setup.ts index 451e290c..1212ea84 100644 --- a/lib/rules/prefer-user-event-setup.ts +++ b/lib/rules/prefer-user-event-setup.ts @@ -57,7 +57,7 @@ export default createTestingLibraryRule({ }, defaultOptions: [], - create(context) { + create(context, options, helpers) { // Track variables assigned from userEvent.setup() const userEventSetupVars = new Set(); @@ -78,19 +78,6 @@ export default createTestingLibraryRule({ ); } - function isDirectUserEventMethodCall( - node: TSESTree.MemberExpression - ): boolean { - return ( - node.object.type === AST_NODE_TYPES.Identifier && - node.object.name === userEventIdentifier && - node.property.type === AST_NODE_TYPES.Identifier && - USER_EVENT_METHODS.includes( - node.property.name as (typeof USER_EVENT_METHODS)[number] - ) - ); - } - return { // Track userEvent imports ImportDeclaration(node: TSESTree.ImportDeclaration) { @@ -196,9 +183,15 @@ export default createTestingLibraryRule({ if ( node.callee.type === AST_NODE_TYPES.MemberExpression && - isDirectUserEventMethodCall(node.callee) + node.callee.property.type === AST_NODE_TYPES.Identifier && + helpers.isUserEventMethod(node.callee.property) ) { - const methodName = (node.callee.property as TSESTree.Identifier).name; + 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 = From 49c67549d0f0b399c7cfc55d6bef0c767c292efd Mon Sep 17 00:00:00 2001 From: Atsushi Ishida Date: Mon, 15 Dec 2025 01:33:56 +0900 Subject: [PATCH 07/10] refactor(prefer-user-event-setup): reuse isUserEventMethod helper --- lib/rules/prefer-user-event-setup.ts | 20 ------------------- lib/utils/index.ts | 20 +++++++++++++++++++ tests/lib/rules/await-async-events.test.ts | 20 ++----------------- .../lib/rules/prefer-user-event-setup.test.ts | 20 +------------------ 4 files changed, 23 insertions(+), 57 deletions(-) diff --git a/lib/rules/prefer-user-event-setup.ts b/lib/rules/prefer-user-event-setup.ts index 1212ea84..41822836 100644 --- a/lib/rules/prefer-user-event-setup.ts +++ b/lib/rules/prefer-user-event-setup.ts @@ -13,26 +13,6 @@ const USER_EVENT_PACKAGE = '@testing-library/user-event'; const USER_EVENT_NAME = 'userEvent'; const SETUP_METHOD_NAME = 'setup'; -// All userEvent methods that should use setup() -const USER_EVENT_METHODS = [ - 'clear', - 'click', - 'copy', - 'cut', - 'dblClick', - 'deselectOptions', - 'hover', - 'keyboard', - 'pointer', - 'paste', - 'selectOptions', - 'tripleClick', - 'type', - 'unhover', - 'upload', - 'tab', -] as const; - export default createTestingLibraryRule({ name: RULE_NAME, meta: { diff --git a/lib/utils/index.ts b/lib/utils/index.ts index faa97c51..9b2a05a3 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -125,6 +125,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, @@ -159,6 +178,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 index f2b4d79e..d086968b 100644 --- a/tests/lib/rules/prefer-user-event-setup.test.ts +++ b/tests/lib/rules/prefer-user-event-setup.test.ts @@ -1,29 +1,11 @@ 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(); -const USER_EVENT_METHODS = [ - 'clear', - 'click', - 'copy', - 'cut', - 'dblClick', - 'deselectOptions', - 'hover', - 'keyboard', - 'pointer', - 'paste', - 'selectOptions', - 'tripleClick', - 'type', - 'unhover', - 'upload', - 'tab', -]; - ruleTester.run(RULE_NAME, rule, { valid: [ // Using userEvent.setup() correctly From 3aa148a21a6888ee1ba94945dc2ae32301d1dcb9 Mon Sep 17 00:00:00 2001 From: Atsushi Ishida Date: Mon, 15 Dec 2025 01:42:29 +0900 Subject: [PATCH 08/10] test(prefer-user-event-setup): add line and column to error assertions --- .../lib/rules/prefer-user-event-setup.test.ts | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/tests/lib/rules/prefer-user-event-setup.test.ts b/tests/lib/rules/prefer-user-event-setup.test.ts index d086968b..1a50b40c 100644 --- a/tests/lib/rules/prefer-user-event-setup.test.ts +++ b/tests/lib/rules/prefer-user-event-setup.test.ts @@ -128,13 +128,15 @@ ruleTester.run(RULE_NAME, rule, { { 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' }, }, @@ -143,13 +145,15 @@ ruleTester.run(RULE_NAME, rule, { { 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' }, }, @@ -159,7 +163,7 @@ ruleTester.run(RULE_NAME, rule, { { code: ` import userEvent from '@testing-library/user-event'; - + test('example', async () => { await userEvent.type(input, 'hello'); await userEvent.click(button); @@ -167,10 +171,14 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [ { + line: 5, + column: 12, messageId: 'preferUserEventSetup', data: { method: 'type' }, }, { + line: 6, + column: 12, messageId: 'preferUserEventSetup', data: { method: 'click' }, }, @@ -181,13 +189,15 @@ ruleTester.run(RULE_NAME, rule, { (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 }, }, @@ -198,13 +208,15 @@ ruleTester.run(RULE_NAME, rule, { { 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' }, }, @@ -214,7 +226,7 @@ ruleTester.run(RULE_NAME, rule, { { code: ` import userEvent from '@testing-library/user-event'; - + test('example', async () => { const user = userEvent.setup(); await user.click(button1); @@ -223,6 +235,8 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [ { + line: 7, + column: 12, messageId: 'preferUserEventSetup', data: { method: 'type' }, }, @@ -232,13 +246,15 @@ ruleTester.run(RULE_NAME, rule, { { 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' }, }, @@ -248,7 +264,7 @@ ruleTester.run(RULE_NAME, rule, { { code: ` import userEvent from '@testing-library/user-event'; - + test('example', async () => { userEvent.setup(); // setup called but result not used await userEvent.click(element); @@ -256,6 +272,8 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [ { + line: 6, + column: 12, messageId: 'preferUserEventSetup', data: { method: 'click' }, }, @@ -265,12 +283,12 @@ ruleTester.run(RULE_NAME, rule, { { 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 @@ -280,10 +298,14 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [ { + line: 6, + column: 13, messageId: 'preferUserEventSetup', data: { method: 'click' }, }, { + line: 12, + column: 13, messageId: 'preferUserEventSetup', data: { method: 'dblClick' }, }, From 73c7e683e1602594a81b13860cadb0241a8b5831 Mon Sep 17 00:00:00 2001 From: Atsushi Ishida Date: Mon, 15 Dec 2025 01:49:53 +0900 Subject: [PATCH 09/10] docs(prefer-user-event-setup): add when not to use case --- docs/rules/prefer-user-event-setup.md | 32 ++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/rules/prefer-user-event-setup.md b/docs/rules/prefer-user-event-setup.md index 749152a9..aa6f74a4 100644 --- a/docs/rules/prefer-user-event-setup.md +++ b/docs/rules/prefer-user-event-setup.md @@ -110,7 +110,37 @@ test('using custom render', async () => { ## When Not To Use This Rule -If you're using an older version of user-event (< v14) that doesn't support or require the setup pattern, you may want to disable 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 From 8510411d2fd094cbe418a569e8a122341f9fe437 Mon Sep 17 00:00:00 2001 From: Atsushi Ishida Date: Mon, 15 Dec 2025 08:47:44 +0900 Subject: [PATCH 10/10] feat(prefer-user-event-setup): export rule from index --- lib/rules/index.ts | 2 ++ 1 file changed, 2 insertions(+) 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, };