JavaScript and React course Β· Module 13: Testing React
Testing Interactions - Flight Simulator
In this lesson4
In a real spaceship, the pilot presses buttons, turns knobs, and enters data on the keyboard. In React tests, we must simulate the same interactions to ensure the application responds correctly. A render test alone only tells you that the panel lit up. It does not tell you whether the engine actually starts after pressing "Launch".
We have two tools for simulating interactions: fireEvent, built into RTL, and the separate userEvent library. Both are wrapped in act(), so React has time to re-render the component before you check the result. They differ in how faithfully they imitate a human, and you will see that difference step by step in this lesson.
fireEvent - Basic Simulation
fireEvent is a React Testing Library tool for triggering DOM events. Every call dispatches exactly one event, such as click, to the given element:
1import { render, screen, fireEvent } from '@testing-library/react';
2
3test('toggles engine status on button click', () => {
4 render(<EnginePanel />);
5
6 const toggleButton = screen.getByRole('button', { name: 'Toggle Engine' });
7 expect(screen.getByText('Engine: OFF')).toBeInTheDocument();
8
9 fireEvent.click(toggleButton);
10 expect(screen.getByText('Engine: ON')).toBeInTheDocument();
11
12 fireEvent.click(toggleButton);
13 expect(screen.getByText('Engine: OFF')).toBeInTheDocument();
14});The test checks the state before the click, after the first and after the second, so you see the switch's full cycle. We do not peek into the component's state, we read the text, just as the pilot would.
Form Events
We change a text field with fireEvent.change, passing the new value in target.value. To check whether the form sent its data, we use a mock function, jest.fn():
1test('updates pilot name input', () => {
2 render(<PilotForm />);
3
4 const nameInput = screen.getByLabelText('Pilot Name');
5 fireEvent.change(nameInput, { target: { value: 'Commander Nova' } });
6
7 expect(nameInput).toHaveValue('Commander Nova');
8});
9
10test('submits mission form', () => {
11 const mockSubmit = jest.fn();
12 render(<MissionForm onSubmit={mockSubmit} />);
13
14 fireEvent.change(screen.getByLabelText('Destination'), {
15 target: { value: 'Mars' }
16 });
17 fireEvent.click(screen.getByRole('button', { name: 'Launch' }));
18
19 expect(mockSubmit).toHaveBeenCalledWith({ destination: 'Mars' });
20});The toHaveBeenCalledWith assertion checks that onSubmit received exactly { destination: 'Mars' }. The internal state of MissionForm does not interest us at all, what counts is what the component passed to the outside.
userEvent - Realistic Simulation
userEvent is a more advanced library that simulates events the way a real user does them. Instead of a single change event, it simulates pressing individual keys. You install it separately as @testing-library/user-event, and in version 14 you start with userEvent.setup():
1import userEvent from '@testing-library/user-event';
2
3test('types pilot name realistically', async () => {
4 const user = userEvent.setup();
5 render(<PilotForm />);
6
7 const nameInput = screen.getByLabelText('Pilot Name');
8 await user.type(nameInput, 'Commander Nova');
9
10 expect(nameInput).toHaveValue('Commander Nova');
11});The user object from setup() keeps the keyboard and pointer state for the whole test. All its methods are asynchronous, which is why each one is preceded by await.
Key Differences: fireEvent vs userEvent
Let's compare what really happens under the hood with both approaches:
1// fireEvent - fast but less realistic
2fireEvent.change(input, { target: { value: 'text' } });
3// Triggers ONLY the change event
4
5// userEvent - slower but realistic
6await user.type(input, 'text');
7// First clicks the field (pointer, mouse, focus, click),
8// then for EACH character: keydown, keypress, beforeinput, input, keyup
9// (React's onChange reacts to every input event)fireEvent.change replaces the value at once and does not check whether the field is disabled or whether it can be clicked at all. userEvent goes through the whole sequence like a human and runs the same checks as a browser along the way: it will not type into a field with the disabled attribute, and it throws an error when you click an element with the CSS pointer-events: none. It will not notice, however, that one element covers another, because jsdom does not calculate page layout. My recommendation: use userEvent by default, and keep fireEvent for events that userEvent does not support.
Click and Double Click
Besides a regular click, userEvent supports double clicks and any mouse button:
1test('handles click and double click', async () => {
2 const user = userEvent.setup();
3 render(<NavigationPanel />);
4
5 // Single click
6 await user.click(screen.getByRole('button', { name: 'Select' }));
7
8 // Double click
9 await user.dblClick(screen.getByText('Mission Details'));
10
11 // Right click
12 await user.pointer({
13 keys: '[MouseRight]',
14 target: screen.getByText('Options')
15 });
16});The pointer method takes the button description in square brackets, here [MouseRight], the right mouse button.
Keyboard Interactions
An accessible application must work without a mouse. user.tab() moves focus like the Tab key, and user.keyboard types special keys in curly braces:
1test('keyboard navigation in mission list', async () => {
2 const user = userEvent.setup();
3 render(<MissionList />);
4
5 // Tab to next element
6 await user.tab();
7 expect(screen.getByText('Mission Alpha')).toHaveFocus();
8
9 // Tab to the next one
10 await user.tab();
11 expect(screen.getByText('Mission Beta')).toHaveFocus();
12
13 // Enter to select
14 await user.keyboard('{Enter}');
15 expect(screen.getByText('Selected: Mission Beta')).toBeInTheDocument();
16});The toHaveFocus matcher from jest-dom checks which element has focus. A test like this protects keyboard navigation from being broken by accident.
Clearing and Selecting Text
Sometimes a field already has a default value that must be cleared before typing a new one:
1test('clears and replaces input value', async () => {
2 const user = userEvent.setup();
3 render(<CoordinateInput defaultValue="0,0,0" />);
4
5 const input = screen.getByRole('textbox');
6 // Select all and clear
7 await user.clear(input);
8 expect(input).toHaveValue('');
9
10 // Type new value
11 await user.type(input, '42,15,88');
12 expect(input).toHaveValue('42,15,88');
13});user.clear selects all the content and deletes it, while user.type appends text at the end, so without clearing you would get 0,0,042,15,88.
Dropdown and Checkbox
Dropdowns and checkboxes have their own methods:
1test('selects planet from dropdown', async () => {
2 const user = userEvent.setup();
3 render(<PlanetSelector />);
4
5 await user.selectOptions(
6 screen.getByRole('combobox'),
7 screen.getByRole('option', { name: 'Mars' })
8 );
9
10 expect(screen.getByRole('option', { name: 'Mars' }).selected).toBe(true);
11});
12
13test('toggles autopilot checkbox', async () => {
14 const user = userEvent.setup();
15 render(<AutopilotSwitch />);
16
17 const checkbox = screen.getByRole('checkbox', { name: 'Autopilot' });
18 expect(checkbox).not.toBeChecked();
19
20 await user.click(checkbox);
21 expect(checkbox).toBeChecked();
22});selectOptions accepts an option element or its value, and we toggle a checkbox with a plain click, just like a user.
Testing Callbacks and Props
Finally, two tests of the same button: once when the systems are ready, once when they are not:
1test('calls onLaunch when all systems ready', async () => {
2 const user = userEvent.setup();
3 const handleLaunch = jest.fn();
4
5 render(<LaunchButton onLaunch={handleLaunch} systemsReady={true} />);
6
7 await user.click(screen.getByRole('button', { name: 'Launch' }));
8 expect(handleLaunch).toHaveBeenCalledTimes(1);
9});
10
11test('does not call onLaunch when systems not ready', async () => {
12 const user = userEvent.setup();
13 const handleLaunch = jest.fn();
14
15 render(<LaunchButton onLaunch={handleLaunch} systemsReady={false} />);
16
17 await user.click(screen.getByRole('button', { name: 'Launch' }));
18 expect(handleLaunch).not.toHaveBeenCalled();
19});The second test matters as much as the first. It checks that the button does not fire when systemsReady is false, and that is often the most critical behavior. A negative test is a safety check: it verifies what the system will not do when the conditions are not met.
Loading Data and a Retry Button
A mission panel often has to fetch data first, for example the crew list. Such a component always goes through the same sequence of states, and the test checks it step by step:
- Initial state -
loading: true, a loading message on the screen. - Sending the request - after the first render, an effect calls the function that fetches the data.
- Receiving the response - the Promise resolves with data or is rejected with an error.
- State update -
loading: falseanddatawith the response, orerrorwith a message. - Re-render - the component shows the list or an error message with a retry button.
So that the test does not send real requests into space, the component receives the data-fetching function as a prop. In the app you pass a real function defined outside the component, for example loadCrew={fetchCrew}, and in the test a mock:
1function CrewLoader({ loadCrew }) {
2 const [state, setState] = useState({ loading: true, data: null, error: null });
3 const [attempt, setAttempt] = useState(0);
4
5 useEffect(() => {
6 let ignore = false;
7 loadCrew()
8 .then((data) => {
9 if (!ignore) setState({ loading: false, data, error: null });
10 })
11 .catch((err) => {
12 if (!ignore) setState({ loading: false, data: null, error: err.message });
13 });
14 return () => {
15 ignore = true;
16 };
17 }, [loadCrew, attempt]);
18
19 function retry() {
20 setState({ loading: true, data: null, error: null });
21 setAttempt((n) => n + 1);
22 }
23
24 if (state.loading) return <p>Loading crew...</p>;
25 if (state.error) {
26 return (
27 <div>
28 <p role="alert">{state.error}</p>
29 <button onClick={retry}>Retry</button>
30 </div>
31 );
32 }
33 return (
34 <ul>
35 {state.data.map((name) => <li key={name}>{name}</li>)}
36 </ul>
37 );
38}Clicking "Retry" goes back to the loading state and increments the attempt counter, and the changed dependency runs the effect again, so the component sends a new request. The ignore flag in the cleanup function discards a response that arrives too late, for example after the component unmounts. That also matters because in development with StrictMode, React runs every effect twice. The error has the alert role, so the test finds it by role.
The test goes through the whole failure-and-recovery path. The mock returns a rejected Promise the first time and data the second time:
1test('shows an error and loads the crew again after Retry', async () => {
2 const user = userEvent.setup();
3 const loadCrew = jest.fn()
4 .mockRejectedValueOnce(new Error('Signal lost'))
5 .mockResolvedValueOnce(['Nova', 'Astro']);
6
7 render(<CrewLoader loadCrew={loadCrew} />);
8 expect(screen.getByText('Loading crew...')).toBeInTheDocument();
9
10 expect(await screen.findByRole('alert')).toHaveTextContent('Signal lost');
11 await user.click(screen.getByRole('button', { name: 'Retry' }));
12
13 expect(await screen.findByText('Nova')).toBeInTheDocument();
14 expect(loadCrew).toHaveBeenCalledTimes(2);
15});mockRejectedValueOnce makes the first call of the mock return a rejected Promise, and mockResolvedValueOnce makes the second one return data. findByRole and findByText wait until the component reaches the next state, because the response arrives asynchronously. Finally, the number of mock calls confirms that the button really sent a second request. The component does not know it is talking to a mock, and that is the whole benefit of passing the function as a prop.
In the next lesson you will look at waiting more closely: you will learn waitFor and how to replace fetch. Remember: a good interaction test presses the same buttons as the pilot and checks only what the pilot sees on the panel.
Code for this lesson: App.jsx
1import React, { useState } from 'react';
2
3const ROLES = [
4 { value: 'pilot', label: 'Pilot' },
5 { value: 'captain', label: 'Captain' },
6 { value: 'engineer', label: 'Engineer' },
7 { value: 'scientist', label: 'Scientist' },
8];
9
10// A form that the test fills in with userEvent before checking the onSubmit call
11function PilotForm({ onSubmit }) {
12 const [name, setName] = useState('');
13 const [role, setRole] = useState('pilot');
14 const [experience, setExperience] = useState(0);
15 const [autopilot, setAutopilot] = useState(false);
16 const [lastAdded, setLastAdded] = useState(null);
17
18 const handleSubmit = (e) => {
19 e.preventDefault();
20 if (!name.trim()) return;
21 onSubmit({ name, role, experience, autopilot });
22 setLastAdded(name);
23 setName('');
24 setRole('pilot');
25 setExperience(0);
26 setAutopilot(false);
27 };
28
29 return (
30 <form className="pilot-form" onSubmit={handleSubmit}>
31 <h2>Register a New Crew Member</h2>
32
33 <div className="field">
34 <label htmlFor="name">Name:</label>
35 <input
36 id="name"
37 type="text"
38 value={name}
39 onChange={(e) => setName(e.target.value)}
40 placeholder="Enter pilot name..."
41 />
42 </div>
43
44 <div className="field">
45 <label htmlFor="role">Role:</label>
46 <select id="role" value={role} onChange={(e) => setRole(e.target.value)}>
47 {ROLES.map((r) => (
48 <option key={r.value} value={r.value}>{r.label}</option>
49 ))}
50 </select>
51 </div>
52
53 <div className="field">
54 <label htmlFor="experience">Experience (years):</label>
55 <input
56 id="experience"
57 type="number"
58 value={experience}
59 onChange={(e) => setExperience(Number(e.target.value))}
60 min="0"
61 max="50"
62 />
63 </div>
64
65 <div className="field checkbox">
66 <label>
67 <input
68 type="checkbox"
69 checked={autopilot}
70 onChange={(e) => setAutopilot(e.target.checked)}
71 />
72 Autopilot certified
73 </label>
74 </div>
75
76 <button type="submit" disabled={!name.trim()}>
77 Add Crew Member
78 </button>
79
80 {lastAdded && (
81 <p className="success" role="status">Added: {lastAdded}</p>
82 )}
83 </form>
84 );
85}
86
87// Switches that the test clicks and checks by their text
88function TogglePanel() {
89 const [engines, setEngines] = useState({ left: false, right: false });
90 const [shield, setShield] = useState(0);
91
92 const status =
93 engines.left && engines.right
94 ? 'Both engines running - full speed ahead!'
95 : engines.left || engines.right
96 ? 'Single engine mode - reduced speed'
97 : 'All engines offline';
98
99 return (
100 <div className="toggle-panel">
101 <h2>Ship Controls</h2>
102
103 <div className="controls">
104 <button
105 onClick={() => setEngines((e) => ({ ...e, left: !e.left }))}
106 className={engines.left ? 'active' : ''}
107 >
108 Left engine: {engines.left ? 'ON' : 'OFF'}
109 </button>
110
111 <button
112 onClick={() => setEngines((e) => ({ ...e, right: !e.right }))}
113 className={engines.right ? 'active' : ''}
114 >
115 Right engine: {engines.right ? 'ON' : 'OFF'}
116 </button>
117
118 <button onClick={() => setShield((s) => Math.min(s + 25, 100))}>
119 Shield: {shield}%
120 </button>
121 </div>
122
123 <p className="status">{status}</p>
124 </div>
125 );
126}
127
128export default function App() {
129 const [calls, setCalls] = useState([]);
130
131 return (
132 <div className="app">
133 <h1>Testing Interactions - Flight Simulator</h1>
134 <p className="note">
135 The preview does not run tests. Fill in and submit the form: a test with
136 userEvent does the same, then uses toHaveBeenCalledWith to check what data
137 the onSubmit function received.
138 </p>
139 <PilotForm onSubmit={(data) => setCalls((c) => [...c, data])} />
140
141 {calls.length > 0 && (
142 <div className="submissions">
143 <h3>onSubmit calls ({calls.length}):</h3>
144 {calls.map((data, i) => (
145 <code key={i} className="call">{JSON.stringify(data)}</code>
146 ))}
147 </div>
148 )}
149
150 <TogglePanel />
151 </div>
152 );
153}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is the main advantage of userEvent over fireEvent in React testing?
2. How do you check in a test that the onSubmit callback was called with the correct data?
Hands-on tasks in the game
- Code editor
Finish PlanetLoader, which loads the planets with the loadPlanets function from props and shows three states: loading (role="status"), error (role="alert" with a Try again button) and the planet list. ___BLANK1___: after a successful load, store in the status state that the data is ready (e.g. 'success'), so that the list replaces the loading message. ___BLANK2___: when the promise is rejected, set the status to 'error'. ___BLANK3___: retry increases the attempt counter by 1, so the effect runs again and calls loadPlanets once more. In the preview the first load fails on purpose, so you will see the error message and the retry.
- Vertical ordering
Arrange the states of an asynchronous component in the order they appear:
- Click in order
Arrange the elements of the userEvent.type() syntax in the correct order: