() => "Robert Prib"

How our team writes React tests and other stuff too

8 minute read

Have you ever worked in a codebase where each test file was different to the next? Different terminology and grouping of test scenarios; different usages of snapshot testing and react-testing-library.

The effect of this meant wasted time reading a full test file to understand its structure before adding another test case. Creating new tests involved making multiple decisions; should I copy the structure of an existing test or create a new one? Should I use snapshot tests?

We solved this problem by defining guidelines to follow, that make writing tests that are easy to read and write, and would reduced wasted time on decisions.

Our guidelines are as follows:

  • Group tests by render and user behaviours
  • Use a snapshot test to validate render
  • Use GIVEN-WHEN-THEN to describe branching logic
  • Don't be afraid to stub child components also know in Jest as mocking
  • Don't re-test functionality of other components
  • Use a single expect statement per test
  • Accept unit testing UI components can be difficult
  • Try use screen.getByRole
  • Isolate complex component logic, and test separately

For each guideline I go into greater detail using this Counter component as an example for our testing purposes:

const Counter = ({ initialCount, steps }) => {
    const [{ count, action }, setCount] = useState(initialCount, action: null });

    const increment = () => {
        setCount(({count }) => { count: count + steps, action: "increment-click" });

    }

    const inputChange = (e) => {
        setCount(({count }) => { count: e.target.value, action: "input-change" });
    }

    return (<>
        <input name="counter" value={count} onChange={inputChange} />
        <button onClick={increment}>Increment</button>
        <Tooltip>increments in steps of {steps}</Tooltip>
    </>
    );
};

Group tests by render and user behaviours

Group tests into describe blocks named render and behaviours at the top level.

The main advantages of grouping the tests is to:

  • Provide clarity to the reviewer/collaborator on where to find or add a specific test case
  • Prompt the initial test author(s) to include tests for the two main scenarios that need testing:
    • Assert on things that appear on render
    • Assert on things that change after an user interaction
describe("<Counter />", () => {
    describe("render" => () => {
        // include your tests for things that should happen on render
    });

    describe("behaviours" => () => {
        // include your tests for things that should happen after user interactions
    });
});

Use a snapshot test to validate render

Snapshots are good for validating regressions when refactoring code and capturing changes to shared styled components. However snapshots have drawbacks, as they can:

  • Slow down the test suite
  • Slow down the test suite
  • Generate errors in multiple components when making a minor change to a single component.

As such the guidelines for using snapshot tests are as follows:

  • Generate at least 1 snapshot test per component on render to:
    • Validate shared styled-component changes that could affect this component.
    • Validate any regressions when code refactoring.
    • Mock child components to reduce time to produce snapshots and to reduce the triggering of errors when making changes to components. (See section Don't be afraid to stub child components).
  • Consider removing existing snapshots and generating new snapshots when making substantial changes to component functionality
  • When refactoring a component you should use the interactive update snapshot mode to update and review any changes to snapshots.
  • Closely review changes to snapshots in PRs as so not miss regressions
describe("<Counter />", () => {
	describe("render" => () => {
		it("SHOULD render", () => {
			const { asFragment } = render(<Counter />);
			expect(asFragment()).toMatchSnapshot();
		});
	});

	describe("behaviours" => () => {
		// include your tests for things that should happen after user interactions*
	});

});

Use GIVEN-WHEN-THEN to describe branching logic

The biggest improvement to our tests readability has been determining a consistent language and structure for our test descriptions – and for not a lot of effort! We found that using theGIVEN-WHEN-THEN structure works really well for describing component tests when using the `react-testing-library`.

GIVEN some context e.g. some component props, context, or state

WHEN some action is carried out e.g. a button is clicked or an user types in an input

THEN a particular set of observable set of consequences SHOULD happen - e.g. some text has changed or no longer displays in the browser.

We use GIVEN-WHEN-THEN in the following manner:

  • Write the keywords GIVEN, WHEN, AND, NOT, SHOULD in uppercase to make the structure easy to identify and read.
  • Start with GIVEN before WHEN
  • Strict adherence is not required to the structure, focus should be placed on readability

You will notice the example below creates meaningful sentences that describes each test scenario e.g.

  • <Counter /> render GIVEN a initial count AND steps great than zero SHOULD display provided count
  • <Counter /> behaviours WHEN a user clicks increase count button GIVEN steps SHOULD display provided count
describe("<Counter />", () => {
    describe("render" => () => {
        it("SHOULD render", () => {
                // snapshot test
        });

        describe("GIVEN NO intial count", () => {
            describe("AND steps greater than 0", () => {
                it("SHOULD display count zero", () => {
                    // ...
                });
            });
        });

        describe("GIVEN a initial count", () => {
            it("SHOULD display provided count", () => {
                // ...
            });

            describe("AND steps greater than 0", () => {
                it("SHOULD display steps amount", () => {
                        // ...
                });
            });

            describe("AND steps is 0", () => {
                it("SHOULD NOT display counter", () => {
                    // ...
                });
                it("SHOULD THROW steps must be greater than 0 error", () => {
                    // ...
                });
            });
        });

    });

    describe("behaviours" => () => {

        describe("WHEN a user clicks increase count button", () => {
            it("SHOULD display incremented count", () => {
                    // ...
            });
        });
    
        describe("WHEN a user clicks increase count button multiple times", () => {
            it("SHOULD display incremented count", () => {
                    // ...
            });
        });

        describe("GIVEN steps", () => {
            describe("WHEN a user clicks increase count button", () => {
                it("SHOULD display incremented count", () => {
                        // ...
                });
            });
        
            describe("WHEN a user clicks increase count button multiple times", () => {
                it("SHOULD display incremented count", () => {
                        // ...
                });
            });
        });

        describe("GIVEN an initial count", () => {
            describe("WHEN a user clicks increase count button", () => {
                it("SHOULD display incremented count", () => {
                        // ...
                });
            });
        
            describe("WHEN a user clicks increase count button multiple times", () => {
                it("SHOULD display incremented count", () => {
                        // ...
                });
            });
        });

    });
});

Don't be afraid to stub child components

Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in for the test. Stubs may also record information about calls, such as an email gateway stub that remembers the messages it ‘sent’, or maybe only how many messages it ‘sent’.
— Martin Fowler, Mocks arent Stubs

Stubs are described as Mocks in Jest. See this article Advanced React Component Mocks with Jest and React Testing Library by Eric Cobb for details about how to use jest to stub components.

These are unit tests and not integration tests. Stubbing out complex components can:

  • Reduce the amount of effort to write a test
  • Increases the speed of snapshot testing
  • Allow you to stub browser behaviours. Not all features of the browser of are emulated in jsdom, and in these scenarios it makes more sense to mock the child component or browser behaviour.

For example what if our counter example had a tooltip component within it. Usually tooltip components contain a lot of code and logic for figuring out where to position themselves on the page. Within our counter test, we don't care if the tooltip works – there should already be coverage in for this in our tooltip tests. In that case why let this component get in the way the counter tests or slow us down?

// Counter.jsx

import { Tooltip } from "./tooltip";

const Counter = ({ initialCount, steps }) => {
	const [count, setCount] = useState(initialCount);
	const increment = () => setCount((count) => count + steps);

	return (<>
		<input name="counter" value={count} />
		<button onClick={increment}>Increment</button>
		<Tooltip>increments in steps of {steps}</Tooltip>
	</>);
};

// Counter.spec.jsx

jest.mock("./tooltip", () => {
	Tooltip: ({ children }) => <>{children}</>;
});

Don't re-test functionality of child components

Referring back to our previous counter component example, we avoid testing that the tooltip works in the context of the counter component. We expect that the tooltip already has coverage in its own tests, and it usage within the counter component is only considered plumbing or configuration, which can just be validated by a developer, or in a code review.

e.g. We do not test the following:

describe("<Counter />", () => {
		describe("behaviours", () => {
			describe("WHEN user mouseovers tooltip", () => {
				// AVOID TESTING THIS!
				it("SHOULD display tooltip text", () => {
        });
      });
		});
});

Use a single expect per test

  • It's easier to debug failing tests
  • The test case is easier to read and understand what it is testing for
describe("<Counter />", () => {
	describe("behaviours", () => {
        // AVOID THIS
		describe("WHEN user clicks increment", () => {
            it("SHOULD display incremented value for each click by defined steps", () => {
                render(<Counter steps={5} intialCount={0}/>);
                const incrementButton = screen.getByRole('button', { name: 'increment'});
                userEvent.click(incrementButton);
                const counter = screen.getByRole('textarea', { name: 'counter'})

                expect(counter.value).toBe(5);

                userEvent.click(incrementButton);
                expect(counter.value).toBe(10);

                            userEvent.click(incrementButton);
                expect(counter.value).toBe(15);
            });
        
        });

        // PREFER THIS
        describe("WHEN user clicks increment", () => {
            it("SHOULD display incremented value by defined steps", () => {
                render(<Counter steps={5} intialCount={0}/>);
                const incrementButton = screen.getByRole('button', { name: 'increment'});
                userEvent.click(incrementButton);
                const counter = screen.getByRole('textarea', { name: 'counter'})

                expect(counter.value).toBe(5);
            });
        });

        describe("WHEN user clicks increment multiple times", () => {
            it("SHOULD display incremented value by defined steps times clicks", () => {
                render(<Counter steps={5} intialCount={0}/>);
                const incrementButton = screen.getByRole('button', { name: 'increment'});
                userEvent.click(incrementButton);
                const counter = screen.getByRole('textarea', { name: 'counter'})
                userEvent.click(incrementButton);
                userEvent.click(incrementButton);
                userEvent.click(incrementButton);

                expect(counter.value).toBe(15);
            });
        });
    });
});

Accept unit testing UI components can be difficult

The react-test-library is limited by the functionality provided by js-dom where not all all browser APIs are emulated, making it hard to test. Things usually difficult to test are:

  • User scrolling within areas of the page. e.g. A common example of this is browser scrolling, for example, testing that data is loaded when a user scrolls past a threshold.

When writing a particular test becomes too difficult, we should consider the following:

  • You may be testing for the wrong thing. e.g. Are you testing child components like in the tooltip example above?
  • Your component may be doing too much e.g. Is your component handling the retrieving of data from an API, rendering a list and calculating the printing out of pagination? If your component is doing more than 2-3 things you should consider splitting your component out into smaller components or hooks.
  • Is there another way you could test this? Could I write a simpler test?
    • What would it mean if you didn't have this test and there was an error in this part of the code?
    • Is there another way you could test this? e.g Could I write a simpler.
    • Is there another way you could test this? Could I write a simpler test?
      describe("WHEN user scrolls to bottom of page", () => {
      	// TODO: Figure out how to test this
      	it.skip("SHOULD load more results") 
      });

Try use getByRole

If you familiarise yourself with the testing libraries documentation on priority of queries you will notice that the first query suggested to utilise is getByRole.

For the majority of user behaviours, there is a way to use getByRole. to find the element to interact with. The ability to query an element using getByRole. means it will very likely be accessible and visible to screen readers.

I recommend reviewing the MDN docs for ARIA Roles to figure out the best way to add the correct roles to your interactive elements so thatgetByRole. can be used.

For example for a button element you can use the attribute aria-pressed="true" to represent a button is active or inactive.

I will use the example of a ToggleButton that can either be on or off to show how we can now test a button's active state using getByRole.

// ToggleButton.js
const ToggleButton = ({intialActive}) => {
    const [isActive, setActive] = useState(intialActive)
    return (<button aria-pressed={isActive}>{ isActive? 'On': 'Off' }</button>
}

OnOffSwitch.defaultProps = {
    intialActive; false
}

// ToggleButton.spec.js
describe("<ToggleButton />", () => {
	describe("behaviours", () => {
     describe("GIVEN button is toggled off", () => {
        describe("WHEN user clicks toggle button", () => {
            it("SHOULD set button state inactive", () => {
                render(<ToggleBtn />)
                const toggle = screen.getByRole('button', { name: /off/i, pressed: false });
                userEvent.click(toggle)
                const activeToggle = screen.getByRole('button', { name: /on/i, pressed: true});

                expect(activeToggle).toBeInTheDocument();
            });
        });
     });
  });
});

Isolate complex component logic into a hooks and helper functions and test separately

In the counter component example, if we wanted to add a feature that allows users to provide their own value using keyboard input, this would require more logic to be added into our component.

Adding more logic will increase the amount of tests and responsibilities for that component. By refactoring some of the logic into a useCounter hook, we can split out the responsibility and make things easier to test.

The refactored counter component and useCounter hook

// useCounter.js
export const useCounter = (initialCount, steps) => {

	const [count, setCount] = useState(initialCount);
	
	const increment = () => {
		setCount(({count }) => count + steps);
	}

	return { count, increment, setCount  }
}

// Counter.js
export const Counter = ({ initialCount, steps }) => {

	const { count, increment, setCount } =  useCounter(initialCount, steps );

	return (<>
		<input name="counter" value={count} onChange={(e) => setCount(e.target.value)} />
		<button onClick={increment}>Increment</button>
		<Tooltip>increments in steps of {steps}</Tooltip>
	</>);
};

The updated tests

// useCounter.spec.js
describe("useCounter", () => {
    describe("GIVEN intialCount AND steps", () => {
        it("SHOULD set count to intialCount", () => {
            const { result } = renderHook(() => useCounter({ intialCount: 5, steps: 10 }))

            expect(result.current.count).toBe(5);
        });

    describe("GIVEN increment()", () => {
	    it("SHOULD increase count by steps", () => {
	        const { result } = renderHook(() => useCounter({ intialCount: 5, steps: 10 }))
            act(() => {
            result.current.increment()
            })
            expect(result.current.count).toBe(15);
        });
    });

    describe("GIVEN setCount() with new value", () =>{
        it("SHOULD update count value", () => {
            const { result } = renderHook(() => useCounter({ intialCount: 5, steps: 10 }))
            act(() => {
            result.current.setCount(20)
            })
            expect(result.current.count).toBe(20);
        });
	});
});

// Counter.spec.js
describe("<Counter />", () => {

    describe("render" => () => {
        it("SHOULD render", () => {
            const { asFragment } =  render(<Counter />);

            expect(asFragment()).toMatchSnapshot();
        });
    });
});

Conclusion

After following these guidelines we went from having a haphazard approach to writing and structuring UI tests, to having a clear and consistent method. There are many different ways to write, structure, and organise tests, and this is by no means the best or only approach, but one that worked for us.

If you want to improve testing in your codebase you should consider agreeing on a set of guidelines that suits your team.