DEV Community

Javier Marquez
Javier Marquez

Posted on

Testing with React Testing Library using component instances

In the last year, I followed the trend in the industry, and I changed the way I test my React components from using Enzyme to embrace Testing Library.

The change has been for good! Now my tests are now more concise, clear and meaningful. They are especially easier to read because they don't have any code related to the component internals. And I love how one test can cover the interaction among many components, reaching very deep in the component tree.

But... sometimes I miss Enzyme.

Why to test React component instances

When switching to Testing Library, we focus on the UI trying to avoid any contact with the internal implementation of our React components. Our tests become like final users, that know how to detect elements, click on them, type on the inputs... and our app should just work, no matter how things are handled in the background.

I have also discussed with my workmates about what's the difference with Selenium or Cypress tests then? There should be any?

There are a lot of differences between the end to end tests we create with Selenium/Cypress to check our systems integration, and our deep React testing that should follow the specs without messing with servers or API interaction at all. But I see how there is some overlapping, and I understand people that get confused, especially if we see our React components only as UI.

React components are more than UI, they also contain logic, functionality that sometimes is much harder to test emulating a user clicking buttons, than with a good unit test.

Explosion of tests

Before showing an example of why sometimes it's nice to have access to the component instances in our testing, I'll share react-dom-instance, a library to find the component instances bound to DOM elements. It plays really nicely with React Testing Library, without the need of re-installing Enzyme again.

When creating tests with Testing Library, there is an issue that I stumble upon often. It's about having my logic is in a parent component and a(many) stateful child component(s) that I need to interact with multiple times in order to check if the parent does what I expect.

Let's say we have this todo list app (credits to Fumie Wada), rendered by the component <TodoApp />:

Alt Text

When we click on the "Create new item" link at the top right, a form is open to let us type a new todo item. The form is rendered by the <CreateForm /> component:

Alt Text

When the user opens the form, type the text of the item, check if it's important, select a color and click on the "Add" button, our TodoApp component should:

  • Create a new todo item with the text typed and the color selected.
  • If it was marked as important, the item is also added to another list.
  • Send an event to google analytics with the color selected, if it wasn't marked as important 🀷

In our TodoApp.spec.js file, we would have a test for this logic that would be something like:

it('should create an important item', () => {
  const { queryByTestId } = render( <TodoApp /> );

  fireEvent.click( queryByTestId('openButton') );
  fireEvent.input( queryByTestId('todoInput'), {target: {value: "Buy some bread"}} );
  fireEvent.click( queryByTestId('color_red') );
  fireEvent.click( queryByTestId('importantCheckbox') );
  fireEvent.click( queryByTestId('addButton') );

  // We had mocked some actions beforehand
  expect( createItem ).toHaveBeenCalledWith( "id1", "Buy some bread", "red" );
  expect( addToImportant ).toHaveBeenCalledWith( "id1" );
  expect( trackGAEvent ).not.toHaveBeenCalled();
});

We are testing so mucho up there, and there are many things that could be done better, but just ignore them for the sake of the example.

Let's focus on how the color that we clicked in a child component, it's the color that we are creating the todo with. We have many colors in the form, should we test all of them?

If we have not enabled the "Mark as important" option, should we check that we are tracking all the colors in Google Analytics properly again?

TodoApp component tests shouldn't care about how many colors are, instead they should focus on completing the expected tasks once any color has been selected. Not testing all the colors feels just bad, but all the clicking and typing we have to do for every test look very repetitive too.

The ability of React Testing Library to test nested components working together is amazing, but it tends to move the checks to the top of the React tree. When the children have many settings, we end up with really big test files in the top components, and those tests are usually made by repetitive tasks with small permutations of the checks we have to do. The more options we have in our child components, the bigger is the rate of growth for test cases in the parent component... it's like an explosion of tests.

Splitting test files using instances

The CreateForm component has no complex logic, simply let the user type a name, select if it's important and pick a color. It doesn't know what to do with that information but, for example, it's responsible for how many colors are available to pick.

We can listen to the user's selection thanks to the onAdd prop, so a test file for CreateForm looks like the perfect place for testing all the colors that are available:

it('should select the color red when clicking on the red input', () => {
  const onAddMock = jest.fn();
  const { queryByTestId } = render(
    <CreateForm onAdd={ onAddMock } />
  );

  fireEvent.click( queryByTestId('color_red') );
  fireEvent.click( queryByTestId('addButton') );

  expect( onAddMock.mock.calls[0].color ).toBe('red');
});

// ...the same for the rest of the colors

That's a simple and well-scoped test. Once we had tested all the colors individually for the CreateForm, we don't need to test them again for TodoApp.

We can trust that the rendered CreateForm instance will provide no-matter-what color and check the logic without all the clicking, but be sure that the components are integrated properly:

import { findInstance } from 'react-dom-instance';

it('should create an important item', () => {
  const { queryByTestId } = render( <TodoApp /> );
  const createForm = findInstance( queryByTestId('createForm') );

  // call the `onAdd` handler passed by `TodoApp` directly
  createForm.onAdd({
    text: "Buy some bread",
    color: "whatever",
    isImportant: true
  });

  // We had mocked some actions beforehand
  expect( createItem ).toHaveBeenCalledWith( "id1", "Buy some bread", "whatever" );
  expect( addToImportant ).toHaveBeenCalledWith( "id1" );
  expect( trackGAEvent ).not.toHaveBeenCalled();
});

In the TodoApp tests, we went from "I don't care how it works internally, just click things and see" to "I don't care what the user clicks, I expect to receive this". We are still not using the internals of the components, but we making the most of knowing their surface, their API.

It's not that we are not testing what user clicks, it's that we not need to repeat ourselves testing it in places that shouldn't depend on the exact user interaction. This approach has some advantages:

  • Tests cases are better scoped, besides their source code.
  • Changes in the CreateForm won't be breaking TodoApp tests, or, at least, we won't require multiple changes in it.
  • No more big testing files for parent components with exponential growth of test cases when adding UI options.

Not that bad huh?

When to use instances in our React tests

Splitting big test files is a nice example of how instances are handy in the way we test React components. There are other cases, like testing component's imperative methods, where they can be of help too.

But keep in mind that the fact that we can use instances in our tests, doesn't mean that we should do it everywhere. This not a matter of taste, like in "I prefer user interaction" or "I prefer instances".

It's about finding the sweet spot where we can do deep tests of a group of components together by emulating user interaction, while we can abstract that complexity from other places, where the user interaction is not the main thing to test.

I know that saying "finding the sweet spot" doesn't help on when to use instances, but it's not that hard. We all know when a test file is getting out of control. At that moment, identify an independent unit of functionality and extract its tests to its own file. With a little bit of practice, we'll quickly learn to foresee when it's a good idea to split :)


This is my first article in DEV.to and I enjoyed writing it! If you liked it, follow me, give me love and unicorns, and sure I'll write much more!

My twitter is @arqex.

Top comments (8)

Collapse
 
thomaslombart profile image
Thomas Lombart

Thanks for the post! I'm not sure if that would be the right way to do it though since you're not testing the connection between parents and children components anymore. For example, if onAdd gets renamed on the parent, your test would pass even if things would break.

What about having a big test on the parent that tests the happy path of your feature and multiple tests on the children that test edge cases and behaviors? That would still make sure the connection between your components work and still you could test the different components.

Also, be careful, you're making use of lots of data-testid which is an escape hatch, you should use other queries such as getByText (or queryByText), getByPlaceholder (or queryByPlaceholder). This makes sure you're really testing the app as a user which is the guiding principle of the library πŸ™‚

Collapse
 
arqex profile image
Javier Marquez

Hey thanks for your thoughts! You mean, if onAdd gets renamed in the children? If so I guess you are right, it's a weak point of the splitting, because we would keep trying if we passed the onAdd property on the parent but the children is not using it anymore.

The happy path you mention is a great practice, we don't need to test every possible permutation in the parent component, but testing one is very healthy. Totally agree!

As for the data-testid, you are right too, I just thought it would be simpler to follow in the article than if I start using roles, for example, to select the colors.

Collapse
 
thomaslombart profile image
Thomas Lombart

Yes, that's what I meant!

The thing that I found hard in Testing Library is that there are lots of ways to test your components and there's not a one-size-fits-all solution to a particular problem. I guess it's up to you to make trade-offs and see if in the end your tests give you confidence πŸ™‚

Collapse
 
alexweber15 profile image
Alex Weber

Thanks for sharing there's a lot of different things you can do in testing and it's always great to read about different tips...that said it's an enormous shame that react-dom-instance doesn't support functional components... I realize there's a handful of niche lifecycle methods not supported via hooks yet but does anyone still write class-based ones in 2020? :)

Collapse
 
arqex profile image
Javier Marquez

I was thinking about adding some support for functional components to react-dom-instance. It doesn't feel really well to say that it will be returning instances, because they aren't actual instances, but I think it would be great if it returned at least an object with the latest the props received by the component, so we can split the tests also for functional components.

Collapse
 
alexweber15 profile image
Alex Weber

Nice, that would definitely add value!

Collapse
 
wolverineks profile image
Kevin Sullivan

Does this still work with function components?

Collapse
 
arqex profile image
Javier Marquez

I'm afraid it doesn't. Function component have no instances, we would need to find a way of splitting the tests in a different way.