Testing - templates

from my research on the many methods and SO questions, it seems testing is still a rapidly changing field, so methods and best practices may be outdated by the time you read this

kent c dodds seems to have nailed it down though so it may be the start of an era of standardized react testing

Tests, what are they for?

What to keep in mind when writing tests

  1. make it declarative
    • it should not be testing for implementation details, e.g. existence of some state
    • it should be testing for outputs, e.g. "what does the component need to show the user? What should it do once the user clicks on this button?"
  2. use custom attributes (especially if code changes often)
    • many examples show getting elements by raw dom manipulation or by class attributes, but they should be allowed to change due to styling changes etc.
    • @testing-library/react uses data-testid="your-id" as a custom prop, which bypasses brittleness when testing with other selectors, while making it clear which parts of the component are tested.

NOT testing implementation details

YES testing the component output and behaviour from the user's perspective

as such, testing for whether isModal is true is not as good as testing for the existence of the modal element.

Key pieces

Integration tests

Unit tests

import React from "react";
import "jest-styled-components";
import renderer from "react-test-renderer";
import { Selection } from "./";
import { SelectionProps } from "./types";
import { render, fireEvent, act } from "@testing-library/react";
// import { mount, shallow } from "enzyme";

// ref: https://www.robertcooper.me/testing-stateful-react-function-components-with-react-testing-library
// ref: https://github.com/cedrickchee/react-typescript-jest-enzyme-testing/blob/master/src/checkboxWithLabel.test.tsx
// ref: https://github.com/OfficeDev/office-ui-fabric-react/blob/master/packages/office-ui-fabric-react/src/components/Checkbox/Checkbox.test.tsx

describe("components/Selection", () => {
  const handleChangeMock = jest.fn();
  let props: SelectionProps;
  beforeEach(() => {
    props = {
      type: "checkbox",
      onChange: handleChangeMock,
      options: [
        { label: "one", checked: true },
        { label: "two", checked: false },
      ],
    };
    handleChangeMock.mockClear();
  });

  it("should match snapshot", () => {
    const tree = renderer.create(<Selection {...props} type="checkbox" />).toJSON();
    expect(tree).toMatchSnapshot();
  });

  it("should render checkbox with type correctly", () => {
    const { getAllByRole } = render(<Selection {...props} type="checkbox" />);

    const checkboxes = getAllByRole("checkbox");
    expect(checkboxes.length).toBe(2);
    checkboxes.forEach((el) => {
      expect(el).toHaveProperty("type", "checkbox");
    });
  });

  it("should render radio with type correctly", () => {
    const { getAllByRole } = render(<Selection {...props} type="radio" />);
    const radios = getAllByRole("radio");

    expect(radios.length).toBe(2);
    radios.forEach((el) => {
      expect(el).toHaveProperty("type", "radio");
    });
  });

  it("(checkbox) should default to false when not given checked prop", () => {
    const options = [{ label: "one" }, { label: "two" }];

    const { getAllByRole } = render(<Selection {...props} options={options} type="checkbox" />);
    const checkboxes: HTMLInputElement = getAllByRole("checkbox");

    expect(checkboxes[0].checked).toBe(false);
    expect(checkboxes[1].checked).toBe(false);
  });

  it("(radio) should default to false when not given checked prop", () => {
    const options = [{ label: "one" }, { label: "two" }];

    const { getAllByRole } = render(<Selection {...props} options={options} type="radio" />);
    const radios: HTMLInputElement = getAllByRole("radio");

    expect(radios[0].checked).toBe(false);
    expect(radios[1].checked).toBe(false);
  });

  it("should respect default checkbox prop", () => {
    const options = [
      { label: "one", checked: true },
      { label: "two", checked: false },
    ];

    const { getAllByRole } = render(<Selection {...props} options={options} type="checkbox" />);
    const checkboxes: HTMLInputElement = getAllByRole("checkbox");

    expect(checkboxes[0].checked).toBe(true);
    expect(checkboxes[1].checked).toBe(false);
  });

  it("should respect default radio prop", () => {
    const options = [
      { label: "one", checked: false },
      { label: "two", checked: true },
    ];

    const { getAllByRole } = render(<Selection {...props} options={options} type="radio" />);
    const radios: HTMLInputElement = getAllByRole("radio");

    expect(radios[0].checked).toBe(false);
    expect(radios[1].checked).toBe(true);
  });

  it("should trigger onChange when clicked", () => {
    const { getAllByRole } = render(<Selection {...props} type="checkbox" />);
    const checkboxes = getAllByRole("checkbox");

    fireEvent.click(checkboxes[0]);

    expect(handleChangeMock).toHaveBeenCalledTimes(1);
  });

  it("should return the right checkbox states after change", () => {
    const options = [
      { label: "one", checked: true },
      { label: "two", checked: false },
    ];

    const { getAllByRole } = render(<Selection {...props} options={options} type="checkbox" />);
    const checkboxes = getAllByRole("checkbox");

    fireEvent.click(checkboxes[0]);

    expect(handleChangeMock).toHaveBeenCalledTimes(1);
    expect(handleChangeMock).toHaveBeenCalledWith([
      { label: "one", checked: false },
      { label: "two", checked: false },
    ]);

    fireEvent.click(checkboxes[1]);

    expect(handleChangeMock).toHaveBeenCalledTimes(2);
    expect(handleChangeMock).toHaveBeenCalledWith([
      { label: "one", checked: false },
      { label: "two", checked: true },
    ]);
  });

  it("should return the right radio states after change", () => {
    const options = [
      { label: "one", checked: true },
      { label: "two", checked: false },
    ];

    const { getAllByRole } = render(<Selection {...props} options={options} type="radio" />);
    const radios = getAllByRole("radio");

    fireEvent.click(radios[0]);

    expect(handleChangeMock).toHaveBeenCalledTimes(0); // clicking on an already selected radio

    fireEvent.click(radios[1]);

    expect(handleChangeMock).toHaveBeenCalledTimes(1);
    expect(handleChangeMock).toHaveBeenCalledWith([
      { label: "one", checked: false },
      { label: "two", checked: true },
    ]);

    fireEvent.click(radios[0]);

    expect(handleChangeMock).toHaveBeenCalledTimes(2);
    expect(handleChangeMock).toHaveBeenCalledWith([
      { label: "one", checked: true },
      { label: "two", checked: false },
    ]);
  });
});

Testing with Redux

testing actions

testing reducers

testing components

Testing with React hooks

Other things

Mocking data fetching

Mocking libraries

the example below shows the testing of urls(), that uses an external library "unsplash-js" to fetch data. during the test, however, you can mock the library using jest.mock() that would magically listen to an import

import { urls } from "../Unsplash";

// the mock of the "unsplash-js" library should
// have all the properties that make it possible
// to call its api and return results
jest.mock("unsplash-js", () => ({
  __esModule: true, //https://remarkablemark.org/blog/2018/06/28/jest-mock-default-named-export/
  default: jest.fn(() => ({
    search: {
      photos: jest.fn(() => ({
        json: jest.fn(() =>
          Promise.resolve({
            results: Array.from(Array(10)).map((v, i) => ({
              urls: { regular: `url-stub-${i}` },
            })),
          })
        ),
      })),
    },
  })),
}));

describe("Unsplash API", () => {
  test("it should return a different url each time urls() is called", async () => {
    // Arrange
    const searchTerm = "inspirational";

    // Act
    const getImg = urls(searchTerm); // thunk
    const img1 = await getImg();
    const img2 = await getImg();

    // Assert
    expect(img1).not.toEqual(img2);
  });
});

Mocking exported functions

almost exactly like mocking a library (a library is an exported module/function after all). the caveats are that when you us jest.mock(), you will replace all the functions in the file with what you have in jest.mock(). if your tested function calls another function from the mocked file, it will look for it inside jest.mock().

to solve that, use jest.requireActual() inside your jest.mock() and spread all exports so that they are accessible to your test function. ref

jest.mock('./myModule.js', () => (
  {
    ...jest.requireActual('./myModule.js'),
    otherFn: () => {}
  }
))

describe(...)

a more complex example:

// don't import
// require after you mock

describe("fetchAdhocData", () => {
  beforeEach(() => {
    jest.resetModules();
  });

  it('should return an object with "bodyData" and "headerData" keys if fetch suceeds', async () => {
    // arrange
    // mock request-promise library (success response)
    jest.doMock("request-promise", () => {
      const mockSuccessResponse = {
        statusCode: 200,
        body: '{"stub":"stub"}',
      };

      console.log("MOCKING for fetchAdhocData");

      return {
        __esModule: true,
        default: jest.fn().mockResolvedValue(mockSuccessResponse),
      };
    });
    const { fetchData } = require("./utils.ts");

    const params = {
      uri: "stub",
      headerSql: "stub",
      bodySql: "stub",
    };

    // act
    const response = await fetchData(params);

    // assert
    expect(response).toHaveProperty("bodyData");
    expect(response).toHaveProperty("headerData");
  });

  it('should return an object with "err" key if fetch fails', async () => {
    // arrange
    // failure mock
    jest.doMock("request-promise", () => ({
      __esModule: true,
      default: jest.fn(
        () =>
          new Promise((_, reject) => {
            process.nextTick(() => reject({ stack: "stub" }));
          })
      ),
    }));
    const { fetchData } = require("./utils.ts");

    const params = {
      uri: "stub",
      headerSql: "stub",
      bodySql: "stub",
    };

    // act
    const errorResult = await fetchData(params);

    // assert
    expect(errorResult).toHaveProperty("err");
  });
});

Mocking local functions

not possible. only exported functions are visible to the outside, so if you need to test a function that uses another local function (function without export), you can't. you have to move it out (e.g. into a utils file) and mock that file.

Mocking Higher Order Components / Wrapper Components

jest.mock('../your/HOC', () => () =>
    Component => props => <Component {...props} />
)

// breakdown
jest.mock('../your/HOC', () => /* a function */)
// the function is the mock HOC, where propsFromWrapper is the wrapper component
// () => Wrapped => propsFromWrapper => <Wrapped {...propsFromWrapper}>
() => Component => props => <Component {...props}/>

Updating props (on @testing-library/react)

const { container } = render(<Foo bar={true} />);

// update the props, re-render to the same container (this means the component will not be unmounted / remounted)
render(<Foo bar={false} />, { container });

// can still use the same container and all other utilities

// you could also make a little utility
const updateProps = (props) => render(<Foo {...props} />, { container });
updateProps({ bar: false });

test coverage on one file / subdirectory of files

yarn test --coverage --collectCoverageFrom="src/app/components/Tools/**/*.js"

developing with jest

TZ=UTC NODE_PATH=src NODE_ENV='development' jest --no-cache --watch --coverage yourFilename --collectCoverageFrom='["src/**/yourFilename/**"]'

some benefits (over enzyme)

More advanced examples from @testing-library/react

<iframe src="https://codesandbox.io/embed/github/kentcdodds/react-testing-library-examples/tree/master/?fontsize=14" title="react-testing-library-examples" allow="geolocation; microphone; camera; midi; vr; accelerometer; gyroscope; payment; ambient-light-sensor; encrypted-media; usb" style="width:100%; height:500px; border:0; border-radius: 4px; overflow:hidden;" sandbox="allow-modals allow-forms allow-popups allow-scripts allow-same-origin"></iframe>

refs