Skip to content

Unit tests

Todd Schiller edited this page Sep 25, 2022 · 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.

Some rules of thumb for writing unit tests:

  • 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.

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.

Enabling console logs for tests in IntelliJ

NOTE: you must do this for each test run configuration. If you rerun a single test from a run, you'll have to follow these steps again fo the configuration IntelliJ created for running that single test

  1. Open the Run/Debug Configuration for the test
  2. Find the "Jest options" entry for the test
  3. Add --no-silent

Pro-tip: update the Jest configuration template to include --no-silent. (Does not affect existing configurations)

  1. Click "Edit configuration templates"
  2. Click "Jest"
  3. Add --no-silent to the "Jest options" entry

Viewing React/HTML structure

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

Notable Example Test Suites

Testing Basics

  1. Unit testing a plain function utils.test
  2. More complex unit tests partnerIntegrations.test

Front-end Tests

  1. A React component tests ArrayWidget.test
  2. More complex functional testing of a React component (with mocks, user events, handling async actions, etc) EditorPane.test

Runtime Tests

  1. Testing a transformer brick parseJson.test.ts
  2. Testing a brick with subpipelines IfElse.test.ts

Installer

  1. Tests referencing Redux state from the background page and using the browser API deployments.test.ts

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 (EditorPane.test has a lot of examples).

Pay attention to the render function that is imported from @/pageEditor/testHelpers. It supports Redux and Formik initialization.

See examples:

3. Testing regular functions

Just regular unit testing.

See examples:

4. Testing hooks

This article does a good job of explaining the various ways to test hooks, including using react-testing-library's renderHook() function.

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]);

Approach #4: use fake timers. You have to use immediate user events (click imitation) when fake timers are enabled (see here).

Example. EditorPane: instruct to use fake timers (see the beforeAll part here), perform some actions, run the timers, assert.

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.tsx

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
  })
);

Mocking RTK Query: Gotchas

When mocking RTK Query hooks, be careful to return a constant data reference. Otherwise, the calling the mocked hook will result in a re-render on each call.

jest.mock("@/services/api", () => ({
  // BAD! - returns a different `data` reference on each calls
  useGetRecipesQuery: jest.fn(() => {
    data: [],
    isLoading: false, 
  }),
});
const NO_RECIPES: readonly Recipe[] = Object.freeze([]);
jest.mock("@/services/api", () => ({
  // BETTER - returns a fixed data value. But object changes reference, so be careful if caller 
  // uses the returned object as a dependency, e.g., in useEffect
  useGetRecipesQuery: jest.fn(() => {
    data: NO_RECIPES,
    isLoading: false, 
  }),
});