Testing User Interactions #150
π Introductionβ
So far in our testing journey, culminating with Writing Your First Unit Tests (Part 2), we've focused on rendering components and verifying their output or simple state changes. However, React applications are interactive. Users click buttons, type into forms, select options, and more. This article dives into how to simulate these user interactions in your tests using @testing-library/user-event
, a companion library to React Testing Library that provides more realistic event simulation.
π Prerequisitesβ
Before we begin, ensure you have:
- A working Jest and React Testing Library setup.
@testing-library/user-event
installed (covered in Article 146).- Understanding of basic RTL queries and assertions.
- Familiarity with common HTML form elements and browser events.
π― Article Outline: What You'll Masterβ
In this article, you will learn:
- β
Why
@testing-library/user-event
? Benefits over RTL'sfireEvent
. - β
Setting up
userEvent
: TheuserEvent.setup()
method. - β
Simulating Clicks:
userEvent.click()
. - β
Simulating Typing:
userEvent.type()
anduserEvent.keyboard()
. - β
Working with Form Elements: Selecting options (
selectOptions
), checking/unchecking (click
on checkboxes/radios). - β
Hover and Focus Events (Briefly):
userEvent.hover()
,userEvent.focus()
. - β Testing Components that Respond to User Input.
π§ Section 1: Why @testing-library/user-event
?β
React Testing Library provides a basic fireEvent
API (e.g., fireEvent.click(buttonElement)
). While fireEvent
dispatches DOM events, it's a lower-level API and doesn't always replicate the full sequence of events a real user interaction would trigger in a browser.
@testing-library/user-event
(often aliased as userEvent
) aims to simulate user interactions more closely to how they actually occur in a browser. For example:
userEvent.click(element)
: DispatchespointerOver
,pointerMove
,pointerDown
,mouseOver
,mouseMove
,mouseDown
,pointerUp
,mouseUp
,click
. It also correctly handles focus changes.userEvent.type(element, text)
: Simulates a user typing, including dispatchingkeyDown
,keyPress
,keyUp
events for each character, and handling focus.- It generally dispatches events that more accurately reflect what happens when a user interacts, which can be crucial for testing components that rely on specific event sequences or default browser behaviors (like form submissions on Enter key).
Recommendation: For most interaction testing, @testing-library/user-event
is preferred over fireEvent
due to its higher fidelity simulation. All userEvent
APIs return a Promise, so you should typically await
their calls.
π» Section 2: Setting up userEvent
β
Before using userEvent
methods, it's good practice to call userEvent.setup()
at the beginning of your test or describe
block. This can configure aspects of the event simulation, though often the defaults are fine.
import userEvent from '@testing-library/user-event';
describe('MyInteractiveComponent', () => {
let user; // Declare user variable
beforeEach(() => {
user = userEvent.setup(); // Initialize user-event for each test
});
test('should do something when clicked', async () => {
render(<MyInteractiveComponent />);
const button = screen.getByRole('button');
await user.click(button); // Use the initialized user object
// ... assertions ...
});
});
While you can call userEvent.click()
directly without setup()
, using setup()
allows for potential future configuration or use with advanced options if needed (like delay
for typing).
π οΈ Section 3: Simulating Clicks with userEvent.click()
β
We've already seen basic usage in previous examples. userEvent.click(element, eventInit, options)
simulates a user clicking on an element.
// src/components/ToggleButton.jsx
import React, { useState } from 'react';
const ToggleButton = ({ initialMessage = "Show Message", toggledMessage = "Message Shown!" }) => {
const [isToggled, setIsToggled] = useState(false);
return (
<div>
<button onClick={() => setIsToggled(!isToggled)}>
{isToggled ? "Hide" : "Show"} Details
</button>
{isToggled && <p>{toggledMessage}</p>}
</div>
);
};
export default ToggleButton;
// src/components/ToggleButton.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ToggleButton from './ToggleButton';
describe('ToggleButton Component', () => {
let user;
beforeEach(() => {
user = userEvent.setup();
});
test('initially does not show the toggled message', () => {
render(<ToggleButton toggledMessage="Secret Info" />);
expect(screen.queryByText("Secret Info")).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /show details/i })).toBeInTheDocument();
});
test('shows the toggled message after button click and changes button text', async () => {
const message = "You found the secret!";
render(<ToggleButton toggledMessage={message} />);
const toggleButton = screen.getByRole('button', { name: /show details/i });
await user.click(toggleButton); // Act
// Assert
expect(screen.getByText(message)).toBeInTheDocument();
expect(toggleButton).toHaveTextContent(/hide details/i); // Button text changed
});
test('hides the message after a second click', async () => {
const message = "Will disappear";
render(<ToggleButton toggledMessage={message} />);
const toggleButton = screen.getByRole('button', { name: /show details/i });
// First click (to show)
await user.click(toggleButton);
expect(screen.getByText(message)).toBeInTheDocument();
expect(toggleButton).toHaveTextContent(/hide details/i);
// Second click (to hide)
await user.click(toggleButton);
expect(screen.queryByText(message)).not.toBeInTheDocument();
expect(toggleButton).toHaveTextContent(/show details/i);
});
});
Remember that userEvent.click()
(and most userEvent
methods) returns a Promise, so use async/await
.
π¬ Section 4: Simulating Typing - userEvent.type()
and userEvent.keyboard()
β
4.1 - userEvent.type(element, text, options)
β
This method simulates a user typing text into an input field or textarea. It's very realistic, dispatching individual key events.
// src/components/LoginForm.jsx
import React, { useState } from 'react';
const LoginForm = ({ onSubmit }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (event) => {
event.preventDefault();
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button type="submit">Login</button>
</form>
);
};
export default LoginForm;
// src/components/LoginForm.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm Component', () => {
let user;
beforeEach(() => {
user = userEvent.setup();
});
test('allows typing into email and password fields and submits data', async () => {
const mockOnSubmit = jest.fn();
render(<LoginForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /login/i });
// Act: Type into fields
const testEmail = '[email protected]';
const testPassword = 'password123';
await user.type(emailInput, testEmail);
await user.type(passwordInput, testPassword);
// Assert: Check if inputs have the typed values
expect(emailInput).toHaveValue(testEmail);
expect(passwordInput).toHaveValue(testPassword);
// Act: Click submit
await user.click(submitButton);
// Assert: Check if onSubmit was called with the correct data
expect(mockOnSubmit).toHaveBeenCalledTimes(1);
expect(mockOnSubmit).toHaveBeenCalledWith({ email: testEmail, password: testPassword });
});
});
Options for userEvent.type()
:
delay
(number): Simulates a delay (in ms) between keystrokes. Useful if your component has debouncing or other timing-sensitive logic.skipClick
(boolean): By default,userEvent.type
first clicks the element to focus it. Set totrue
to skip this initial click.
4.2 - userEvent.keyboard(text, options)
β
This simulates keyboard events without being tied to a specific input element. It's useful for testing global keyboard shortcuts or more complex sequences involving special keys. text
can include special key descriptors like {enter}
, {arrowleft}
, {control}
.
test('form submits on Enter key press in password field', async () => {
const user = userEvent.setup();
const mockOnSubmit = jest.fn();
render(<LoginForm onSubmit={mockOnSubmit} />);
const passwordInput = screen.getByLabelText(/password/i);
await user.type(passwordInput, 'secret');
// Simulate pressing Enter while passwordInput is focused (user.type focuses it)
await user.keyboard('{enter}');
expect(mockOnSubmit).toHaveBeenCalledTimes(1);
});
// More complex example with special keys:
// await user.keyboard('foo{shift}bar{/shift}baz'); // -> fooBARbaz
// await user.keyboard('{control>}{alt>}a{/alt}{/control}'); // -> Ctrl+Alt+A (presses control, then alt, then a, then releases alt, then control)
Refer to userEvent
documentation for the full list of special key descriptors.
β¨ Section 5: Working with Other Form Elementsβ
5.1 - userEvent.selectOptions(element, values, options)
β
For <select>
elements (single or multiple).
element
: The<select>
element or one of its<option>
s.values
: A string or array of strings representing thevalue
attributes of the options to select.
// src/components/CountrySelector.jsx
import React, { useState } from 'react';
const CountrySelector = ({ onCountryChange }) => {
const [selectedCountry, setSelectedCountry] = useState('');
const countries = [
{ code: '', name: 'Select a country' },
{ code: 'US', name: 'United States' },
{ code: 'CA', name: 'Canada' },
{ code: 'GB', name: 'United Kingdom' },
];
const handleChange = (event) => {
const countryCode = event.target.value;
setSelectedCountry(countryCode);
if (onCountryChange) {
onCountryChange(countryCode);
}
};
return (
<div>
<label htmlFor="country-select">Country:</label>
<select id="country-select" value={selectedCountry} onChange={handleChange}>
{countries.map(country => (
<option key={country.code} value={country.code}>
{country.name}
</option>
))}
</select>
{selectedCountry && <p>You selected: {
countries.find(c => c.code === selectedCountry)?.name
}</p>}
</div>
);
};
export default CountrySelector;
// src/components/CountrySelector.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CountrySelector from './CountrySelector';
describe('CountrySelector Component', () => {
let user;
beforeEach(() => {
user = userEvent.setup();
});
test('allows selecting a country', async () => {
const mockOnChange = jest.fn();
render(<CountrySelector onCountryChange={mockOnChange} />);
const selectElement = screen.getByLabelText(/country/i);
// Act: Select "Canada" (whose option value is "CA")
await user.selectOptions(selectElement, 'CA');
// Assert
expect(selectElement).toHaveValue('CA');
expect(screen.getByText('You selected: Canada')).toBeInTheDocument();
expect(mockOnChange).toHaveBeenCalledWith('CA');
// Act: Select "United Kingdom"
await user.selectOptions(selectElement, 'GB');
expect(selectElement).toHaveValue('GB');
expect(screen.getByText('You selected: United Kingdom')).toBeInTheDocument();
expect(mockOnChange).toHaveBeenCalledWith('GB');
});
});
5.2 - Checkboxes and Radio Buttonsβ
These are typically toggled using userEvent.click()
. RTL provides matchers like .toBeChecked()
.
// src/components/Preferences.jsx
import React, { useState } from 'react';
const Preferences = () => {
const [notifications, setNotifications] = useState(false);
const [theme, setTheme] = useState('light');
return (
<div>
<label>
<input
type="checkbox"
checked={notifications}
onChange={() => setNotifications(!notifications)}
/>
Enable Notifications
</label>
<fieldset>
<legend>Theme:</legend>
<label>
<input
type="radio"
name="theme"
value="light"
checked={theme === 'light'}
onChange={() => setTheme('light')}
/> Light
</label>
<label>
<input
type="radio"
name="theme"
value="dark"
checked={theme === 'dark'}
onChange={() => setTheme('dark')}
/> Dark
</label>
</fieldset>
<p>Notifications: {notifications ? 'Enabled' : 'Disabled'}</p>
<p>Theme: {theme}</p>
</div>
);
};
export default Preferences;
// src/components/Preferences.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Preferences from './Preferences';
describe('Preferences Component', () => {
let user;
beforeEach(() => {
user = userEvent.setup();
});
test('toggles notification checkbox', async () => {
render(<Preferences />);
const notificationCheckbox = screen.getByLabelText(/enable notifications/i);
expect(notificationCheckbox).not.toBeChecked();
expect(screen.getByText('Notifications: Disabled')).toBeInTheDocument();
await user.click(notificationCheckbox);
expect(notificationCheckbox).toBeChecked();
expect(screen.getByText('Notifications: Enabled')).toBeInTheDocument();
});
test('changes theme via radio buttons', async () => {
render(<Preferences />);
const lightThemeRadio = screen.getByLabelText('Light'); // Assumes "Light" is text of label
const darkThemeRadio = screen.getByLabelText('Dark');
expect(lightThemeRadio).toBeChecked();
expect(darkThemeRadio).not.toBeChecked();
expect(screen.getByText('Theme: light')).toBeInTheDocument();
await user.click(darkThemeRadio);
expect(darkThemeRadio).toBeChecked();
expect(lightThemeRadio).not.toBeChecked();
expect(screen.getByText('Theme: dark')).toBeInTheDocument();
});
});
π Section 6: Hover, Focus, and Other Interactionsβ
userEvent
provides methods for other common interactions:
userEvent.hover(element)
: Simulates hovering over an element (dispatchesmouseOver
,mouseEnter
).userEvent.unhover(element)
: Simulates moving the mouse off an element (dispatchesmouseOut
,mouseLeave
).userEvent.focus(element)
/userEvent.tab()
: For focus management.tab()
simulates pressing the Tab key to move focus.userEvent.clear(element)
: Clears the value of an input or textarea.userEvent.upload(element, fileOrFiles)
: For testing file inputs.
Consult the @testing-library/user-event
documentation for the full API and advanced usage.
π‘ Conclusion & Key Takeawaysβ
@testing-library/user-event
is an indispensable tool for testing the interactivity of your React components. By simulating user actions like clicks, typing, and selections with high fidelity, it allows you to write tests that accurately reflect how users engage with your application, leading to greater confidence in your component's behavior.
Key Takeaways:
- Prefer
@testing-library/user-event
overfireEvent
for more realistic interaction simulation. - Initialize with
userEvent.setup()
inbeforeEach
or at the start of a test. - Use
await user.click()
for clicks,await user.type()
for typing, andawait user.selectOptions()
for dropdowns. - Remember that
userEvent
methods are generally asynchronous. - Test the outcome of user interactions on the UI, not the internal state directly.
β‘οΈ Next Stepsβ
We've now covered how to test user interactions with forms and buttons. A common scenario in modern web apps is fetching data from an API and displaying it, or sending data to an API. To test components that make API calls without actually hitting a real network, we need to mock those calls. The next article, "Mocking API Calls in Tests (Part 1)", will introduce techniques for mocking fetch
or other API clients using Jest.
Keep building interactive and well-tested components!
glossaryβ
@testing-library/user-event
: A companion library to React Testing Library that simulates user interactions more realistically thanfireEvent
.userEvent.setup()
: Initializesuser-event
for a test, allowing for configuration.userEvent.click(element)
: Simulates a user click on an element.userEvent.type(element, text)
: Simulates a user typing text into an element.userEvent.keyboard(text)
: Simulates keyboard events, including special keys.userEvent.selectOptions(element, values)
: Simulates selecting options in a<select>
element.- Event Fidelity: The degree to which simulated events in tests accurately replicate the sequence and properties of real browser events.
Further Readingβ
@testing-library/user-event
Official Documentation- [React Testing Library:
fireEvent
vsuserEvent
](https://testing-library.com/docs/dom-testing-library/ ΰ¦ΰ§ΰ¦¨ΰ¦ΰ¦Ύ-μ¬μ©ν΄μΌ-ν κΉμ:-fireevent-vs-userevent) (Note: Link may be in Korean, but the English version or translated content is usually available. Search for "fireEvent vs userEvent testing library" for English resources). The key takeaway isuserEvent
is generally preferred. - Common mistakes with React Testing Library (by Kent C. Dodds) (Often covers interaction testing best practices).