Skip to content

Commit d9f8aa4

Browse files
committed
CONSOLE-5176: Refactor RTL Tests from fireEvent to userEvent
1 parent f9ad621 commit d9f8aa4

File tree

58 files changed

+1010
-575
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+1010
-575
lines changed

.claude/skills/gen-rtl-test/SKILL.md

Lines changed: 100 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ RTL emphasizes testing components as users interact with them. Users find button
5959

6060
4. **DRY Helpers** - Use reusable function in frontend/packages/console-shared/src/test-utils directoty and sub-directory if exists else extract repetitive setup into reusable functions
6161

62-
5. **Async-Aware** - Handle asynchronous updates with `findBy*` and `waitFor`
62+
5. **Async-Aware** - Prefer `findByText` / `findByRole` / `findAllByRole` when waiting for content to **appear**; use `waitFor` when the assertion is **not** expressible as a query (e.g. button **disabled** after validation, **absence**, class names, mocks). See **Rule 11**.
6363

6464
6. **TypeScript Safety** - Use proper types for props, state, and mock data
6565

@@ -360,7 +360,7 @@ it('should find the heading', () => {
360360
**Query Variants:**
361361
- **`getBy*`** - Element expected to be present synchronously (throws if not found)
362362
- **`queryBy*`** - Only for asserting element is NOT present
363-
- **`findBy*`** - Element will appear asynchronously (returns Promise)
363+
- **`findBy*`** - Element will appear asynchronously (returns Promise). Prefer **`findBy*`** over **`waitFor(() => … getBy* …)`** when the goal is to wait for that node to **appear** (see **Rule 11**).
364364

365365
**Anti-pattern:** Avoid `container.querySelector` - it tests implementation details.
366366

@@ -456,7 +456,8 @@ it('should render the Name field', async () => {
456456
// Don't do this - use verifyInputField instead!
457457
expect(screen.getByLabelText('Name')).toBeInTheDocument();
458458
expect(screen.getByLabelText('Name')).toHaveValue('');
459-
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'test' } });
459+
const user = userEvent.setup();
460+
await user.type(screen.getByLabelText('Name'), 'test');
460461
expect(screen.getByLabelText('Name')).toHaveValue('test');
461462
expect(screen.getByText('Unique name for the resource')).toBeInTheDocument();
462463
});
@@ -482,14 +483,15 @@ When testing form components:
482483
### Rule 10: Test Conditional Rendering by Asserting Both States
483484

484485
```typescript
485-
it('should show content when expanded', () => {
486+
it('should show content when expanded', async () => {
487+
const user = userEvent.setup();
486488
render(<Collapsible />);
487489

488490
// 1. Assert initial hidden state
489491
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument();
490492

491493
// 2. Simulate user action
492-
fireEvent.click(screen.getByRole('button', { name: 'Expand' }));
494+
await user.click(screen.getByRole('button', { name: 'Expand' }));
493495

494496
// 3. Assert final visible state
495497
expect(screen.getByText('Hidden content')).toBeVisible();
@@ -498,18 +500,54 @@ it('should show content when expanded', () => {
498500

499501
### Rule 11: Handle Asynchronous Behavior
500502

503+
#### Prefer `findBy*` over `waitFor` + `getBy*` when waiting for content to appear
504+
505+
`findByText`, `findByRole`, `findAllByRole`, etc. are implemented as **`waitFor` + `getBy*`** (they return a Promise and retry until the element is found or the timeout is reached). When the intent is **“wait until this text/role appears in the DOM,”** use **`await screen.findBy…`** instead of wrapping **`getBy…`** in **`waitFor`**.
506+
501507
```typescript
502-
// Use findBy* to wait for an element to appear
503-
const element = await screen.findByText('Loaded content');
504-
expect(element).toBeVisible();
508+
// ✅ GOOD: findBy* expresses “eventually this appears”
509+
expect(await screen.findByText('Loaded content')).toBeVisible();
510+
const row = await screen.findByRole('row', { name: /my-resource/i });
511+
expect(await screen.findAllByRole('button', { name: 'Remove' })).toHaveLength(2);
505512

506-
// Use waitFor for complex assertions
513+
// ⚠️ EQUIVALENT but more verbose (prefer findBy* above)
507514
await waitFor(() => {
508-
expect(screen.getByText('Updated')).toBeInTheDocument();
515+
expect(screen.getByText('Loaded content')).toBeInTheDocument();
509516
});
510517
```
511518

512-
**Avoid Explicit act():** Rarely needed. `render`, `fireEvent`, `findBy*`, and `waitFor` already wrap operations in `act()`.
519+
#### Keep `waitFor` when `findBy*` cannot express the assertion
520+
521+
**`findBy*` queries wait for presence** of a matching node. They do **not** replace every `waitFor`. Still use **`waitFor`** when you need to wait for something that is **not** “find this text/role in the tree,” for example:
522+
523+
| Situation | Why `findBy*` is not enough | Pattern |
524+
|-----------|------------------------------|---------|
525+
| **Control state after async validation** (e.g. Save **disabled** after Formik/yup runs) | Disabled is a **property**, not a new queryable label; there is no `findBy…` for “button became disabled.” | `await waitFor(() => expect(save).toBeDisabled());` — you can still use **`findByText`** / **`findByRole`** in the **same** test for **error messages** that appear as text. |
526+
| **Eventually absent** (list/link/control **not** in the document) | **`findBy*` waits for presence**, not absence. | `await waitFor(() => expect(screen.queryByRole('list')).not.toBeInTheDocument());` or `waitForElementToBeRemoved` when something was visible first. |
527+
| **CSS class** or **non-query** DOM state | Not a text/role query. | `await waitFor(() => expect(input).toHaveClass('invalid-tag'));` |
528+
| **Mock** or **callback** assertions | Not the DOM. | `await waitFor(() => expect(mockFn).toHaveBeenCalledWith(…));` |
529+
530+
```typescript
531+
// Async validation: message appears → findByText; button disabled → waitFor
532+
await user.type(cpuRequest, '300');
533+
await waitFor(() => expect(save).toBeDisabled());
534+
expect(
535+
await screen.findByText('CPU request must be less than or equal to limit.'),
536+
).toBeVisible();
537+
```
538+
539+
#### Quick reference
540+
541+
```typescript
542+
// Prefer findBy* when waiting for content to appear
543+
const element = await screen.findByText('Loaded content');
544+
expect(element).toBeVisible();
545+
546+
// Use waitFor for disabled state, absence, classes, mocks (see table above)
547+
await waitFor(() => expect(save).toBeDisabled());
548+
```
549+
550+
**Avoid Explicit act():** Rarely needed. `render`, `userEvent` (await its async methods), `findBy*`, and `waitFor` already wrap operations in `act()`.
513551

514552
### Rule 12: Use Lifecycle Hooks for Setup and Cleanup
515553

@@ -553,24 +591,31 @@ expect(userName).toBeVisible();
553591
expect(editButton).toBeVisible();
554592
```
555593

556-
### Rule 14: Simulate User Events with fireEvent
594+
### Rule 14: Simulate User Events with userEvent
595+
596+
Use `@testing-library/user-event` for clicks, typing, and other interactions. It simulates full event sequences closer to real browser behavior than low-level `fireEvent`.
557597

558598
```typescript
559-
import { render, screen, fireEvent } from '@testing-library/react';
599+
import { render, screen } from '@testing-library/react';
600+
import userEvent from '@testing-library/user-event';
560601

602+
const user = userEvent.setup();
561603
render(<MyForm />);
562604

563605
const input = screen.getByLabelText(/name/i);
564606
const button = screen.getByRole('button', { name: /submit/i });
565607

566-
// Simulate typing
567-
fireEvent.change(input, { target: { value: 'John Doe' } });
608+
// Simulate typing (prefer over fireEvent.change)
609+
await user.type(input, 'John Doe');
568610

569611
// Simulate clicking
570-
fireEvent.click(button);
612+
await user.click(button);
571613
```
572614

573-
**Note:** `userEvent` from `@testing-library/user-event` is not supported due to incompatible Jest version (will be updated after Jest upgrade).
615+
**Conventions:**
616+
- Call `const user = userEvent.setup()` per test and **await** `user.click`, `user.type`, `user.keyboard`, etc.
617+
- For text inputs in forms, prefer **`verifyInputField`** (Rule 9) when it applies; use `userEvent` for other controls (selects, checkboxes, buttons, complex widgets).
618+
- Reserve **`fireEvent`** only for rare cases (e.g., triggering a specific low-level DOM event that `userEvent` does not model).
574619

575620
### Rule 15: Test "Unhappy Paths" and Error States
576621

@@ -648,12 +693,14 @@ Remove any imports that are not used in the test file:
648693

649694
```typescript
650695
// ❌ BAD - Unused imports
651-
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
696+
import { render, screen, waitFor, within } from '@testing-library/react';
697+
import userEvent from '@testing-library/user-event';
652698
import { k8sCreate, k8sPatch, k8sUpdate } from '@console/internal/module/k8s';
653-
// ... but only using render, screen, fireEvent
699+
// ... but only using render, screen, userEvent
654700

655701
// ✅ GOOD - Only what's needed
656-
import { render, screen, fireEvent } from '@testing-library/react';
702+
import { render, screen } from '@testing-library/react';
703+
import userEvent from '@testing-library/user-event';
657704
import { k8sCreate } from '@console/internal/module/k8s';
658705
```
659706

@@ -717,23 +764,25 @@ Clean up any variables that are declared but never used:
717764

718765
```typescript
719766
// ❌ BAD - Unused variables
720-
it('should submit form', () => {
767+
it('should submit form', async () => {
721768
const mockData = { foo: 'bar' };
722769
const unusedSpy = jest.spyOn(console, 'log');
723770
const onSubmit = jest.fn();
771+
const user = userEvent.setup();
724772

725773
render(<Form onSubmit={onSubmit} />);
726-
fireEvent.click(screen.getByRole('button'));
774+
await user.click(screen.getByRole('button'));
727775

728776
expect(onSubmit).toHaveBeenCalled();
729777
});
730778

731779
// ✅ GOOD - Only necessary variables
732-
it('should submit form', () => {
780+
it('should submit form', async () => {
781+
const user = userEvent.setup();
733782
const onSubmit = jest.fn();
734783

735784
render(<Form onSubmit={onSubmit} />);
736-
fireEvent.click(screen.getByRole('button'));
785+
await user.click(screen.getByRole('button'));
737786

738787
expect(onSubmit).toHaveBeenCalled();
739788
});
@@ -950,43 +999,45 @@ act(() => {
950999

9511000
**Strategy 1: Wrap async interactions in waitFor**
9521001
```typescript
1002+
import { screen, waitFor } from '@testing-library/react';
1003+
import userEvent from '@testing-library/user-event';
1004+
1005+
const user = userEvent.setup();
1006+
9531007
// ❌ BAD: Causes act() warning
954-
fireEvent.click(button);
1008+
await user.click(button);
9551009
expect(screen.getByText('Updated')).toBeInTheDocument();
9561010

9571011
// ✅ GOOD: Use waitFor for async updates
958-
fireEvent.click(button);
1012+
await user.click(button);
9591013
await waitFor(() => {
9601014
expect(screen.getByText('Updated')).toBeInTheDocument();
9611015
});
9621016
```
9631017

9641018
**Strategy 2: Use findBy* queries (preferred for new elements)**
965-
```typescript
966-
// ❌ BAD: Causes act() warning
967-
fireEvent.click(button);
968-
expect(screen.getByText('Loaded')).toBeInTheDocument();
9691019

970-
// ✅ GOOD: Use findBy* which waits automatically
971-
fireEvent.click(button);
972-
expect(await screen.findByText('Loaded')).toBeInTheDocument();
973-
```
1020+
When waiting for content to appear after user interactions, prefer `findBy*` queries which automatically wait for elements. See **Rule 11** for comprehensive guidance on `findBy*` vs `waitFor`, including when to use each and a detailed comparison table.
9741021

975-
**Strategy 3: Wrap test in act() when needed**
1022+
**Strategy 3: Prefer userEvent + findBy* for dropdown/select-style interactions**
9761023
```typescript
977-
// Import act from @testing-library/react
978-
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
1024+
import { screen, act } from '@testing-library/react';
1025+
import userEvent from '@testing-library/user-event';
1026+
1027+
const user = userEvent.setup();
1028+
1029+
// ❌ BAD: Asserts before async menu content appears
1030+
await user.click(screen.getByText('Select Option'));
1031+
expect(screen.getByText('Option 1')).toBeInTheDocument();
9791032

980-
// ❌ BAD: Causes act() warning for dropdown/select interactions
981-
const dropdown = screen.getByText('Select Option');
982-
fireEvent.click(dropdown);
1033+
// ✅ GOOD: Wait for option, then click
1034+
await user.click(screen.getByText('Select Option'));
1035+
await user.click(await screen.findByText('Option 1'));
9831036

984-
// ✅ GOOD: Wrap in act() for complex interactions
1037+
// If a third-party widget still warns, wrap only that interaction in act():
9851038
await act(async () => {
986-
fireEvent.click(dropdown);
1039+
await user.click(screen.getByRole('button', { name: /toggle menu/i }));
9871040
});
988-
const option = await screen.findByText('Option 1');
989-
fireEvent.click(option);
9901041
```
9911042

9921043
**Strategy 4: Mock timers or async operations**
@@ -1004,7 +1055,7 @@ await waitFor(() => {
10041055
#### Common Causes of act() Warnings
10051056

10061057
1. **Dropdown/Select interactions** - PatternFly Select/Dropdown components
1007-
- Solution: Wrap in `act()` or use `waitFor` after interaction
1058+
- Solution: Use `await user.click` / `user.type` with `findBy*` or `waitFor` after opening; wrap in `act()` only if a widget still emits warnings
10081059

10091060
2. **Async state updates** - useEffect, setTimeout, promises
10101061
- Solution: Use `findBy*` or `waitFor`
@@ -1455,11 +1506,13 @@ When generating tests for React components:
14551506
4. ✅ Mock factories return simple values (null, strings, children) - NO React.createElement
14561507
5. ✅ Cast mocked imports to `jest.Mock` when calling `.mockResolvedValue()` etc.
14571508
6.**Import `verifyInputField` for form components** - Rule 9 strictly enforced
1509+
7.**Use `userEvent` from `@testing-library/user-event` for clicks, typing, and similar interactions** - Rule 14 (`userEvent.setup()`, then `await user.click` / `await user.type`, etc.)
14581510

14591511
**Code Generation Pattern:**
14601512
```typescript
14611513
// ✅ ALWAYS - ES6 imports at file top
1462-
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
1514+
import { render, screen, waitFor } from '@testing-library/react';
1515+
import userEvent from '@testing-library/user-event';
14631516
import { k8sCreate } from '@console/internal/module/k8s';
14641517
import { history } from '@console/internal/components/utils';
14651518
import { verifyInputField } from '@console/shared/src/test-utils/unit-test-utils'; // For form components
@@ -1584,7 +1637,7 @@ If **ANY** `require()` found that is NOT `jest.requireActual` → **IMMEDIATELY
15841637
- Fix **EVERY** act() warning using Rule 23 strategies:
15851638
- Wrap async interactions in `waitFor`
15861639
- Use `findBy*` queries for async elements
1587-
- Add `await waitFor()` after dropdown/select interactions
1640+
- After `await user.click` on dropdowns/selects, wait for menu content with `findBy*` or `waitFor`
15881641
- Ensure async state updates are properly awaited
15891642
- Re-run tests after fixing warnings
15901643
- **DO NOT** complete until output has ZERO act() warnings

frontend/packages/console-app/src/__tests__/hooks/useCSPViolationDetector.spec.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { act, fireEvent } from '@testing-library/react';
1+
import { act } from '@testing-library/react';
22
import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils';
33
import {
44
newPluginCSPViolationEvent,
@@ -78,7 +78,7 @@ describe('useCSPViolationDetector', () => {
7878
mockCacheEvent.mockReturnValue(true);
7979
renderWithProviders(<TestComponent />);
8080
act(() => {
81-
fireEvent(document, testEvent);
81+
document.dispatchEvent(testEvent);
8282
});
8383
expect(mockCacheEvent).toHaveBeenCalledWith(testPluginEvent);
8484
expect(mockFireTelemetry).toHaveBeenCalledWith('CSPViolation', testPluginEvent);
@@ -89,7 +89,7 @@ describe('useCSPViolationDetector', () => {
8989
renderWithProviders(<TestComponent />);
9090

9191
act(() => {
92-
fireEvent(document, testEvent);
92+
document.dispatchEvent(testEvent);
9393
});
9494

9595
expect(mockCacheEvent).toHaveBeenCalledWith(testPluginEvent);
@@ -104,7 +104,7 @@ describe('useCSPViolationDetector', () => {
104104
const expected = newPluginCSPViolationEvent('foo', testEventWithPlugin);
105105
renderWithProviders(<TestComponent />);
106106
act(() => {
107-
fireEvent(document, testEventWithPlugin);
107+
document.dispatchEvent(testEventWithPlugin);
108108
});
109109
expect(mockCacheEvent).toHaveBeenCalledWith(expected);
110110
expect(mockFireTelemetry).toHaveBeenCalledWith('CSPViolation', expected);
@@ -119,7 +119,7 @@ describe('useCSPViolationDetector', () => {
119119
const expected = newPluginCSPViolationEvent('foo', testEventWithPlugin);
120120
renderWithProviders(<TestComponent />);
121121
act(() => {
122-
fireEvent(document, testEventWithPlugin);
122+
document.dispatchEvent(testEventWithPlugin);
123123
});
124124
expect(mockCacheEvent).toHaveBeenCalledWith(expected);
125125
expect(mockFireTelemetry).toHaveBeenCalledWith('CSPViolation', expected);

frontend/packages/console-app/src/components/modals/resource-limits/__tests__/ResourceLimitsModal.spec.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ComponentProps } from 'react';
2-
import { screen, fireEvent } from '@testing-library/react';
2+
import { screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
34
import type { FormikProps, FormikValues } from 'formik';
45
import { formikFormProps } from '@console/shared/src/test-utils/formik-props-utils';
56
import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils';
@@ -70,16 +71,18 @@ describe('ResourceLimitsModal Form', () => {
7071
});
7172

7273
it('calls the cancel function when the Cancel button is clicked', async () => {
74+
const user = userEvent.setup();
7375
renderWithProviders(<ResourceLimitsModal {...formProps} />);
7476

75-
await fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
77+
await user.click(screen.getByRole('button', { name: 'Cancel' }));
7678
expect(formProps.cancel).toHaveBeenCalledTimes(1);
7779
});
7880

7981
it('calls the handleSubmit function when the form is submitted', async () => {
82+
const user = userEvent.setup();
8083
renderWithProviders(<ResourceLimitsModal {...formProps} />);
8184

82-
await fireEvent.submit(screen.getByRole('form'));
85+
await user.click(screen.getByRole('button', { name: 'Save' }));
8386
expect(formProps.handleSubmit).toHaveBeenCalledTimes(1);
8487
});
8588
});

frontend/packages/console-shared/src/components/alerts/__tests__/SwitchToYAMLAlert.spec.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { screen, fireEvent } from '@testing-library/react';
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
23
import { renderWithProviders } from '../../../test-utils/unit-test-utils';
34
import SwitchToYAMLAlert from '../SwitchToYAMLAlert';
45

@@ -42,12 +43,13 @@ describe('SwitchToYAMLAlert', () => {
4243
expect(closeButton).toBeInTheDocument();
4344
});
4445

45-
it('should call onClose when close button is clicked', () => {
46+
it('should call onClose when close button is clicked', async () => {
47+
const user = userEvent.setup();
4648
const mockOnClose = jest.fn();
4749
renderWithProviders(<SwitchToYAMLAlert onClose={mockOnClose} />);
4850

4951
const closeButton = screen.getByRole('button', { name: /close/i });
50-
fireEvent.click(closeButton);
52+
await user.click(closeButton);
5153

5254
expect(mockOnClose).toHaveBeenCalledTimes(1);
5355
});

0 commit comments

Comments
 (0)