CS 312 - Software Development

CS 312 - Practical Four

Goals

  • Learn some basic techniques for testing React apps
  • Get some experience using Jest and the Testing Library
  • Get some more TDD practice

Prerequisites

  1. Click through to the GitHub classroom assignment to create your private repository. Then clone that newly created repository to your local computer as you have done previously.

  2. Install the package dependencies by running npm install inside the root directory of the newly cloned repository.

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][smoke]. Unlike the testing we saw earlier, we aren't going to assert anything, nor will we test anything explicitly. 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 added any errors in unexpected places. 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 side of the world, where a smoke test means "plug it in and see if smoke comes out").

For our smoke test, we will test the whole render tree of the FilmExplorer component. You will find the file FilmExplorer.test.js in the src/components directory. We have already provided the imports for you.

As mentioned during lecture, we need to mock the fetch function to deal with the fact that FilmExplorer wants to get data from the server. The test file already includes a set of sample films and the the mocked server described during the lecture.

Now we are ready to write the smoke test, which simply looks like this:

test('Smoke test', async () => {
  render(<FilmExplorer />);
  await act(async () => {
    await fetchMock.flush(true);
  });
});

The first line renders the component. The second line is a bit more complicated. fetch is an asynchronous operation. So, if we just call render, we don't wait around for the component to call fetch and re-render with the content.

fetch-mock.flush() returns a Promise that doesn't resolve until the fetch calls are complete and the body has re-rendered (the await is because of the Promise).

To test React components, we need to wrap any rendering in act(). Mostly, we can ignore this, because the testing library already does this for us. However, in this instance, we aren't using a Testing Library function, so we need to do it. Since we need to make the function we pass to act asynchronous to deal with the Promise, we also have to wait for act to complete.

Don't worry if you didn't understand all of that. In general, we will be querying the DOM after any fetch with a findBy* and the Testing Library will take care of most of those details.

Snapshots

Jest provides another quick regression testing tool called 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 JSON description of the component that can be quickly compared.

Here is the snapshot test:

test('Snapshot test', async () => {
  const { container } = render(<FilmExplorer />);
  await act(async () => {
    await fetchMock.flush(true);
  });

  expect(container.firstChild).toMatchSnapshot();
});

This time we are getting the container from the render function. The container is simply a div containing the rendered element. To get the actual root node, we ask for container.firstChild.

Note that we are explicitly calling act again because we are still not performing any tests from the Testing Library -- this is pure jest.

Note also that we didn't write anything to generate the snapshot. Jest will do that automatically the first time the test is run.

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 SearchBar.js and find where the place where write the name of the app ("FilmExplorer") 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. If you are running the test watcher (npm test), you can just type u, and Jest will update the snapshot. Go ahead and update your snapshot to acknowledge your change.

Can you use snapshots for TDD?

Answer

TDD with React

If you are really paying attention, you will see that there is a new feature that has been added to Film Explorer. There is a small arrow next to the sort tool. If you click it, nothing happens, but we would like it to change the sort order of the films.

If you look in the code, you will see that the FilmExplorer component has a new piece of state called ascending, which is passed down to SearchBar to determine the direction of the arrow, but currently the state is not updated by clicking the arrow. You will now practice some Test Driven Development (TDD) by writing tests to reflect what the ascending prop should do, and then writing the code so that it does do it.

Testing state changes

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

  • Test that we are in the initial state
  • Initiate an action that should change state
  • Test that we are in the new state
  • Initiate action to return state to original
  • Test that we are in original state

The first step is often overlooked, but important to establish that the state change is moving from 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 binary toggle like our arrow.

Add a new test to FilmExplorer.test.js called 'Arrow changes direction'. Start by copying the code from the smoke test to get the component mounted and initialized with the data (including the act call).

We need to find the arrow component in order to test that it changes its display to reflect state changes, and also to simulate clicking it to initiate that change.

To find the component, we will use let arrow = screen.queryByText('▲'). There are other options, but since the arrow is implemented as a single character, this is a pretty unique text string to look for.

We are also using getBy because it will allow us to query for things not being in the DOM rather than throwing an error. We will test if the component is visible using the matcher toBeInTheDocument().

If the state changes, we can detect it, because our '▲' will be gone and replaced with '▼'.

To simulate the click, we will use fireEvent.click(arrow).

Putting this all together, you will get something like this (while you could just copy and paste this, I recommend typing it in to build up a little muscle memory):

test('Arrow changes direction', async () => {
  render(<FilmExplorer />);
  await act(async () => {
    await fetchMock.flush(true);
  });
  let arrow = screen.queryByText('▲');
  expect(arrow).toBeInTheDocument();
  fireEvent.click(arrow);
  expect(screen.queryByText('▲')).not.toBeInTheDocument();
  arrow = screen.queryByText('▼');
  expect(arrow).toBeInTheDocument();
  fireEvent.click(arrow);
  expect(screen.queryByText('▼')).not.toBeInTheDocument();
  expect(screen.queryByText('▲')).toBeInTheDocument();
});

If you run the test, the test should fail. To get the state to update properly, FilmExplorer needs to pass down the setDirection callback to SearchBar (i.e., add it as a prop to SearchBar). You will also need to add an onClick to the span holding the arrow that sets the direction to the opposite of the current value of ascending.

Get sorting working

Clicking the arrow should now flip it back and forth, but it doesn't change the sort order, which it seems like it should. To make this happen, we need to turn our attention to FilmTableContainer, the other component rendered by FilmExplorer.

As its name suggests, FilmTableContainer is a "container component" (CC). It implements the film filtering and sorting. The actual presentation of the films is handled by the FilmTable (a "presentation component" or PC). FilmTableContainer works by transforming the Array of films its receives as a prop to create a new Array that is passed to FilmTable as a prop. FilmExplorer is also already providing the value of ascending to FilmTableContainer as a prop, so we just have to worry about what FilmTableContainer is doing with it.

Inside of the components directory, you will find FilmTableContainer.test.js, which already includes a collection of tests. We will walk through some of these before adding some new ones.

You should also note that we have again provided some dummy films for our tests. This time, however, we don't have to worry about mocking fetch. We are isolating FilmTableContainer, which allows us to just pass the films in as props as they normally would be.

Next we will examine some of the tests.

test('Empty string does not filter films', () => {
  render(
    <FilmTableContainer
      films={films}
      searchTerm=""
      sortType="title"
      setRatingFor={jest.fn}
      ascending={true}
    />
  );

  expect(screen.queryByText(films[0].title)).toBeInTheDocument();
  expect(screen.queryByText(films[1].title)).toBeInTheDocument();
});

Since there is no filtering, we expect both of the films to be present in the DOM.

test('Any substring satisfies the filter', () => {
  render(
    <FilmTableContainer
      films={films}
      searchTerm="sub"
      sortType="title"
      setRatingFor={jest.fn}
      ascending={true}
    />
  );

  expect(screen.queryByText(films[0].title)).toBeInTheDocument();
  expect(screen.queryByText(films[1].title)).not.toBeInTheDocument();
});

In the above test, we are looking at the filtering behavior of the component. In order to do that, we need to introduce a search term.

Of course, for our new feature, we want to think about the sorting order. We have created another test suite to group these tests.

Here we have a test that looks at the sorting order for the title field. Note that we repeat the check, change, check pattern.

test('Sorts by title', () => {
  const { rerender } = render(
    <FilmTableContainer
      films={films}
      searchTerm="word"
      sortType="title"
      setRatingFor={jest.fn}
      ascending={true}
    />
  );
  let items = screen.queryAllByRole('heading').map((item) => item.textContent);

  expect(items).toEqual([films[0].title, films[1].title]);

  rerender(
    <FilmTableContainer
      films={films}
      searchTerm="word"
      sortType="title"
      setRatingFor={jest.fn}
      ascending={false}
    />
  );

  items = screen.queryAllByRole('heading').map((item) => item.textContent);

  expect(items).toEqual([films[1].title, films[0].title]);
});

This time, we are using the rerender function to update the props on the component. Notice also how we are doing the test. We find all "heading" objects, which will be the film titles. We then extract their text content with a map. We can then compare this directly to an array of the film titles in the correct order.

Add two more tests that perform the appropriate checks for release_date and vote_average. The "descending" order tests should fail (remember we are employing TDD where first we write the test, then we write the code).

Now fix FilmTableContainer so that the tests no longer fail. The sort method does not have a parameter to switch the comparison order so you will need to think about a different way to switch the order.

Finishing Up

  1. Add and commit your changes and push those commit(s) to GitHub.
  2. Submit your repository to Gradescope