“I’ve got a React codebase, I want to introduce tests, where do I start?”

I recently started hosting AMA-style cafe chats with developers at various stages of their journey, about software engineering and anything else within my technical experience. Below are some rough & rapid-fire notes from the chat on Oct 21st, 2022.

Getting started

You can test at various levels, from an individual function that knows nothing about browsers to an entire application running in a real browser and hitting real network services.

Testing small or medium-sized interactions in a simulated browser environment is a good middle ground — approachable yet useful.

Pick a basic interaction and write it down:

“when button ‘About’ is clicked, modal with information appears”.

Stick to interactions where the UI is both the input and the output channel.

About “channels”...
  • A test’s “input channel” and “output channel” are terms I made up; probably better ones exist; related: “test boundaries”.
  • When testing a pure function, input and output channels are obvious: arguments in, return value out.
  • When testing a callback function, it’s a little less obvious: arguments in, callback argument out.
  • When testing a UI, it’s even less obvious: user action in, UI change out; or: user action in, API request out; or: push notification in, UI change out; and so on.

Install & set up jest, jest-environment-jsdom, @testing-library/react, and @testing-library/jest-dom.

Pick a component high enough in your app’s tree to support the chosen interaction. Don’t be afraid to use <App/> itself.

Write a <component-you-picked>.test.tsx file containing a test("when button 'About' is clicked'...) where you render() the component, the button, and expect() that the intended change happened in the UI.

The UI change might happen on the next iteration of the JavaScript event loop; use await waitFor(() => { expect(...) }) to account for it.

Use screen.debug() to inspect the state of the DOM at any point of the test.

Continue with interactions that involve the network and repeat.

Choosing output channels when network interactions are involved...
  • “When button is clicked, API request is sent” → the network is the output channel.
  • “When button is clicked, table gets populated with server data” → the UI is the output channel.
  • “When button is clicked, API request is sent and table gets populated with the returned data” → nothing bars you from looking at multiple output channels.

Arrange something that handles the network request within the test environment.

  • Since you’re getting started, use nock — it’s the least magical option and forces you to understand what’s going on.
  • nock() a request and its response, render() the component, the button that triggers the network request, expect() the UI changes and/or that the request was made.
    • if you’re using Node 18 and your frontend code calls the global fetch instead of one imported from isomorphic-fetch or cross-fetch): Node 18 introduces a global fetch, so your tests might end up invoking that, and nock can’t mock it yet. Fix by adding import "cross-fetch/polyfill" in a jest setupFile or by importing fetch from cross-fetch in application code rather then relying on the global. Alternatively, you can use the mocking facilities of Node 18’s new HTTP client undici, but those are unlikely to match nock’s for a while.

Getting better

As you become more comfortable with the testing machinery, explore other ways of simulating parts of your system inside the test environment:

  • Your API service is written in Node and lives next to the React app as part of a monorepo? Launch the actual server in beforeAll(), point your components to it by passing them an apiBaseUrl prop or context value, and let them interact with the real (albeit sandboxed) thing.
  • If your API service is an external? Mock it with something like MSW.
  • jest.mock() and spyOn() things that your components affect in ways other than the network, e.g. third-party SDKs
  • Zero in on non-UI parts of your applications and test them independently of React: vanilla JS components, reducers, …
    • The more logic you manage to move away from React components and into functions (especially pure functions), the bigger benefits you’ll reap in terms of architecture and maintainability of the code base, as well as of the quality of your flow (since tests are faster and easier to understand).