Practical: Testing React apps with Jest and React Testing Library

Initial Due Date: 2025-03-06 9:45AM
Final Due Date: 2025-03-27 4:15PM

Goals

Prerequisites

  1. Create the git repository for your practical by accepting the assignment from GitHub Classroom. This will create a new repository with a skeleton application already setup for you.
  2. Clone the repository to you computer with đź’» git clone (get the name of the repository from GitHub).
  3. Open up the package.json file and add your name as the author of the package.
  4. Install the module dependencies by executing đź’» pnpm install in the terminal.

You previously used Jest for unit testing JS code. Today we are going to use Jest in combination with the Testing Library library to test a React application. There are a number of items that need to be installed, but the project skeleton includes everything you need.

Regression Tests

Smoke test

The easiest form of testing we can perform is called a smoke test. Unlike the testing we saw earlier, we aren’t going to take any actions or assert anything. All we will do is try to render a component. If the process throws any errors, the test will fail, otherwise it succeeds. This kind of test is great for quick and dirty regression testing, where we are trying to make sure that adding new features or fixing bugs hasn’t inadvertently introduced any errors. Note that it doesn’t actually tell you that the functionality hasn’t been broken, just that it didn’t catch fire as it were (the name comes from the hardware development, where a smoke test means “plug it in and see if smoke comes out”).

For our smoke test, we will render the entire App component. You will find the file distance.test.js in the src/__tests__ directory. The skeleton provides the imports for you. All you need to do is render the component; add the following to create your smoke test:

test("Smoke test", () => {
  render(<App />);
});

Snapshots

Jest provides another quick regression testing tool, snapshots. You take a snapshot of the component at a time when you like the way it looks. Jest saves a representation of the component, and then every time you run the tests, Jest regenerates the component and compares it to the snapshot. If the component changes, the test will fail, which is a cue to either fix the offending code, or to take a new snapshot because the change was intentional. Note that the snapshot is not a literal picture, it is a textual description of the component that can be quickly compared. Add the following to distance.test.js to create the snapshot:

test("Snapshot test", () => {
  const { asFragment } = render(<App />);
  expect(asFragment()).toMatchSnapshot();
});

Here we are getting the asFragment function from the render function. The asFragment function returns a DocumentFragment with the rendered component. We then snapshot that fragment.

Note that we didn’t write anything to generate the snapshot. Jest will do that automatically the first time the test is run. Go ahead and run the tests. You will find that Jest has created a new directory called __snapshots__ in the same directory as your test file. Open this up and look at the snapshot that is stored in there. This should be committed with your code so that subsequent tests can use it.

So that you can see how the snapshot test works, go into src/pages/index.js and find where the place where write the name of the app (“Distance Converter”) and change it to something else. If you run the test now, the snapshot test will fail. Notice that you are provided with a diff showing what has changed. Of course, sometimes you will be making a change and you want the page to be different. You can update your snapshot with 💻 pnpm test -u (or if you are running the test watcher, 💻 pnpm test --watch, you can just type u). Update your snapshot to acknowledge your change.

Can you use snapshots for TDD? Peek at the answer.

No. Recall in TDD we write the tests first. But snapshots require a working implementation. Snapshots are most useful for regression testing.

Testing React Applications

Testing DistanceUnit

The DistanceConverter is composed of DistanceUnits. Let’s start our testing there. Here is an initial “Arrange” step, where we render the DistanceUnit component with a mock function for the callback. The actions involve the numeric input, so we need to obtain that element using RTL’s query tools. We can use the website https://testing-playground.com to help us figure out good queries (amongst the many possibilities).

  1. The screen.debug() call prints the generated DOM to the console. Copy the printed DOM into https://testing-playground.com.
  2. Click on the “miles” input field to get suggested queries.
  3. Notice the suggested query getByRole uses the ARIA role for the number input, made more precise by matching (with a regular expression for a case-insensitive substring match) the name of the associated label.
    screen.getByRole('spinbutton', { name: /miles/i });
    
describe("DistanceUnit", () => {
  test("DistanceUnit invokes callback with converted input", () => {
    // Arrange
    const setMetersMock = jest.fn();
    render(
      <DistanceUnit
        label="miles"
        valueInMeters={0}
        setMeters={setMetersMock}
        conversionFactor={0.000621371}
      />,
    );
    screen.debug(); // Render DOM to console
  });
});

Now, remove screen.debug() and insert the query and the “arrange” assertion on the initial value:

const input = screen.getByRole("spinbutton", { name: /miles/i });
expect(input).toHaveValue(0);

The “action” is to enter a distance. Add the following fireEvent call to update the input value.

fireEvent.change(input, { target: { value: 1 } });

Consider the assertions. Note that the input in DistanceUnit is a controlled component (controlled by the valueInMeters prop). Thus the action won’t actually change the value. Instead it will trigger the callback. Add assertions that the value remains the same and the callback is invoked with the expected value (based on the conversion factor for miles to meters). For example:

expect(input).toHaveValue(0);
expect(setMetersMock).toHaveBeenCalledWith(
  expect.closeTo(1 / 0.000621371, 2),
);

Run the tests to verify they pass. Temporarily change the miles conversion factor in the component (i.e., break the component) to verifying that the tests are actually catching errors.

Testing DistanceConverter

We have a general pattern that we follow when writing tests around state changes.

  1. Assert that we are in the initial state
  2. Act to change the state
  3. Assert that we are in the new state
  4. Act to return to the original state
  5. Assert that we are in original state.

The first step is often overlooked, but important to establish that the system is “Arranged” in a known baseline state. Without that, we can’t know that a state change actually occurred. The last two steps are less important, but worth doing when the state is a form of binary toggle (less relevant here).

In the existing test for DistanceConverter add assertions to verify the initial state of the component. In this case, we expect all the units to have a value of 0 (based on the initial value of the meters state). Recall that we can select specific inputs by specifying the relevant “name” for the number input. Make sure you are testing all the different units (think about how you could use forEach to do so concisely). As a suggestion consider making an array of objects with the expected labels and conversion factors that you can use to implement your tests. For example:

const units = [
  { label: "meters", fromMeters: 1 },
  { label: "miles", fromMeters: 0.000621371 },
]

Above we used a hard-coded regular expression literal (/meters/i) to match the button by label. But now we want to match based on a variable, e.g., label. To do so, we can create the regular expression dynamically. For example, assuming label is assigned "meters", the following

screen.getByRole("spinbutton", {
  name: new RegExp(label, "i"),
});

is equivalent to screen.getByRole("spinbutton", { name: /miles/i }).

The skeleton includes an “Act” step that updates the “meters” input to be 1. Add a corresponding set of assertions to verify the new state. One such assertion is included in the skeleton. Notice we extract the current value of the component, which is a string, convert it to a Number and compare the expected value within 2 decimal places.

Applying TDD for a new feature

Recall that in TDD, we first write the tests then the code. We want to add inches as a unit to the converter component (i.e., we should have miles, km, feet, meters and inches). Start by extending your tests to include an “inches” input. At this point the tests should fail since you have’t yet implemented the necessary code. Now extend the DistanceConverter component (in src/pages/index.js) to includes inches. At this point your tests should all pass!

Finishing Up

  1. Make sure the tests are passing (with đź’» pnpm test) and there are no linting errors (with đź’» pnpm run lint)
  2. Add and commit your changes and push those commit(s) to GitHub.
  3. Submit your repository to Gradescope as described here. Note that for this practical the Gradescope “Autograder Output” should include tests that appear to fail. That is intentional, i.e., we purposely try your tests with a broken component to make sure they catch the error (i.e., we are expecting failure). Pay attention to whether the tests have green labels (i.e., are passing) or not. Investigate any tests with red labels.

Grading

Required functionality:

Recall that the Practical exercises are evaluated as “Satisfactory/Not yet satisfactory”. Your submission will need to implement all of the required functionality (i.e., pass all the tests) to be Satisfactory (2 points).