Friday, March 29

React Integration Testing: Greater Coverage, Fewer Tests

Integration tests are a natural fit for interactive websites, like ones you might build with React. They validate how a user interacts with your app without the overhead of end-to-end testing.

This article follows an exercise that starts with a simple website, validates behavior with unit and integration tests, and demonstrates how integration testing delivers greater value from fewer lines of code. The content assumes a familiarity with React and testing in JavaScript. Experience with Jest and React Testing Library is helpful but not required.

There are three types of tests:

  • Unit tests verify one piece of code in isolation. They are easy to write, but can miss the big picture.
  • End-to-end tests (E2E) use an automation framework — such as Cypress or Selenium — to interact with your site like a user: loading pages, filling out forms, clicking buttons, etc. They are generally slower to write and run, but closely match the real user experience.
  • Integration tests fall somewhere in between. They validate how multiple units of your application work together but are more lightweight than E2E tests. Jest, for example, comes with a few built-in utilities to facilitate integration testing; Jest uses jsdom under the hood to emulate common browser APIs with less overhead than automation, and its robust mocking tools can stub out external API calls.

Another wrinkle: In React apps, unit and integration are written the same way, with the same tools. 

Getting started with React tests

I created a simple React app (available on GitHub) with a login form. I wired this up to reqres.in, a handy API I found for testing front-end projects.

You can log in successfully:

…or encounter an error message from the API:

The code is structured like this:

LoginModule/
├── components/
⎪   ├── Login.js // renders LoginForm, error messages, and login confirmation
⎪   └── LoginForm.js // renders login form fields and button
├── hooks/
⎪    └── useLogin.js // connects to API and manages state
└── index.js // stitches everything together

Option 1: Unit tests

If you’re like me, and like writing tests — perhaps with your headphones on and something good on Spotify — then you might be tempted to knock out a unit test for every file.

Even if you’re not a testing aficionado, you might be working on a project that’s “trying to be good with testing” without a clear strategy and a testing approach of “I guess each file should have its own test?”

That would look something like this (where I’ve added unit to test file names for clarity):

LoginModule/
├── components/
⎪   ├── Login.js
⎪   ├── Login.unit.test.js
⎪   ├── LoginForm.js
⎪   └── LoginForm.unit.test.js
├── hooks/
⎪   ├── useLogin.js 
⎪   └── useLogin.unit.test.js
├── index.js
└── index.unit.test.js

I went through the exercise of adding each of these unit tests on on GitHub, and created a test:coverage:unit  script to generate a coverage report (a built-in feature of Jest). We can get to 100% coverage with the four unit test files:

100% coverage is usually overkill, but it’s achievable for such a simple codebase.

Let’s dig into one of the unit tests created for the onLogin React hook. Don’t worry if you’re not well-versed in React hooks or how to test them.

test('successful login flow', async () => {
  // mock a successful API response
  jest
    .spyOn(window, 'fetch')
    .mockResolvedValue({ json: () => ({ token: '123' }) });


  const { result, waitForNextUpdate } = renderHook(() => useLogin());


  act(() => {
    result.current.onSubmit({
      email: 'test@email.com',
      password: 'password',
    });
  });


  // sets state to pending
  expect(result.current.state).toEqual({
    status: 'pending',
    user: null,
    error: null,
  });


  await waitForNextUpdate();


  // sets state to resolved, stores email address
  expect(result.current.state).toEqual({
    status: 'resolved',
    user: {
      email: 'test@email.com',
    },
    error: null,
  });
});

This test was fun to write (because React Hooks Testing Library makes testing hooks a breeze), but it has a few problems.

First, the test validates that a piece of internal state changes from 'pending' to 'resolved'; this implementation detail is not exposed to the user, and therefore, probably not a good thing to be testing. If we refactor the app, we’ll have to update this test, even if nothing changes from the user’s perspective.

Additionally, as a unit test, this is just part of the picture. If we want to validate other features of the login flow, such as the submit button text changing to “Loading,” we’ll have to do so in a different test file.

Option 2: Integration tests

Let’s consider the alternative approach of adding one integration test to validate this flow:

LoginModule/
├── components/
⎪   ├─ Login.js
⎪   └── LoginForm.js
├── hooks/
⎪   └── useLogin.js 
├── index.js
└── index.integration.test.js

I implemented this test and a test:coverage:integration script to generate a coverage report. Just like the unit tests, we can get to 100% coverage, but this time it’s all in one file and requires fewer lines of code.

Here’s the integration test covering a successful login flow:

test('successful login', async () => {
  // mock a successful API response
  jest
    .spyOn(window, 'fetch')
    .mockResolvedValue({ json: () => ({ token: '123' }) });


  const { getByLabelText, getByText, getByRole } = render(<LoginModule />);


  const emailField = getByLabelText('Email');
  const passwordField = getByLabelText('Password');
  const button = getByRole('button');


  // fill out and submit form
  fireEvent.change(emailField, { target: { value: 'test@email.com' } });
  fireEvent.change(passwordField, { target: { value: 'password' } });
  fireEvent.click(button);


  // it sets loading state
  expect(button.disabled).toBe(true);
  expect(button.textContent).toBe('Loading...');


  await waitFor(() => {
    // it hides form elements
    expect(button).not.toBeInTheDocument();
    expect(emailField).not.toBeInTheDocument();
    expect(passwordField).not.toBeInTheDocument();


    // it displays success text and email address
    const loggedInText = getByText('Logged in as');
    expect(loggedInText).toBeInTheDocument();
    const emailAddressText = getByText('test@email.com');
    expect(emailAddressText).toBeInTheDocument();
  });
});

I really like this test, because it validates the entire login flow from the user’s perspective: the form, the loading state, and the success confirmation message. Integration tests work really well for React apps for precisely this use case; the user experience is the thing we want to test, and that almost always involves several different pieces of code working together.

This test has no specific knowledge of the components or hook that makes the expected behavior work, and that’s good. We should be able to rewrite and restructure such implementation details without breaking the tests, so long as the user experience remains the same.

I’m not going to dig into the other integration tests for the login flow’s initial state and error handling, but I encourage you to check them out on GitHub.

So, what does need a unit test?

Rather than thinking about unit vs. integration tests, let’s back up and think about how we decide what needs to be tested in the first place. LoginModule needs to be tested because it’s an entity we want consumers (other files in the app) to be able to use with confidence.

The onLogin hook, on the other hand, does not need to be tested because it’s only an implementation detail of LoginModule. If our needs change, however, and onLogin has use cases elsewhere, then we would want to add our own (unit) tests to validate its functionality as a reusable utility. (We’d also want to move the file because it wouldn’t be specific to LoginModule anymore.)

There are still plenty of use cases for unit tests, such as the need to validate reusable selectors, hooks, and plain functions. When developing your code, you might also find it helpful to practice test-driven development with a unit test, even if you later move that logic higher up to an integration test.

Additionally, unit tests do a great job of exhaustively testing against multiple inputs and use cases. For example, if my form needed to show inline validations for various scenarios (e.g. invalid email, missing password, short password), I would cover one representative case in an integration test, then dig into the specific cases in a unit test.

Other goodies

While we’re here, I want to touch on few syntactic tricks that helped my integration tests stay clear and organized.

Big waitFor Blocks

Our test needs to account for the delay between the loading and success states of LoginModule:

const button = getByRole('button');
fireEvent.click(button);


expect(button).not.toBeInTheDocument(); // too soon, the button is still there!

We can do this with DOM Testing Library’s waitFor helper:

const button = getByRole('button');
fireEvent.click(button);


await waitFor(() => {
  expect(button).not.toBeInTheDocument(); // ahh, that's better
});

But, what if we want to test some other items too? There aren’t a lot of good examples of how to handle this online, and in past projects, I’ve dropped additional items outside of the waitFor:

// wait for the button
await waitFor(() => {
  expect(button).not.toBeInTheDocument();
});


// then test the confirmation message
const confirmationText = getByText('Logged in as test@email.com');
expect(confirmationText).toBeInTheDocument();

This works, but I don’t like it because it makes the button condition look special, even though we could just as easily switch the order of these statements:

// wait for the confirmation message
await waitFor(() => {
  const confirmationText = getByText('Logged in as test@email.com');
  expect(confirmationText).toBeInTheDocument();
});


// then test the button
expect(button).not.toBeInTheDocument();

It’s much better, in my opinion, to group everything related to the same update together inside the waitFor callback:

await waitFor(() => {
  expect(button).not.toBeInTheDocument();
  
  const confirmationText = getByText('Logged in as test@email.com');
  expect(confirmationText).toBeInTheDocument();
});

Interestingly, an empty waitFor will also get the job done, because waitFor has a default timeout of 50ms. I find this slightly less declarative than putting your expectations inside of the waitFor, but some indentation-averse developers may prefer it:

await waitFor(() => {}); // or maybe a custom util, `await waitForRerender()`


expect(button).not.toBeInTheDocument(); // I pass!

For tests with a few steps, we can have multiple waitFor blocks in row:

const button = getByRole('button');
const emailField = getByLabelText('Email');


// fill out form
fireEvent.change(emailField, { target: { value: 'test@email.com' } });


await waitFor(() => {
  // check button is enabled
  expect(button.disabled).toBe(false);
});


// submit form
fireEvent.click(button);


await waitFor(() => {
  // check button is no longer present
  expect(button).not.toBeInTheDocument();
});

Inline it comments

Another testing best practice is to write fewer, longer tests; this allows you to correlate your test cases to significant user flows while keeping tests isolated to avoid unexpected behavior. I subscribe to this approach, but it can present challenges in keeping code organized and documenting desired behavior. We need future developers to be able to return to a test and understand what it’s doing, why it’s failing, etc.

For example, let’s say one of these expectations starts to fail:

it('handles a successful login flow', async () => {
  // beginning of test hidden for clarity


  expect(button.disabled).toBe(true);
  expect(button.textContent).toBe('Loading...');


  await waitFor(() => {
    expect(button).not.toBeInTheDocument();
    expect(emailField).not.toBeInTheDocument();
    expect(passwordField).not.toBeInTheDocument();


    const confirmationText = getByText('Logged in as test@email.com');
    expect(confirmationText).toBeInTheDocument();
  });
});

A developer looking into this can’t easily determine what is being tested and might have trouble deciding whether the failure is a bug (meaning we should fix the code) or a change in behavior (meaning we should fix the test).

My favorite solution to this problem is using the lesser-known test syntax for each test, and adding inline it-style comments describing each key behavior being tested:

test('successful login', async () => {
  // beginning of test hidden for clarity


  // it sets loading state
  expect(button.disabled).toBe(true);
  expect(button.textContent).toBe('Loading...');


  await waitFor(() => {
    // it hides form elements
    expect(button).not.toBeInTheDocument();
    expect(emailField).not.toBeInTheDocument();
    expect(passwordField).not.toBeInTheDocument();


    // it displays success text and email address
    const confirmationText = getByText('Logged in as test@email.com');
    expect(confirmationText).toBeInTheDocument();
  });
});

These comments don’t magically integrate with Jest, so if you get a failure, the failing test name will correspond to the argument you passed to your test tag, in this case 'successful login'. However, Jest’s error messages contain surrounding code, so these it comments still help identify the failing behavior. Here’s the error message I got when I removed the not from one of my expectations:

For even more explicit errors, there’s package called jest-expect-message that allows you to define error messages for each expectation:

expect(button, 'button is still in document').not.toBeInTheDocument();

Some developers prefer this approach, but I find it a little too granular in most situations, since a single it often involves multiple expectations.

Next steps for teams

Sometimes I wish we could make linter rules for humans. If so, we could set up a prefer-integration-tests rule for our teams and call it a day.

But alas, we need to find a more analog solution to encourage developers to opt for integration tests in a situation, like the LoginModule example we covered earlier. Like most things, this comes down to discussing your testing strategy as a team, agreeing on something that makes sense for the project, and — hopefully — documenting it in an ADR.

When coming up with a testing plan, we should avoid a culture that pressures developers to write a test for every file. Developers need to feel empowered to make smart testing decisions, without worrying that they’re “not testing enough.” Jest’s coverage reports can help with this by providing a sanity check that you’re achieving good coverage, even if the tests are consolidated that the integration level.

I still don’t consider myself an expert on integration tests, but going through this exercise helped me break down a use case where integration testing delivered greater value than unit testing. I hope that sharing this with your team, or going through a similar exercise on your codebase, will help guide you in incorporating integration tests into your workflow.

Source: CSS-tricks.com

0 0 votes
Article Rating
Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x