Skip to content

Unit tests

Todd Schiller edited this page Nov 23, 2021 · 29 revisions

Unit testing

General

We use Testing Library for UI unit tests.

It gets a component and renders it in a sandbox environment (click here to see why this approach). Then we can assert the rendered DOM using snapshots or query helpers.

There's a catch though. As we know components can use hooks, some of the hooks are asynchronous. This means the component will render several times before getting to a stable state. In the browser React updates the page with every re-render cycle. In a test, we need to tell explicitly "hey, wait for another cycle and then continue". The way to do that is to call the act function (if interested, you could start from FAQ "How do I fix 'an update was not wrapped in act(...)' warnings?"), use async utilities, or await for our waitForEffect helper.

Debugging Tests

To view console output, you must run the test(s) in no-silent mode (npm run test:watch -- --no-silent). By default our tests are configured to only output test failures.

Since testing of components relies on the rendered output of the component you may want to see the exact HTML produced during a test. Use screen.debug() for that:

import { render, screen } from "@testing-library/react";

render(<Component />);
const someInput = screen.getByLabelText("Field label");

screen.debug(); // to see the whole rendered output
screen.debug(someInput); // to see the HTML of the someInput element

Some rules of thumb for writing unit tests:

  • try to have one expect per test;
  • use test.each for multiple test cases of the same functionality (you can have the test name, value to test and expected value in the array);
  • use contextual matchers instead of generics, favor .toBeNull over .toBe(null), use HTML element matches like .toHaveClass, use .not to negate the matcher like .not.toBeNull.

Testing approaches

1. Snapshot testing of React components.

Snapshot testing means that the test will render a component save the rendered HTML in a snapshot file and on the next run it will compare the freshly rendered HTML with the snapshot. Snapshot tests are simple to create but may be cumbersome to maintain. Keep in mind that when you add a snapshot test for a page, a change in any component used on that page will fail the test. Snapshot tests are good as unit tests for small components (like SwitchButtonWidget) or as integration tests for bigger components involving several moving parts.

See examples:

2. Functional testing of React components.

The test still renders the component but instead of comparing the markup to the saved snapshot, the test expects to find (or not to find) certain elements or text content in the rendered DOM. To access the rendered DOM you can use Queries (preferable) or element query selectors. Trigger user events like clicks or text input as needed (FormEditor has a lot of examples).

See examples:

3. Testing regular functions

Just regular unit testing.

See examples:

Dealing with Async State

Approach #1: use waitForEffect

Approach #2: split the component into a presentational (sometimes call "dumb") component and a stateful component (sometimes called a "smart" or "connected" component)

Approach #3: split the useAsyncState value generators/factories out into helper methods. Use jest mocks to mock the return value of the factory (using mockResolvedValue for the resolved promise, or a regular mock for an unresolved promise). If a component has multiple useAsyncState uses, you may want to combine them into a single useAsyncState call which returns a single promise (just be sure to use Promise.all where appropriate to enable network request promises to run concurrently)

const state = useAsyncState(async () => helper(foo, bar), [foo, bar]);

Dealing with Timestamps

If you need to create predictable timestamps, you can use the mockdate library:

import MockDate from "mockdate";
const timestamp = new Date("10/31/2021");
MockDate.set(timestamp);

Dealing with 3d party libraries

1. React Router

If you need to test a component that requires Router context, you can wrap the component under test with the StaticRouter.

import { StaticRouter } from "react-router-dom";
...
<StaticRouter>
  <InstalledPage extensions={[]} push={jest.fn()} onRemove={jest.fn()} />
</StaticRouter>

Example: InstalledPage.test

2. Formik

Add <Formik> around your component. You can use createFormikTemplate helper from formHelpers

const FormikTemplate = createFormikTemplate({
  [RJSF_SCHEMA_PROPERTY_NAME]: {
    schema,
    uiSchema: {},
  } as RJSFSchema,
});

render(
  <FormikTemplate>
    <FormEditor activeField={fieldName} {...defaultProps} />
  </FormikTemplate>
);

Example: FormEditor.test

Mocks

Mocks with different implementations per test

If you need to mock a module with different implementations per test, you can use mockImplementation after mocking & importing the mocked module. Clean up the mock after a test run with mockClear() or mockReset()

jest.mock("packageToMock", () => ({
  methodToMock: jest.fn()
}));
import { methodToMock } from "packageToMock";

afterEach(() => {
  // clear the mock usage
  methodToMock.mockClear();
  // or make a deep cleaning including implementations and return values
  // methodToMock.mockReset();
});

test("test foo", () => {
  methodToMock.mockImplementation(() => "foo");
  // or
  // methodToMock.mockImplementationOnce(() => "foo");
  // or
  // methodToMock.mockReturnValue("foo");
  // or
  // methodToMock.mockReturnValueOnce("foo");
  // see docs at https://jestjs.io/docs/mock-function-api
});

test("test bar", () => {
   methodToMock.mockImplementation(() => "bar")
});

Example: InstalledPage.test

Alternatively, you can look into doMock.

Mocking a single method in a module

Important: __esModule: true is required for the default export of the module to work. Otherwise imports of the default module will resolve to a plain object with a "default" property

Example: LocalProcessOptions.test.tsx

jest.mock("@/components/form/widgets/RemoteSelectWidget", () => {
  // The jest.requireActual must be its own statement. You cannot spread it in the returned object
  const mock = jest.requireActual(
    "@/components/form/widgets/RemoteSelectWidget"
  );
  return {
    // Required for the default of the export module to work
    __esModule: true,
    ...mock,
    // The things you actually want to mock
    useOptionsResolver: jest.fn().mockReturnValue([[], false, undefined]),
  };
});

Mocking the default export of a module

Example: LocalProcessOptions.test.tsx

Return the mock directly from the mock factory function:

jest.mock("@/services/useDependency", () =>
  jest.fn().mockReturnValue({
    // Omitted for brevity
  })
);