A Wide View of Automated Testing in React Apps

This guide will be as succinct and straight to the point as possible. In the future, we would like to provide more learning material, such as:

  • specific patterns of writing tests with open-source libraries (like RTL) and what functions provided by these libraries enable meaningful tests
  • implementing an advanced/complex level of automated testing at very large applications/organizations at scale
  • workshop material you can present to your developer team to make the automated testing efforts on your team more meaningful and valuable
  • more detailed tutorials of testing libraries and tradeoffs of approaches in each

If you think you could help out with the above and are interested in working in open-source/public, you can get involved with us on GitHub.

Right now, we want to cover the basics - to explain to you why testing is valuable/good. To explain what the types of testing are, and then to explain (or at least introduce) what and how you should be testing in your React applications. Each section can be navigated via the navigation on the left, but feel free to jump to the areas most relevant to your needs.

Given the focus on React of this website - most of the testing concepts presented in this guide will be described in a way that its used with React. This might not be the best introduction to testing if you've never heard of it before.

What is the point of automated testing?

Let's split this into 2 categories in case you have to make the argument for testing to your non-technical superiors.

Benefits of testing for the development team

  1. Efficiency: Saves time by automatically running repetitive test cases.
  2. Consistency: Tests run the same way every time, reducing human error.
  3. Coverage: More code can be covered, revealing more potential issues.
  4. Regression Testing: Easily rerun tests when code changes, ensuring no new bugs.
  5. Documentation: Test cases can serve as documentation.
  6. Early Detection: Identifies issues early in the development process, reducing costs and bugs found in production environments.
  7. Deployment Confidence: Increases confidence in code quality.

Benefits of testing for the product/organization

  1. Cost-Efficiency: Reduced need for manual testing saves time, time = money.
  2. Speed to Market: Quicker tests lead to quicker development cycles.
  3. Risk Mitigation: Early bug detection reduces post-launch hotfix costs.
  4. Quality Assurance: Consistent and comprehensive tests ensure a more reliable application.
  5. Competitive Edge: Better quality and faster delivery can lead to a competitive advantage.

The 3 Types of Tests

  1. Unit testing - testing that our applications' smallest units (components) work as expected.
  2. Integration testing - testing interactions between multiple units to see if they work as expected.
  3. End-to-end ("E2E", "Functional") testing - testing entire workflows/paths that users can take in your application.

Key terms & concepts

  • Test - a piece of code written to assert if another piece of code is behaving as expected
  • Test Case - an individual point of functionality or "state" to test in the code that's being tested.
  • Test Runner - a tool that discovers your test files and interprets how to execute them
  • Test Suite - the collection of test cases for your application
  • Assertion - A comparison or check to verify some type of condition (for example, asserting if two string variables are equal or not)
  • Mocking / Mock - Overwriting/simulating external dependencies of your application for the sake of testing
  • Code Coverage - how much of the application code (0% - 100%) runs after the testing suite is executed. All things equal - 100% code coverage is the best end of the spectrum - but never an absolute requirement.
  • Regression Testing / Visual Regression - using screenshots of your application and comparing it to previous versions to alert you of visual changes. Heavy focus on the visual output of your application code, and not necessarily the behavioral aspect of it.

Getting Started in Your React App

  1. Choose a test-runner
    1. Jest or Vitest for unit/integration tests
    2. Cypress or Selenium or Playwright for E2E tests
  2. Choose a testing library
    1. React Testing Library is the best modern choice. Enzyme is an alternative, but we wouldn't recommend it due to the patterns of tests the API encourages developers to write.
  3. Dive in!
    1. If you've never written or seen a unit test in a React app before, you could watch Jack Herrington go through a few examples in this YouTube video.
    2. Be aware of this configuration requirement if your application uses a React version β‰₯ 18 and you're not going to use React Testing Library (which handles it for you).
    3. Also be aware of new features added to React 18 that might break your test suite if the underlying tools haven't taken them into account (e.g. Concurrent mode, Suspense)

What to Test In A React App

In an ideal world, the answer to this question is everything you control. But here are some practical examples to get you started:

Visual:

  • Snapshot testing - not terribly useful, alerts you to changes in the DOM rendered by component
  • Visual Regression - pixel-perfect screenshot comparison to know all changes, both big and small
  • Styles - Testing styles isn't the best use of time, but sometimes, it might make sense if the styles are a result of an interaction/event and important to the function of the app

Behavioral:

  • UI Components (Unit Tests)
    • The smaller/basic components like Buttons, Cards, Toggles, etc., should mostly rely on props for their internal logic (they're more "pure") and, therefore should be easy to test.
    • Test output - Test that passing different props combinations results in the expected render output. What are this component's different render "states" or "variants" of output? Are there loading states or "empty" states if the data passed is null/empty?
    • Test interactivity - Does this component contain interactions that change what is rendered? Observe the interactivity and write that into a test
    • Controlled component interactivity - When components become "controlled" - they relinquish some of their internal state to the callee/parent. Typically, this is through the use of props and they would be named something like onClick, onToggle, onChange, etc. You should be testing that these props properly work and are called when expected and with the expected arguments if any are included.
  • Custom Hooks & Context Providers (Unit Tests)
    • As for testing a custom hook / context provider - it is recommended not to try to test the hook/context itself and instead consume the hook/context in a component for the sake of testing. The component can be one that's actually used in the application or is just a dummy component (that never gets loaded in the production bundle) for the sake of testing. Either way, writing a component to consume the underlying hook/context will be easier to test than trying to test just a hook/context in isolation.
    • The point we want to drive home for you is that for your test of a hook/context - try to do it all in one component (dummy or real, it doesn't matter). Outside of the test, you should probably be mocking your contexts everywhere else they are used (to make your other tests simpler to reason about).
    • If you are insistent on testing custom hooks in isolation you can try this library: React Hooks Testing Library
  • Global Stores (Unit Tests)
    • Unlike the above, if you're using a library like Redux or Jotai, you CAN write some unit tests for your state logic. Any library worth using should probably have a page on how to write tests in said library, for example:
  • Custom Utils / App Functions (Unit Tests)
    • Most of the time, these are pure javascript functions kept somewhere for repeated use throughout the project. These should be simple to test because you don't have to worry about rendering anything. You can test the inputs and outputs of a simple function by passing different arguments as each function parameter and checking the return value.
  • Page / Feature Components (Integration Tests)
    • Test your components working together with other logic in your code like context providers, global stores, browser APIs, and third-party servers/APIs, as this is a more realistic simulation of your app being used by an actual user.
    • Test output - can you see that the child components are rendered correctly? Are there other visual queues that get rendered you should test for?
    • Test interactivity - typically in feature-level or page-level components, there are additional things you can test, some examples might be:
      • Form submission handlers, form validation, interactions that make third-party API calls, interactions that update stores/contexts, interactions that should cause a navigation change, interactions that trigger other expensive work (export to CSV, drawing on a canvas, etc.) that you could arguably mock
    • Test errors - since features/pages are where API interactions happen, its also typically where users can get errors / error-states in the app. Mock APIs to return errors and test that your frontend responds appropriately.
  • Entire user flows / paths (E2E Tests)
    • Tests that confirm an entire path a user might take is working as expected.
    • Examples: multi-step forms, multi-step authentication, multi-step config, etc.
    • Think of both the happy paths AND the unhappy paths, like when errors occur

Identifying what to test:

  • Which parts of your codebase breaking would be *really bad if they broke? This is where you start writing integration & E2E tests.
    • E-commerce: add to cart + checkout is the critical flow
    • Social networks: Authentication and main feed are the critical flows
    • Airbnb: Property search + booking
  • Is there an A/C (Acceptance Criteria) for a task, ticket, feature, or something that outlines the functionality? These are great starting points for test ideas in a feature/page-level integration test.
  • How are you testing your work locally? Every developer knows the game of ping-ponging between code editor and browser, checking every little behavior along the way. What are the things you are checking while building this feature locally? Use that to think about writing your test.
  • What are the paths or use cases a user of the app might take? Think of both happy paths AND sad paths. Test that both at least show the user the expected UX.
  • When in doubt, ask yourself, "Is this something the user would notice?"

What to avoid when writing tests

  • Testing that components are called with certain props
    • Testing render output is simpler and what the user would experience (users don't care about internal details of your app like props, so neither should your tests)
  • Tests that require or assume certain elements will appear in a certain order or hierarchy in the DOM
    • Sometimes, you'll make too many assumptions in your tests that generate false failures or break too easily. A trivial example of this would be querying all buttons in the DOM and then assuming the nth is the one we need (for example, something like querySelectorAll('.button')[1]). This can especially make an integration test of many interacting components very brittle and give false failures. Instead, query the button by its text, a data-id attribute, or something that wouldn't make this test so easy to break when code around it changes.
    • Something you can use in the browser to make queries for DOM nodes easier and more reliable is to open the developer tools, highlight/inspect the HTML being rendered by the component, and go to Elements β†’ Inspect β†’ Accessibility β†’ Computed Properties. This will give you ways of querying the highlighted element via the accessibility computed properties.
  • Trying to test the internal state logic of components. Let's take a carousel, for example.
    • The internal logic of a carousel would have an index to keep track of the "current" item being shown. But that index could start at 0, or 1, or 500. Trying to test what index the carousel is keeping track of (too much of an implementation detail) - try to test what you expect the output to be of the carousel.
    • Again: "Would the user notice this?"
      • A user won't notice what index your carousel component starts at. But they will notice what the carousel is rendering, so test that!
  • Testing that components render certain child components with certain props (ensuring the data is passed through correctly). This is too much detail. Find ways to test the render output to assess whether the component behaves correctly.
    • Previous popular libraries like enzyme provided functions like .find() to locate child components rendered in a parent, and .props() to let us peek at the props supplied to a component. But again, these are all implementation details. Testing the API of the component is not the same as testing the behavior of the component. Try to test the behavior more than the API.
    • Besides, it's better to learn about trivial bugs like breaking prop name changes via TypeScript (during development)
  • All of these are just various ways of saying don't test too much implementation detail. Test what the user would notice.

FAQs

How to make components more testable

The easiest way to think about making components easier to test is to try and make them more "pure" functions. In the context of React - making components more "pure" tends to mean relying on props (which are the "arguments" to our functional component). If the component relies on any internal/external state for rendering, we can't guarantee what that state data will be when we render the component, and therefore, we can't always guarantee that our functional component will return a certain JSX output.

This is not an outright recommendation to rebuild all of your components to use ONLY props, far from it. The usage of props is very nuanced, and getting it right from both a performance standpoint AND a developer-experience standpoint is difficult to get right. Props are not always the right solution, but if you want to design your components with unit-testability in mind, your functional components' render logic should rely more on props than other sources of information (like global stores, local state, localStorage, etc.)

What should I be mocking?

  • When we mock things, we say "Pretend this thing works perfectly, and pretend that in this particular test, the thing worked this particular way"
  • Great examples of things that should be mocked in most React app test suites:
  • Any code YOU (the developer) cannot easily control/change
  • Browser APIs (like window.scrollTo)
  • Node dependencies (like a google-map embed)
  • 3rd party APIs (data-fetching calls, any backends/servers/databases, etc.)

I'm using TypeScript. Do I still need to write unit tests?

TypeScript does solve a basic level of testing in your application (in an automated way), yes. However, it is not sufficient to say that TS alone is enough to give you real confidence in your application code.

Unit + Integration tests can give you confidence that there are no glaring bugs in the feature set of your application, which TypeScript really can't. Simply put, TypeScript is not a replacement for writing tests.

What types of tests are best to write (and how much)?

Instead of telling you what to do, you should think about the benefits and tradeoffs of each. We'll help you think about it.

  • Unit Tests - Unit tests are probably the simplest to write most of the time, and this type shines when used for your primitive components/functions that are the basic building blocks of your application. The more "pure" your components can be (relying only on props), the easier they'll be to test (that's not to say that heavy prop usage is the best API for every component, only that it makes unit testing much simpler). However, this type really only tests things at a very basic level.
  • Integration Tests - Writing unit tests at the feature/page level. Testing that multiple components all work together with other code logic (Forms, APIs, hooks, global state, etc.) to form application features. This type of test arguably has the best return on investment if you do not want to spend a lot of time on an automated testing suite - it will guarantee the core functionality of your app doesn't break (catastrophically, at least) when making changes to your application code.
  • E2E/Functional Tests - Robots literally open a browser and run through entire flows (happy paths, sad paths, all of it), checking all kinds of functionality along the way. Think of multi-step forms, authentication flows, etc. These are the most complex to implement but are going to give you the utmost confidence that almost every facet of your application is working as expected.

When should the devs be writing tests (during the software development lifecycle)?

  1. During the development of the features. The software engineer picking up the task/story/ticket/feature should know the code better than anyone. They should also be writing the appropriate unit+integration tests to boost confidence in the code they're merging. See further below for examples of what should be tested.
  2. After development, but before production deployment. If your team/organization is large enough and you have resources for it, it's not the worst idea to let a dedicated QA/Automation engineer pick up the ticket after it is merged to write a complete E2E test (which might not be the specialty of the software engineer completing the feature).
  3. When you get a chance to catch your breath and address tech debt, refactors, etc. This is a good time to get a baseline of automated tests running because while addressing tech debt and implementing refactors for a better codebase, the actual features of the application shouldn't change at all (that's the definition of a "refactor"). Hence, if you spend some time writing tests, they won't immediately break and require more time to fix. Now, they make a good baseline moving forward for your deployment pipeline.
  4. If none of these times suit you, or you can't find a way to prioritize testing in your own way or in one recommended way above, your product/organization probably wouldn't gain much from automated testing anyway.

General Tips

  • Refactoring your code shouldn't break tests.
    • Since the traditional definition of a refactor would include NOT changing the functionality/output (and simply making the code more efficient/maintainable) - if a test breaks during a refactor session, then you had to have changed functionality, or the test was too brittle and assumed too much implementation detail. This is a "false failure", by the way.
  • Use custom rendering to make it easier to simulate your application in your test suite
  • Use Mock Service Workers (MSW) for mocking your API endpoints
  • Use faker for generating large amounts of fake datasets

Jest Tips

// Mocking global APIs like window.scrollTo that you don't import from anywhere

// A) You can have the same mock for ALL of your tests in one place
beforeAll(() => {
  window.functionToMock = jest.fn().mockImplementation(() => {});
})

// B) You can mock the function to something different for each test
beforeEach(() => {
  window.functionToMock = jest.fn();
})

test('description', () => {
  window.functionToMock.mockImplementation(() => {}) // mock function A
})

test('description', () => {
  window.functionToMock.mockImplementation(() => {}) // mock function B
})

// How to mock functions you are importing from another file/source
// This is useful for mocking functions in your code that hit API endpoints (if you're not using the MSW pattern)
import { someFunction } from 'third-party-npm-dependency';

// tell jest we will mock it in this file
jest.mock('third-party-npm-dependency');

test('the first test', () => {
  someFunction.mockImplementation(() => {}) // pass a mock function here
})

test('another test where we want a different return value from someFunction', () => {
  someFunction.mockImplementation(() => {}) // you can change it in each test with mockImplementation();
})

RTL Tips

Watch Testing Library: everybody uses it, but nobody understands it by Matan Borenkraout.

  • RTL has a very rich API for querying/selecting DOM nodes rendered by your components - it’s very useful for making tests less brittle and reliant on implementation details
    • You can find a similar tool in browsers by going to Elements β†’ Inspect β†’ Accessibility β†’ Computed Properties
    • The Testing Playground can also be helpful in finding creative but robust ways of finding DOM nodes in your render output
  • When firing events in your tests, 99% of the time, you should prefer userEvent over fireEvent, because userEvent simulates an event closer to how a browser would (including propagation and other events that get fired like mouseUp / mouseDown)
// example of "custom rendering" so that when you render your components, they have dependencies like contexts/stores/etc
// create local test-utils.ts file

import {render as libraryRender} from '@testing-library/react';
import {SomeContextProvider} from 'providers/SomeContextProvider'

function render(ui, {theme = 'light', ...options} = {}) {
  const ComponentWrapper = ({children}) => (
    <SomeContextProvider values={/* You define the initial values */}>{children}</SomeContextProvider>
  )
  return libraryRender(ui, {wrapper: ComponentWrapper, ...options})
}

export * from 'testing-library/react' // export all other API functions from the library
export {render} // overrides the render function exported from testing-libary with your custom version

// The query functions can be really powerful!
// but remember, the getBy, queryBy, findBy methods all have different behaviors when they can't find matching DOM nodes!
// https://testing-library.com/docs/queries/about#types-of-queries

const el = getByText(/word or phrase/i); // gets an element by its text content, but doesn't have to match capitalization
// you should be relying mostly on this for elements that have text content
// remember other properties you can query for by going to Elements β†’ Inspect β†’ Accessibility β†’ Computed Properties

Misc Reading