Introduction to Testing User Actions
The more your tests resemble the way your software is used, the more confidence they can give you. - Kent D. Dodds, creator of React testing Library.
User actions refer to the various ways a user interacts with a web application, often triggering changes in the state of the application or causing certain functions to execute. These actions can include but are not limited to clicking buttons, submitting forms, hovering over elements, dragging and dropping items, and pressing keys. In essence, user actions serve as the bridge between the user and the application, enabling the two-way communication that makes interactivity possible.
These interactions are an integral part of any interactive application for several reasons:
Usability: User actions help in navigating the application, performing tasks, and accessing features. Without the ability to effectively capture and respond to these actions, an app would be static and not user-friendly.
Dynamic Content: Most modern web applications are designed to be dynamic, updating content or changing layout based on user interactions. The handling of user actions is fundamental to this dynamic behavior.
Data Capture: Actions like form submissions are essential for capturing user data, whether it's a simple search query or a complex set of options in a multi-step form.
User Experience: Properly responding to user actions is crucial for providing a positive user experience. For instance, failing to accurately capture and process a click event could result in a frustrating experience for the user.
Given the critical role that user actions play in interactive applications, it's crucial to rigorously test how your application responds to these actions to ensure reliability, accuracy, and a positive user experience.
Importance of Testing User Actions
Testing user actions is a critical component of ensuring that an application is both reliable and robust for several key reasons:
Bug Identification: Without testing user actions, bugs can easily go unnoticed until the application is in the hands of the user. By then, not only has the user had a frustrating experience, but it's also more costly in terms of both time and resources to fix the issue.
Predictable Behavior: Testing ensures that the application behaves as expected when users interact with it. This predictability is crucial for building user trust and satisfaction. A button should perform the action it's designed for every time it’s clicked, and a form should submit data accurately and reliably.
Accessibility: Testing user actions also ensures that your application is accessible to as many people as possible, including those using assistive technologies. This is not only good practice but also a legal requirement in many jurisdictions.
User Experience: Ultimately, if user actions are not tested properly, the overall user experience suffers. Elements might not be clickable, forms may not submit correctly, and features may not be accessible, all leading to user dissatisfaction.
Edge Cases: Users often interact with applications in unpredictable ways. Thorough testing of user actions helps to uncover edge cases where the application might behave unexpectedly, allowing for preemptive resolution of these issues.
Security: User actions like form submissions can be vulnerable points for attacks such as SQL injection or cross-site scripting. Rigorous testing can help identify and fix these vulnerabilities.
Given these factors, it's evident that testing user actions is not just a good-to-have but a must-have practice for any serious development project. It contributes directly to the application’s reliability and robustness, ensuring that it meets both functional and non-functional requirements.
Methods for Testing User Actions
The library we will be using is @testing-library/user-event
. Here's a brief discussion on what userEvent
is and why it's beneficial for simulating user actions.
userEvent
?
What is userEvent is a library that simulates user actions like clicking, typing, tabbing, etc., in a way that closely mimics real user behavior. It builds upon the fireEvent
utility, also from React Testing Library, but provides a more user-centric experience for simulating events.
userEvent
?
Why Use Realistic Simulation: Unlike simpler methods of event simulation that trigger only individual events,
userEvent
methods generate a sequence of events that more closely mimic user interactions. For example, when you useuserEvent.type
, it doesn't just change the value of an input; it also fires the appropriatekeydown
,keyup
, andchange
events in the correct sequence.Ease of Use:
userEvent
offers a more straightforward, readable API for simulating complex user interactions, making your tests easier to write and understand.Focus on User Experience: By simulating the full flow of events triggered by user interactions,
userEvent
helps you catch issues that simpler simulation methods might miss. This leads to a more robust application and better user experience.Comprehensive Testing: Using
userEvent
, you can cover various edge cases, like what happens if a user types too quickly, or if they click a button multiple times in rapid succession.
Suggested reading: Use @testing-library/user-event over fireEvent where possible.
userEvent
Methods
Commonly Used click(element)
: Simulates a mouse click event on a given element.type(element, value)
: Types the given value into the input element, simulating individual keypress events.hover(element)
: Simulates a hover event over a given element.dblClick(element)
: Simulates a double-click event on a given element.clear(element)
: Clears the content of an input field.- See all available methods here
Await
userEvent
methods return a Promise. The use of await
in conjunction with userEvent
often has to do with asynchronous behavior and updates in React.
Examples
Testing Button Click
Here's a simple example using Jest and React Testing Library to test a button click:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('should update the text', () => {
render(<MyButtonComponent />);
const button = screen.getByRole('button', { name: /click me/i });
await userEvent.click(button);
expect(screen.getByText('Button clicked')).toBeInTheDocument();
});
In this example, userEvent.click()
simulates a real user clicking the button, making the test more reflective of actual user behavior.
Testing Toggle Button
it('should render toggle show more/less button', async () => {
let showMoreButton = screen.getByRole('button', { name: /showmore/i });
let showLessButton = screen.queryByRole('button', { name: /showless/i });
expect(showMoreButton).toBeInTheDocument();
expect(showLessButton).not.toBeInTheDocument();
await userEvent.click(showMoreButton as HTMLButtonElement);
showMoreButton = screen.getByRole('button', { name: /showless/i });
showLessButton = screen.queryByRole('button', { name: /showmore/i });
expect(showMoreButton).toBeInTheDocument();
expect(showLessButton).not.toBeInTheDocument();
});
When we are trying to assert elements that are not yet visible on the DOM, we can use the `queryBy` method variants. Read more about this here: Using query* variants*
Testing User Navigation
Here is a trickier scenario where we are testing the navigation of the user:
First, we create a Router Mock. This can be done by creating a new file: __mocks__/router.ts
, which looks something like this:
import { NextRouter } from "next/router";
const createMockRouter = (router: Partial<NextRouter>): NextRouter => {
return {
basePath: "",
pathname: "/",
route: "/",
query: {},
asPath: "/",
forward: jest.fn(),
back: jest.fn(),
beforePopState: jest.fn(),
prefetch: jest.fn(() => Promise.resolve()),
push: jest.fn(),
reload: jest.fn(),
replace: jest.fn(),
events: {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
isFallback: false,
isLocaleDomain: false,
isReady: true,
defaultLocale: "en",
domainLocales: [],
isPreview: false,
...router,
};
};
export default createMockRouter;
This allows us to use Next Router
when testing for User Navigation. Look at the example code below:
import { RouterContext } from "next/dist/shared/lib/router-context";
import createMockRouter from "__mocks__/router";
const router = createMockRouter({});
it("should navigate to register page", async () => {
const user = userEvent.setup();
render(
<RouterContext.Provider value={router}>
<LoginForm />
</RouterContext.Provider>
);
await user.click(screen.getByRole("link", { name: /Register/i }));
expect(router.push).toHaveBeenCalled();
});
Testing Form Submit
Many times, we have complex forms that are very important to our business running smoothly, so we can't risk them not working. Here is how we can write a simple test for a form:
First, we set up MSW (Mock Service Worker) to handle API requests made by the form. This will ensure we have more control over the responses, so we can test for different scenarios.
import { rest } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
rest.post(testApiUrl("/api/login"), (req, res, ctx) => {
return res(ctx.status(200), ctx.json(true));
})
);
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
After, we can test the Login form that hits the /api/login
endpoint:
import { FormProvider, useForm } from "react-hook-form";
it("should submit form", async () => {
const user = userEvent.setup();
const methods = useForm();
return (
<FormProvider {...methods}>
<LoginForm />
</FormProvider>
);
const emailInput = screen.getByRole("textbox", { name: /Email Address/i });
const passwordInput = screen.getByPlaceholderText("Enter Password");
await user.type(emailInput, "test@infinum.com");
await user.type(passwordInput, "password");
await user.click(screen.getByRole("button", { name: /Submit/i }));
expect(screen.getByText("Login Successful.")).toBeInTheDocument();
});
Best Practices
Testing user actions is an essential part of ensuring that your web application is robust and user-friendly. However, the way you approach this testing can have a significant impact on its effectiveness. Below are some do's and don'ts to consider when testing user actions in your application.
Do's:
Always use userEvent: Use
userEvent
to simulate user actions as closely as possible to how a real user would interact with the application.Do Use Realistic Test Data: Use data that closely mimics what actual users would input. This makes your tests more reliable and likely to catch edge cases.
Do Test Multiple User Flows: Don't just test the "happy path." Consider different user behaviors, such as what might happen if a user double-clicks a button, inputs invalid data, or tries to submit a form without filling it out.
Do Check for Accessibility: Use tools and techniques to ensure that your application is accessible. Test keyboard navigation, screen reader compatibility, and other accessibility features.
Do Test Asynchrony: If the user action triggers asynchronous operations like API calls, make sure to account for that in your tests.
Do Validate UI Changes: After a user action, check that the UI updates as expected. This could involve new elements becoming visible, text changing, or items being added to a list.
Do Use Descriptive Test Names: Make sure that the names of your test cases clearly indicate what is being tested.
Don'ts:
Don't Only Test the JavaScript: While it’s crucial to test how your JavaScript handles user actions, don’t forget to test the end result from a user’s perspective, such as whether the correct page elements are displayed.
Don't Ignore Edge Cases: Users rarely follow the exact path you expect them to. Always test edge cases to make sure your application can handle them gracefully.
Don't Assume Users Will Follow Instructions: Users might not use your application the way you intend. Test for unintended or incorrect user actions as well.
Don't Rely Solely on Unit Tests: While unit tests are helpful, they can't capture the full range of user interactions with your application. Make sure to include integration and end-to-end tests.
Don't Hardcode Test Data: Hardcoding values can make your tests brittle and less reusable. Whenever possible, generate test data programmatically or fetch it from a controlled source.
Don't Skip Cleanup: Make sure to reset any changes your tests make to the application state so that one test doesn't affect the outcome of another.
By keeping these do's and don'ts in mind, you'll be better equipped to write effective tests for user actions, leading to a more robust and reliable application.
Suggested Reading
- Common mistakes with React Testing Library
- Avoid Nesting when you're Testing
- Common Testing Mistakes
- Static vs Unit vs Integration vs E2E Testing for Frontend Apps
- Stop mocking fetch
- Write tests. Not too many. Mostly integration.
- Write fewer, longer tests
- Test Isolation with React
- How to know what to test