-
Notifications
You must be signed in to change notification settings - Fork 154
feat(prefer-user-event-setup): add new rule #1125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Belco90
merged 11 commits into
testing-library:main
from
gipcompany:pr/prefer-user-event-setup-rule
Dec 15, 2025
+682
−18
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
64c97b9
feat(prefer-user-event-setup): adding new rule
gipcompany fca0cb4
feat(prefer-user-event-setup): add prefer-user-event-setup rule
gipcompany f89ad18
fix(prefer-user-event-setup): docs fixes
gipcompany c3b59d5
fix(prefer-user-event-setup): fix meta.type
gipcompany 1f873f1
test: update number of rules to 29 for prefer-user-event-setup rule
gipcompany f75b0f8
refactor(prefer-user-event-setup): reuse isUserEventMethod helper
gipcompany 49c6754
refactor(prefer-user-event-setup): reuse isUserEventMethod helper
gipcompany 3aa148a
test(prefer-user-event-setup): add line and column to error assertions
gipcompany 73c7e68
docs(prefer-user-event-setup): add when not to use case
gipcompany 4566b3f
Merge branch 'main' into pr/prefer-user-event-setup-rule
gipcompany 8510411
feat(prefer-user-event-setup): export rule from index
gipcompany File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| # Suggest using userEvent with setup() instead of direct methods (`testing-library/prefer-user-event-setup`) | ||
|
|
||
| <!-- end auto-generated rule header --> | ||
|
|
||
| ## 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(<MyComponent />); | ||
| // ❌ Direct call without setup() | ||
| await userEvent.click(screen.getByRole('button')); | ||
| }); | ||
|
|
||
| test('typing in input', async () => { | ||
| render(<MyComponent />); | ||
| // ❌ Direct call without setup() | ||
| await userEvent.type(screen.getByRole('textbox'), 'Hello'); | ||
| }); | ||
|
|
||
| test('multiple interactions', async () => { | ||
| render(<MyComponent />); | ||
| // ❌ 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(<MyComponent />); | ||
| await user.click(screen.getByRole('button')); | ||
| }); | ||
|
|
||
| test('typing in input', async () => { | ||
| // ✅ Create user instance with setup() | ||
| const user = userEvent.setup(); | ||
| render(<MyComponent />); | ||
| await user.type(screen.getByRole('textbox'), 'Hello'); | ||
| }); | ||
|
|
||
| test('multiple interactions', async () => { | ||
| // ✅ Use the same user instance for all interactions | ||
| const user = userEvent.setup(); | ||
| render(<MyComponent />); | ||
| 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(<MyComponent />); | ||
| 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(<MyComponent />); | ||
| 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(<MyComponent />); | ||
| 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Options, MessageIds>({ | ||
| 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<string>(); | ||
|
|
||
| // Track functions that return userEvent.setup() instances | ||
| const setupFunctions = new Map<string, Set<string>>(); | ||
|
|
||
| // 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) && | ||
Belco90 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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<string>(); | ||
| 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, | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.