Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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][] | | |

<!-- end auto-generated rules list -->
Expand Down
118 changes: 118 additions & 0 deletions docs/rules/prefer-user-event-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# 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

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)
221 changes: 221 additions & 0 deletions lib/rules/prefer-user-event-setup.ts
Original file line number Diff line number Diff line change
@@ -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<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) {
// 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
);
}

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<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 &&
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,
},
});
}
}
},
};
},
});
2 changes: 1 addition & 1 deletion tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading