Skip to main content

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's fireEvent.
  • βœ… Setting up userEvent: The userEvent.setup() method.
  • βœ… Simulating Clicks: userEvent.click().
  • βœ… Simulating Typing: userEvent.type() and userEvent.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): Dispatches pointerOver, pointerMove, pointerDown, mouseOver, mouseMove, mouseDown, pointerUp, mouseUp, click. It also correctly handles focus changes.
  • userEvent.type(element, text): Simulates a user typing, including dispatching keyDown, 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 to true 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 the value 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 (dispatches mouseOver, mouseEnter).
  • userEvent.unhover(element): Simulates moving the mouse off an element (dispatches mouseOut, 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 over fireEvent for more realistic interaction simulation.
  • Initialize with userEvent.setup() in beforeEach or at the start of a test.
  • Use await user.click() for clicks, await user.type() for typing, and await 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 than fireEvent.
  • userEvent.setup(): Initializes user-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​