User actions
Last modified on Tue 21 May 2024

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:

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:

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.

What is userEvent?

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.

Why Use userEvent?

  1. 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 use userEvent.type, it doesn't just change the value of an input; it also fires the appropriate keydown, keyup, and change events in the correct sequence.

  2. Ease of Use: userEvent offers a more straightforward, readable API for simulating complex user interactions, making your tests easier to write and understand.

  3. 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.

  4. 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.

Commonly Used userEvent Methods

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:

  1. Always use userEvent: Use userEvent to simulate user actions as closely as possible to how a real user would interact with the application.

  2. 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.

  3. 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.

  4. 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.

  5. Do Test Asynchrony: If the user action triggers asynchronous operations like API calls, make sure to account for that in your tests.

  6. 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.

  7. Do Use Descriptive Test Names: Make sure that the names of your test cases clearly indicate what is being tested.

Don'ts:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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