CS 312 Software Development

CS 312 - Practical Six

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. Visit the Practical 6 page on Replit (or click the 'Start Project' link for Practical 6 on our team page).

  2. Click through to the GitHub classroom assignment to create your private repository.

  3. Return to the assignment on Replit and go to the Version Control panel.

  4. Click the button to create a new git repository (not the one for connecting to an existing repo).

  5. Open the shell and follow the instructions on your GitHub repository for connecting an existing repository:

    1. git remote add origin repo-name where repo-name is the name of your repository (e.g., https://github.com/csci312-s21/practical06-ChristopherPAndrews.git)
    2. git branch -m main
    3. git push -u origin main

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. I 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. You can update your snapshot with npm test -- -u (if you are running the test watcher (npm test), you can just type u). 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 along with its setter setDirection to determine the direction of the arrow, but currently the arrow doesn't make use of the state. 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'. Note that we are testing the FilmExplorer component, not the SearchBar component. While the indicator is in the SearchBar, the state is in FilmExplorer. We could simulate the clicks and make sure that the callback function was called with the correct value, but we are going to be satisfied with more of an integration test here.

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 queryBy 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(). With jest, we can always invert a test by adding a not to the matcher (e.g., .not.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).

Put these together to write a test of this scenario:

Given that the page is rendered and the arrow defaults to "▲", when the user clicks on the arrow it reverses direction to become "▼". When the user clicks again, the "▲" is restored.

The test should

  • render FilmExplorer
  • check that "▲" is in the document
  • simulate a click on the up arrow
  • check that "▲" is no longer in the document
  • check that "▼" is in the document
  • simulates a click on the down arrow
  • check that "▼" is no longer in the document
  • check that "▲" is in the document

Run the test. It should fail (the arrow is current just text).

Let's update SearchBar so that the test passes. Look through the code to find where the arrow is displayed. You will see that it is a simple span with a text arrow in it. The first thing to do is fix the arrow itself so the direction is determined by the ascending prop. Replace the arrow character with {ascending ? "▲" : "▼"}.

Next, we need to add an onClick handler so that the user can actually interact with it. Since the value is just a Boolean, we can simply invert it whenever the user clicks: onClick={() => {setDirection(!ascending);}}.

Run the test again -- it should now pass.

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 I have again provided some dummy films for our tests (they are stripped down to the minimal fields needed 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.

Let's unpack some of the existing tests.

In the first one we are making sure that with an empty search term we see all films. Since there is no filtering, we expect all of the films to be present in the DOM. Notice that we are passing in jest.fn, the jest mock function, as a placeholder function for the setRating prop. We don't care about the callback, but we also don't want PropTypes to complain, so we have to pass in a valid function.

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

  films.forEach((film)=>{
    expect(screen.queryByText(film.title)).toBeInTheDocument();
  });
});

In the next test, we are looking at the filtering behavior of the component. In order to do that, we need to introduce a search term (in this case "sub" which only appears in the description of one film).

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();
  expect(screen.queryByText(films[2].title)).not.toBeInTheDocument();
});

Take a moment to read through the remaining tests in filtering test suite and make sure you understand what they are doing.

For our new feature, we want to think about the sorting order. I have created another test suite to group the tests dealing with sort order. Inside you will find one test that tests that the films are sorted by vote_average.

Let's break down this test as well. The challenge we have is figuring out what order items are displayed on the page (or more properly, in the DOM). The approach I took was to use screen.queryAllByRole("heading"). The "heading" role is grabbing the <h2> tags used to display the film titles, and by using the getAllBy* query, we are getting an array of the DOM elements that the Testing Library finds on the page in the order they appear. I then used map to extract the textual contents of those elements and put them in a new array.

The actual test part compares this new array of titles to an array of film titles assembled in the known correct order.

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

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

Of course, this test doesn't really test our new prop: ascending. Let's apply our "check, change, check" pattern here.

We want to have access to the rerender function so we can see what happens when we change the prop after it has already been rendered. So, change render to const { rerender } = render.

After the assertion that checks if we have the films in the right order, call rerender, passing it the FilmTableContainer just as we did to render, but this time set ascending to false. Then, repeat the two steps of gathering the list of film titles and comparing it to the known good ordering (which should be the reverse of the one we used in the first assertion, right?).

Once you have that written, repeat the process two more times to create tests "Sorts by title" and "Sorts by release_date". Look at the sample films to figure out which order you expect them to be. This is one of the few moments when it will be okay to copy a block of code, paste it back in and change a couple of small values.

Run the tests

Again, we are practicing TDD, so when you run the tests they should fail. However, I want to also take this moment to show you how to target the tests being run, which I've shown to some of you but not to the class as a whole.

By default, jest will look for tests in all files that end in .test.js, as well as any files in directories called __tests__. However, we can target a specific file by passing its name as an argument to the tool. In truth, it doesn't even need to be the full name of the file, jest will do case insensitive pattern matching. So, to just run the tests in FilmTableContainer.test.js, you can type npm test -- table. That will run all test files including the word table (which is just the one in this case).

We can get even more granular than that. For both test suites and individual tests, we can modify them with only or skip to focus in on the tests we are most interested in. So, to focus on a single test, you can write test.only(name, test-func) (see the docs for more details).

Okay, now that we have some failing tests, let's get the sort working properly.

The way that Array.sort works is we pass it a comparator function f(a,b), which is expected to return a value that is less than zero, equal to zero or greater than zero (corresponding to a < b, a == b, and a > b respectively).

In FilmTableContainer, find the place where the films are sorted. Replace the comparator function with the following:

(m1, m2) => {
  if (m1[sortType] < m2[sortType]) {
    return ascending ? -1 : 1;
  } else if (m1[sortType] === m2[sortType]) {
    return 0;
  }
  return ascending ? 1 : -1;
}

Run the tests again, and they should all pass.

Finishing Up

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

Last updated 04/02/2021