Skip to content

Commit 03f8736

Browse files
authored
feat(prefer-user-event-setup): add new rule (#1125)
Closes #646
1 parent 3cc095b commit 03f8736

File tree

7 files changed

+682
-18
lines changed

7 files changed

+682
-18
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ module.exports = [
351351
| [prefer-query-matchers](docs/rules/prefer-query-matchers.md) | Ensure the configured `get*`/`query*` query is used with the corresponding matchers | | | |
352352
| [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][] | | |
353353
| [prefer-user-event](docs/rules/prefer-user-event.md) | Suggest using `userEvent` over `fireEvent` for simulating user interactions | | | |
354+
| [prefer-user-event-setup](docs/rules/prefer-user-event-setup.md) | Suggest using userEvent with setup() instead of direct methods | | | |
354355
| [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][] | | |
355356

356357
<!-- end auto-generated rules list -->
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Suggest using userEvent with setup() instead of direct methods (`testing-library/prefer-user-event-setup`)
2+
3+
<!-- end auto-generated rule header -->
4+
5+
## Rule Details
6+
7+
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.
8+
9+
Using `userEvent.setup()` provides several benefits:
10+
11+
- Ensures proper initialization of the user-event system
12+
- Better reflects real user interactions with proper event sequencing
13+
- Provides consistent timing behavior across different environments
14+
- Allows configuration of delays and other options
15+
16+
### Why Use setup()?
17+
18+
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.
19+
20+
## Examples
21+
22+
Examples of **incorrect** code for this rule:
23+
24+
```js
25+
import userEvent from '@testing-library/user-event';
26+
import { render, screen } from '@testing-library/react';
27+
28+
test('clicking a button', async () => {
29+
render(<MyComponent />);
30+
// ❌ Direct call without setup()
31+
await userEvent.click(screen.getByRole('button'));
32+
});
33+
34+
test('typing in input', async () => {
35+
render(<MyComponent />);
36+
// ❌ Direct call without setup()
37+
await userEvent.type(screen.getByRole('textbox'), 'Hello');
38+
});
39+
40+
test('multiple interactions', async () => {
41+
render(<MyComponent />);
42+
// ❌ Multiple direct calls
43+
await userEvent.type(screen.getByRole('textbox'), 'Hello');
44+
await userEvent.click(screen.getByRole('button'));
45+
});
46+
```
47+
48+
Examples of **correct** code for this rule:
49+
50+
```js
51+
import userEvent from '@testing-library/user-event';
52+
import { render, screen } from '@testing-library/react';
53+
54+
test('clicking a button', async () => {
55+
// ✅ Create user instance with setup()
56+
const user = userEvent.setup();
57+
render(<MyComponent />);
58+
await user.click(screen.getByRole('button'));
59+
});
60+
61+
test('typing in input', async () => {
62+
// ✅ Create user instance with setup()
63+
const user = userEvent.setup();
64+
render(<MyComponent />);
65+
await user.type(screen.getByRole('textbox'), 'Hello');
66+
});
67+
68+
test('multiple interactions', async () => {
69+
// ✅ Use the same user instance for all interactions
70+
const user = userEvent.setup();
71+
render(<MyComponent />);
72+
await user.type(screen.getByRole('textbox'), 'Hello');
73+
await user.click(screen.getByRole('button'));
74+
});
75+
76+
// ✅ Using a setup function pattern
77+
function setup(jsx) {
78+
return {
79+
user: userEvent.setup(),
80+
...render(jsx),
81+
};
82+
}
83+
84+
test('with custom setup function', async () => {
85+
const { user, getByRole } = setup(<MyComponent />);
86+
await user.click(getByRole('button'));
87+
});
88+
```
89+
90+
### Custom Render Functions
91+
92+
A common pattern is to create a custom render function that includes the user-event setup:
93+
94+
```js
95+
import userEvent from '@testing-library/user-event';
96+
import { render } from '@testing-library/react';
97+
98+
function renderWithUser(ui, options) {
99+
return {
100+
user: userEvent.setup(),
101+
...render(ui, options),
102+
};
103+
}
104+
105+
test('using custom render', async () => {
106+
const { user, getByRole } = renderWithUser(<MyComponent />);
107+
await user.click(getByRole('button'));
108+
});
109+
```
110+
111+
## When Not To Use This Rule
112+
113+
You may want to disable this rule in the following situations:
114+
115+
### Using older user-event versions
116+
117+
If you're using an older version of user-event (< v14) that doesn't support or require the setup pattern.
118+
119+
### Custom render functions in external files
120+
121+
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.
122+
123+
For example:
124+
125+
```js
126+
// test-utils.js
127+
export function renderWithUser(ui) {
128+
return {
129+
user: userEvent.setup(), // setup() called here
130+
...render(ui),
131+
};
132+
}
133+
134+
// MyComponent.test.js
135+
import { renderWithUser } from './test-utils';
136+
137+
test('example', async () => {
138+
const { user } = renderWithUser(<MyComponent />);
139+
await user.click(...); // ✅ This is correct, but the rule cannot detect it
140+
});
141+
```
142+
143+
In this case, you should disable the rule for your project since it cannot track setup calls across files.
144+
145+
## Further Reading
146+
147+
- [user-event documentation - Writing tests with userEvent](https://testing-library.com/docs/user-event/intro/#writing-tests-with-userevent)
148+
- [user-event setup() API](https://testing-library.com/docs/user-event/setup)

lib/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import preferQueryByDisappearance from './prefer-query-by-disappearance';
2525
import preferQueryMatchers from './prefer-query-matchers';
2626
import preferScreenQueries from './prefer-screen-queries';
2727
import preferUserEvent from './prefer-user-event';
28+
import preferUserEventSetup from './prefer-user-event-setup';
2829
import renderResultNamingConvention from './render-result-naming-convention';
2930

3031
export default {
@@ -55,5 +56,6 @@ export default {
5556
'prefer-query-matchers': preferQueryMatchers,
5657
'prefer-screen-queries': preferScreenQueries,
5758
'prefer-user-event': preferUserEvent,
59+
'prefer-user-event-setup': preferUserEventSetup,
5860
'render-result-naming-convention': renderResultNamingConvention,
5961
};
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2+
3+
import { createTestingLibraryRule } from '../create-testing-library-rule';
4+
5+
import type { TSESTree } from '@typescript-eslint/utils';
6+
7+
export const RULE_NAME = 'prefer-user-event-setup';
8+
9+
export type MessageIds = 'preferUserEventSetup';
10+
export type Options = [];
11+
12+
const USER_EVENT_PACKAGE = '@testing-library/user-event';
13+
const USER_EVENT_NAME = 'userEvent';
14+
const SETUP_METHOD_NAME = 'setup';
15+
16+
export default createTestingLibraryRule<Options, MessageIds>({
17+
name: RULE_NAME,
18+
meta: {
19+
type: 'suggestion',
20+
docs: {
21+
description:
22+
'Suggest using userEvent with setup() instead of direct methods',
23+
recommendedConfig: {
24+
dom: false,
25+
angular: false,
26+
react: false,
27+
vue: false,
28+
svelte: false,
29+
marko: false,
30+
},
31+
},
32+
messages: {
33+
preferUserEventSetup:
34+
'Prefer using userEvent with setup() instead of direct {{method}}() call. Use: const user = userEvent.setup(); await user.{{method}}(...)',
35+
},
36+
schema: [],
37+
},
38+
defaultOptions: [],
39+
40+
create(context, options, helpers) {
41+
// Track variables assigned from userEvent.setup()
42+
const userEventSetupVars = new Set<string>();
43+
44+
// Track functions that return userEvent.setup() instances
45+
const setupFunctions = new Map<string, Set<string>>();
46+
47+
// Track imported userEvent identifier (could be aliased)
48+
let userEventIdentifier: string | null = null;
49+
50+
function isUserEventSetupCall(node: TSESTree.Node): boolean {
51+
return (
52+
node.type === AST_NODE_TYPES.CallExpression &&
53+
node.callee.type === AST_NODE_TYPES.MemberExpression &&
54+
node.callee.object.type === AST_NODE_TYPES.Identifier &&
55+
node.callee.object.name === userEventIdentifier &&
56+
node.callee.property.type === AST_NODE_TYPES.Identifier &&
57+
node.callee.property.name === SETUP_METHOD_NAME
58+
);
59+
}
60+
61+
return {
62+
// Track userEvent imports
63+
ImportDeclaration(node: TSESTree.ImportDeclaration) {
64+
if (node.source.value === USER_EVENT_PACKAGE) {
65+
// Default import: import userEvent from '@testing-library/user-event'
66+
const defaultImport = node.specifiers.find(
67+
(spec) => spec.type === AST_NODE_TYPES.ImportDefaultSpecifier
68+
);
69+
if (defaultImport) {
70+
userEventIdentifier = defaultImport.local.name;
71+
}
72+
73+
// Named import: import { userEvent } from '@testing-library/user-event'
74+
const namedImport = node.specifiers.find(
75+
(spec) =>
76+
spec.type === AST_NODE_TYPES.ImportSpecifier &&
77+
spec.imported.type === AST_NODE_TYPES.Identifier &&
78+
spec.imported.name === USER_EVENT_NAME
79+
);
80+
if (
81+
namedImport &&
82+
namedImport.type === AST_NODE_TYPES.ImportSpecifier
83+
) {
84+
userEventIdentifier = namedImport.local.name;
85+
}
86+
}
87+
},
88+
89+
// Track variables assigned from userEvent.setup()
90+
VariableDeclarator(node: TSESTree.VariableDeclarator) {
91+
if (!userEventIdentifier || !node.init) return;
92+
93+
// Direct assignment: const user = userEvent.setup()
94+
if (
95+
isUserEventSetupCall(node.init) &&
96+
node.id.type === AST_NODE_TYPES.Identifier
97+
) {
98+
userEventSetupVars.add(node.id.name);
99+
}
100+
101+
// Destructuring from a setup function
102+
if (
103+
node.id.type === AST_NODE_TYPES.ObjectPattern &&
104+
node.init.type === AST_NODE_TYPES.CallExpression &&
105+
node.init.callee.type === AST_NODE_TYPES.Identifier
106+
) {
107+
const functionName = node.init.callee.name;
108+
const setupProps = setupFunctions.get(functionName);
109+
110+
if (setupProps) {
111+
for (const prop of node.id.properties) {
112+
if (
113+
prop.type === AST_NODE_TYPES.Property &&
114+
prop.key.type === AST_NODE_TYPES.Identifier &&
115+
setupProps.has(prop.key.name) &&
116+
prop.value.type === AST_NODE_TYPES.Identifier
117+
) {
118+
userEventSetupVars.add(prop.value.name);
119+
}
120+
}
121+
}
122+
}
123+
},
124+
125+
// Track functions that return objects with userEvent.setup()
126+
// Note: This simplified implementation only checks direct return statements
127+
// in the function body, not nested functions or complex flows
128+
FunctionDeclaration(node: TSESTree.FunctionDeclaration) {
129+
if (!userEventIdentifier || !node.id) return;
130+
131+
// For simplicity, only check direct return statements in the function body
132+
if (node.body && node.body.type === AST_NODE_TYPES.BlockStatement) {
133+
for (const statement of node.body.body) {
134+
if (statement.type === AST_NODE_TYPES.ReturnStatement) {
135+
const ret = statement;
136+
if (
137+
ret.argument &&
138+
ret.argument.type === AST_NODE_TYPES.ObjectExpression
139+
) {
140+
const props = new Set<string>();
141+
for (const prop of ret.argument.properties) {
142+
if (
143+
prop.type === AST_NODE_TYPES.Property &&
144+
prop.key.type === AST_NODE_TYPES.Identifier &&
145+
prop.value &&
146+
isUserEventSetupCall(prop.value)
147+
) {
148+
props.add(prop.key.name);
149+
}
150+
}
151+
if (props.size > 0) {
152+
setupFunctions.set(node.id.name, props);
153+
}
154+
}
155+
}
156+
}
157+
}
158+
},
159+
160+
// Check for direct userEvent method calls
161+
CallExpression(node: TSESTree.CallExpression) {
162+
if (!userEventIdentifier) return;
163+
164+
if (
165+
node.callee.type === AST_NODE_TYPES.MemberExpression &&
166+
node.callee.property.type === AST_NODE_TYPES.Identifier &&
167+
helpers.isUserEventMethod(node.callee.property)
168+
) {
169+
const methodName = node.callee.property.name;
170+
171+
// Exclude setup() method
172+
if (methodName === SETUP_METHOD_NAME) {
173+
return;
174+
}
175+
176+
// Check if this is called on a setup instance
177+
const isSetupInstance =
178+
node.callee.object.type === AST_NODE_TYPES.Identifier &&
179+
userEventSetupVars.has(node.callee.object.name);
180+
181+
if (!isSetupInstance) {
182+
context.report({
183+
node: node.callee,
184+
messageId: 'preferUserEventSetup',
185+
data: {
186+
method: methodName,
187+
},
188+
});
189+
}
190+
}
191+
},
192+
};
193+
},
194+
});

0 commit comments

Comments
 (0)