#
Testing
Historical information around decisions and opinions on our approach to testing React applications
#
Mantras
- Test specific user flows instead of aiming for coverage, i.e. happy path and error states.
- Prefer integration tests over unit tests, i.e. render a screen and assert how each component interacts with one another during a user action.
- Prefer accessibility attributes instead of classes/ids for selectors, i.e. query by role, label, or visible (static) text.
- Avoid writing tests that will fail if implementation details change
- Write tests to provide confidence in future changes, i.e. help your colleagues know that a change made here does't affect something over there.
#
React Testing Library Overview
The core of our testing strategy is React Testing Library. It is a lightweight solution for testing React components. It's approach allows your tests to work with actual DOM nodes instead of instances of rendered components. This gives us confidence by testing things as close as possible to the way an actual user will interact with the application.
#
Unit vs Integration vs End-to-end
#
Unit tests
Unit tests are helpful for testing an isolated piece of code, such as a utility function or a custom hook.
For the most part, TypeScript does a good job of ensuring that our utility functions will operate as intended, and provided a given input will always return an expected output—but there may still be times when you want to test internal logic of these functions.
Hooks are also a good case for unit testing, as these custom hooks are often times re-used across various places within our application, and therefore can benefit from a confidence boost by ensuring an expected output.
#
Integration tests
Integration tests are the bulk of what we will write as UI developers. These are meant to gather a group of components together, in whatever size necessary, to confirm how they interact with each other.
Most often, we are rendering a single screen of the application, taking an action (clicking a button), and asserting the expected outcome (showing a success/error toast/message.)
#
End-to-End
End-to-end tests are written to assert outcomes in the full experience, from user interaction all the way through to backend APIs.
End-to-end tests are outside of our current responsibility. There are QA engineer(s) within MidFirst that work with e2e testing in Cypress, but at this point, most of the tests that live in application code are going to be mocking data and API calls instead of actually reaching out to backend APIs. This allows our tests to run more quickly, and not repeatedly alter our dev/qa environment databases.
#
Querying
You have several options available to you when querying for elements in the rendered DOM of your component tests.
// will find a button if it is the only button rendered
screen.getByRole("button");
// will return array of all buttons on the page
screen.getAllByRole("button");
// will get all buttons with accesible text of `Delete`
screen.getByRole("button", { name: "Delete" });
Sometimes you need to query for an element that is not yet on the page, but will be. Your test function needs to be set to async, and then you can await an async query method
it("should render", async () => {
await screen.findByRole("button", { name: "Delete" });
});
You can, but don't always have to, rely on explicit assertions/expectations. At times an implicit non-failure can act as an assertion of correctness
// explicit assertion
expect(
await screen.findByRole("button", { name: "Delete" })
).toBeInTheDocument();
// implicit assertion
await screen.findByRole("button", { name: "Delete" });
#
Mocking Data
Our tests are written against mock data, use MSW. This library intercepts all network calls in a given environment, and responds instead with a payload that we provide it. This library also allows us to run the application in browser against mocked APIs as well.
Generally we setup an array of handlers that can be added to each time you write a test that talks to a new endpoint.
import { rest } from "msw";
export const handlers = [
rest.get("*/v1/users", (req, res, ctx) => {
return res(ctx.status(200), ctx.json({}));
}),
rest.post("*/v1/users", (req, res, ctx) => {
// NOTE: you have access to the `req` object here,
// so you can return different values based on the body/params
return res(ctx.status(200), ctx.json({}));
}),
rest.get("*/v1/users/:id", (req, res, ctx) => {
return res(ctx.status(200), ctx.json({}));
}),
];
These will be the defaults that will always return when that endpoint is hit. But you can also override these on a per-test or per-test-suite basis.
import { mockServer } from '../path/to/your/mockServer';
it('should do something'. () => {
mockServer.use(
rest.get(`*/v1/users`, (req, res, ctx) =>
res(ctx.status(200), ctx.json(/* insert your override data here */)),
),
);
// rest of your test here
});
Additionally, your application needs to ensure you clean up after each test run so your mocks do not bleed into other test. This should be done for you already if you've used one of our appliciation templates, but if not you'll need this in your jest/setup.ts file
import { mockServer } from "../path/to/your/mockServer";
// Establish API mocking before all tests.
beforeAll(() => mockServer.listen());
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => mockServer.resetHandlers());
// Clean up after the tests are finished.
afterAll(() => mockServer.close());
#
Common Gotchas
#
Async actions
One of the most common frustrations we've encountered when working within React Testing Library is the dreaded act() warning.
Warning: An update to MyAppComponent inside a test was not wrapped in act(...).
This error appears in when a test runs, the component is rendered, and then an action taken or an useEffect runs that causes a state update to happen, i.e. something called setState, and that action was not wrapped in an act() call.
This most commonly occurs in the following flow
- We render a component that starts off with a skeleton while fetching data
- When the API call responds, we update local state, remove the skeleton, and re-render
This solution to this is ensure that we await the changes before continuing on with our test. This can as simple as adding a line like await screen.findByRole('heading', { name: 'Page Heading' }); to ensure some expected content is on the screen after the skeleton disappears.
The incomparable Kent C. Dodds has a great writeup about the intricacies of this issue that can be found here.