All Articles

Testing TypeScript & React with Jest, React Testing Library, Cypress

Testing JavaScript

Want to have more confidence in your codebase when making changes? Let’s look at the modern ways and tools used to test JavaScript, TypeScript & React.

📓 Types of Tests

It’s possible to divide tests into four different types. The distinction between these different types can be blurry. Thus many developers have a slightly different idea of what each type of test should exactly mean.

1. Unit tests

Test the functionality of small isolated pieces of code (Jest, React Testing Library).

Pros:

  • Blazingly fast, 100s of tests can run in seconds
  • Very little setup is needed to get up and running
  • Quick feedback
  • Reliable

Cons:

  • Too much focus on implementation details

2. Integration tests

Test that multiple components & layers work together as expected (React Testing Library & Cypress).

This might mean testing components with React Testing Library that render many child components (think of it as testing a whole Form component vs testing each element of the form) or using Cypress and testing it through the browser (any API calls to the server are stubbed).

Pros:

  • Relatively fast (browser level) tests
  • Testing like a user would
  • Little effort to cover a lot of application code

Cons:

  • Flaky in Continuous Integration (environment differences)

3. End-to-End (E2E) functional tests

Test all layers of your application by running the complete app. This means React, your API, the database and any extra services there may be. These tests can even run against the staging/production server. Testing in production can bring a lot of value and it’s relatively easy to set up, but it must be done in a safe manner.

Pros:

  • Gives high confidence that a critical business flow actually works
  • Most similar to how a user would test it

Cons:

  • Initial tests require a lot of application-specific setup
  • Expensive, slow setup speed for each test
  • Extra flaky in CI (more potential environment differences)

4. Static tests (analysis)

Static analysis can also be considered a type of testing. Consider using TypeScript and tools such as Eslint & Prettier. Linters not only increase overall code quality but also help catch bugs and syntax errors even before code execution.

Pros:

  • Speed up development
  • Catch bugs at compile time
  • Rich IDE support
  • Improved code quality

Cons:

  • Time investment for initial configuration
  • TypeScript compilation can get expensive in complex projects

🤔 Which types of tests to use?

In an ideal world, one would use a mix of all the testing types. It largely depends on the structure of the project. Starting with static analysis tools and unit tests is probably easiest for most simple applications. Try to set up integration tests as early as possible and eventually cover happy paths of all critical parts with E2E tests (e.g signup & login flows).

Focus on tests that you feel give the most value.

🙇‍♂️ Examples

I’ll show you code samples from an open-source Kanban App. Links are provided to each source for any extra context if needed.

Unit tests

A unit test for a color utility function. As simple as it gets.

it("should return white as contrast for black and vice versa", () => {
  expect(getContrastColor("#FFFFFF")).toEqual("#000000");
  expect(getContrastColor("#000000")).toEqual("#FFFFFF");
});

Source

A test for a React component that uses a Popover component from Material-UI to display some text when opened. All of the state is managed inside the component so we can just click the button and assert for visible text. This is often called a Smoke test - reveals simple failures for very little effort.

import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import Footer from "./Footer";

it("should open popover and have text visible", () => {
  render(<Footer />);
  fireEvent.click(screen.getByRole("button", { name: "About" }));
  expect(screen.getByRole("link", { name: "GitHub" })).toBeVisible();
});

Source

Jest also offers Snapshot Testing. It’s an awesome feature used to save a copy of the UI.

import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import Footer from "./Footer";

it("should render github link correctly", async () => {
  render(<Footer />);
  fireEvent.click(screen.getByText("About"));
  await waitFor(() => {
    expect(screen.getByRole("alert")).toBeVisible();
  });
  expect(screen.getByRole("link", { name: "GitHub" })).toMatchSnapshot();
});

Source

After running the test the following snapshot file is created that should be committed alongside code changes.

exports[`should render github link correctly 1`] = `
<a
  class="MuiTypography-root MuiLink-root MuiLink-underlineHover MuiTypography-colorPrimary"
  href="https://github.com/rrebase/knboard"
  rel="noopener"
  target="_blank"
>
  GitHub
</a>
`;

Next time when running the test, jest will compare the changes and fail when snapshots don’t match. Similarly to a smoke test, it gives a lot of value for little effort.

One ideal use case of snapshot testing is refactoring legacy codebases. You could create snapshots of components that are not tested and have the confidence of changing code and knowing when something breaks unexpectedly.

One other type of snapshot testing is Visual Testing, which compares the final pixels of UI instead of comparing the underlying HTML structure. So the snapshots are browser level screenshots saved as images. Storybook in combination with StoryShots is a great example of Automated Visual Testing.

🤔 Now let’s look at a more complex test. The reality is that most tests require a significant amount of mocking.

The following test could also be categorized as an integration test as it tests LabelDialog, which renders components such as LabelCreate, LabelRow and LabelFields.

Here we mock API calls, Redux actions, and window.confirm.

import React from "react";
import { screen, waitFor, fireEvent } from "@testing-library/react";
import { API_LABELS } from "api";
import {
  rootInitialState,
  renderWithProviders,
  axiosMock,
} from "utils/testHelpers";
import { createInfoToast } from "features/toast/ToastSlice";
import LabelDialog from "./LabelDialog";
import { deleteLabel } from "./LabelSlice";

// ...

it("should have one label and dispatch deleteLabel", async () => {
  axiosMock.onDelete(`${API_LABELS}${docLabel.id}/`).reply(204);
  window.confirm = jest.fn().mockImplementation(() => true);
  const { getActionsTypes } = renderWithProviders(<LabelDialog />, {
    ...rootInitialState,
    board: { ...rootInitialState.board, detail: boardDetail },
    label: {
      ...rootInitialState.label,
      dialogOpen: true,
      ids: [docLabel.id],
      entities: { [docLabel.id]: docLabel },
    },
  });
  expect(screen.getByRole("heading", { name: "1 label" })).toBeVisible();
  expect(screen.getByText(docLabel.name)).toBeVisible();
  fireEvent.click(screen.getByRole("button", { name: /delete/i }));
  await waitFor(() =>
    expect(getActionsTypes().includes(deleteLabel.fulfilled.type)).toBe(true)
  );
  expect(getActionsTypes()).toEqual([
    deleteLabel.pending.type,
    createInfoToast.type,
    deleteLabel.fulfilled.type,
  ]);
});

Source

Let’s look at this line by line. So the goal of this test is to make sure that an API call would be dispatched by when a delete button is clicked for a label.

Some of the state in LabelDialog is handled by Redux and not by the component (using useState). We’re completely mocking the redux store here to test the component in isolation.

Since the component has to be wrapped with a Redux <Provider /> and a <Router /> when testing routing, a testing helper renderWithProviders is used for rendering. It also provides access to the mockStore and getActionsTypes for common assertions.

// ...

export const renderWithProviders = (
  ui: React.ReactNode,
  initialState: RootState = rootInitialState
) => {
  const store = configureStore([thunk])(initialState);
  return {
    ...render(
      <MemoryRouter>
        <Provider store={store}>{ui}</Provider>
      </MemoryRouter>
    ),
    mockStore: store,
    getActionsTypes: () => store.getActions().map((a) => a.type),
  };
};

Source

The rootInitialState is based on the initial state of all Redux reducers and is provided to the mock store and overridden to get the desired initial state for a component (one label in this case). Since axios is used to make API calls, axios-mock-adapter is used for easy mocking of requests.

So first we mock the API delete call and then the window.confirm using a jest mock function. Now with the proper initial redux state we have a label that we can delete. Since it’s an async call, let’s properly await for deleteLabel.fulfilled action to be dispatched and finally assert that the dispatched actions were the correct ones. And that’s it for that test! As you can see, it’s easy to get stuck in the implementation details.

The Redux reducers have to be tested separately since the store was mocked. Thankfully reducers are not complex. They take a state and an action as an input and output the changed state.

it("should set error on fail", () => {
  const errorMsg = "Failed to fetch boards.";
  expect(
    boardReducer(
      { ...rootInitialState.board, fetchLoading: true, fetchError: null },
      { type: fetchAllBoards.rejected, payload: errorMsg }
    )
  ).toEqual({
    ...rootInitialState.board,
    fetchLoading: false,
    fetchError: errorMsg
});

Source

Integration tests

Here’s a test that edits the column title if it’s not empty and also tries to cancel editing via escape key.

We don’t have to know anything about redux here. Yay! Just like a user. It’s only an implementation detail, which is not visible at the browser level.

Integration test

Note that it’s usually best practice to test each small thing separately, but that’s not a good idea for integration and E2E tests, which use the browser where each test setup & teardown is slow. So it’s better to test all related actions in one go.

it("should edit column title if not empty & cancel via esc", () => {
  cy.fixture("internals_board").then((board) => {
    const colTitle = "In progress";
    const newColTitle = "Ongoing";
    const newColumn = board.columns.find((c) => c.id === 3);
    cy.route("PATCH", "/api/columns/3/", {
      ...newColumn,
      title: newColTitle,
    });

    cy.findAllByText(newColTitle).should("not.exist");
    cy.findByText(colTitle).should("be.visible");

    cy.findByTestId(`col-${colTitle}`).within(() => {
      cy.findAllByTestId("column-title-textarea").should("not.exist");
      cy.findByTestId("column-title").click();

      cy.findByTestId("column-title-textarea")
        .clear()
        .type("{enter}")
        .should("be.visible");

      cy.findByTestId("column-title-textarea").type(`${newColTitle}{enter}`);
      cy.findByTestId("column-title").should("be.visible");
    });
    cy.findAllByText(colTitle).should("not.exist");
    cy.findByText(newColTitle).should("be.visible");

    cy.findByTestId(`col-${newColTitle}`).within(() => {
      cy.findByTestId("column-title").click();
      cy.findByTestId("column-title-textarea").type("Cancelled title{esc}");
      cy.findAllByTestId("column-title-textarea").should("not.exist");
    });
    cy.findByText(newColTitle).should("be.visible");
  });
});

GitHub Source

E2E functional tests

Here’s a simple example of safely testing the Login functionality in Production. It’s arguably one of the most critical application flows and it’s really simple to test with Cypress. We just need to have a test user created beforehand.

context("Production Auth", () => {
  beforeEach(() => {
    cy.visit("https://knboard.com/");
  });

  it("should login successfully", () => {
    cy.findByRole("button", { name: "Login" }).click();
    cy.findByLabelText("Username").type("e2etestuser");
    cy.findByLabelText("Password").type("testpassword123{enter}");
    cy.findByRole("button", { name: "Login" }).click();
    cy.findByRole("button", { name: "View Boards" }).should("be.visible");
  });
});

It’s a good idea to run a small set of smoke tests like this against production. To run this test against the local development server as most of your tests should, point Cypress to visit localhost instead of the production URL.

Of course Cypress is only one of the possible tools for E2E testing. It can be done with any browser automation framework: Puppeteer, Robot Framework.

Conclusion

Add confidence to your codebase by using a mix of different types of tests. Start with static analysis tools and smoke tests for a lot of value with little effort. Then add unit tests, setup integration tests & a couple of E2E tests for the business-critical flows.

🙏 You’ll be very thankful to have a well-tested app once the codebase grows or it needs refactoring.

References