M4 Hints for unit testing React components in Mirador 4 - ProjectMirador/mirador GitHub Wiki

Here are some recommendations for unit testing React components in Mirador 3. Next you will see an example component followed by an commented unit test suite for that component. The intend of that unit test is to provide a template that can guide a developer while writing tests. Please consider the recommendations as rules of thumb that you can deviate from if you have reasons for that.

Example: CollectionInfo component

We will use this component as an example throughout this article. You can see the component within our codebase here: CollectionInfo.js. The corresponding test is here: CollectionInfo.test.js. For this wiki, the component is slightly edited to simplify text formatting, but otherwise it's what's in production.

// src/components/CollectionInfo.js

import { Component } from 'react';
import PropTypes from 'prop-types';
import Button from '@mui/material/Button';
import CollapsibleSection from '../containers/CollapsibleSection';

/**
 * CollectionInfo
 */
export class CollectionInfo extends Component {
  constructor(props) {
    super(props);

    this.openCollectionDialog = this.openCollectionDialog.bind(this);
  }

  openCollectionDialog() {
    const { collectionPath, showCollectionDialog, windowId } = this.props;

    const manifestId = collectionPath[collectionPath.length - 1];

    showCollectionDialog(manifestId, collectionPath.slice(0, -1), windowId);
  }

  render() {
    const {
      collectionPath,
      id,
      t,
    } = this.props;

    if (collectionPath.length === 0) return null;

    return (
      <CollapsibleSection
        id={`${id}-collection`}
        label="collection"
      >
        <Button
          onClick={this.openCollectionDialog}
        >
          {t('showCollection')}
        </Button>
      </CollapsibleSection>
    );
  }
}

CollectionInfo.propTypes = {
  collectionPath: PropTypes.arrayOf(PropTypes.string),
  id: PropTypes.string.isRequired,
  showCollectionDialog: PropTypes.func.isRequired,
  windowId: PropTypes.string,
};

CollectionInfo.defaultProps = {
  collectionPath: [],
  windowId: null,
  t: key => key,
};

Test Code

And here is the test code with comments.

// __tests__/src/components/CollectionInfo.test.js

/*
  *  __tests__/utils/test-utils.js exports a custome wrapper around the render function from React Testing Library.
  * It provides the ability to pass in a preloaded state and a custom Redux store to the rendered test component, 
  * and wraps a Material-UI theme around it. It also re-exports all the functions from React Testing Library,
  * so we can import everything from our test-utils file, such as 'render', 'screen', 'fireEvent', etc.
*/
import { render, screen } from 'test-utils';
/*
  * Always test the unconnected component.
  *
  * Here we import the component under test. As we write unit tests
  * we want to test the components in isolation. Therefore import the
  * unconnected component from the `components` folder instead of the
  * connected one from the 'containers' folder.
*/
import { CollectionInfo } from '../../../src/components/CollectionInfo';
// Required to simulate events
import userEvent from '@testing-library/user-event';

/*
  * createWrapper is useful when your test needs different test setups. You can provide
  * custom props by using the `props` argument of the function.
*/
function createWrapper(props) {
  return render(
    /*
    * Provide default props for the component.
    *
    * As we test in isolation it is up to us to pass all props to the component.
    * If you forget to provide a value for a prop that is reqired but has no
    * default value you will hopefully see an warning in the test results. But
    * when you forget to provide a value for a prop that is not required or
    * has a default value you will not be warned. In this case your test
    * runs under implicit assumptions. Better make clear what's going on and
    * provide all props.
  */
    <CollectionInfo
      id="test"
      collectionPath={[1, 2]}
      showCollectionDialog={() => {}}
      {...props}
    />,
  );
}

describe('CollectionInfo', () => {
  it('renders a collapsible section', async () => {
    /*
     * Setup userEvent --
     * often good to double check you've done this setup if your test events are not firing.
    */
    const user = userEvent.setup();
    // render the component
    createWrapper();

    /*
      * RTL's `screen` provides a set of methods to query the rendered DOM
      * getByRole looks for the accessible name of the element.
      * This is the recommended way to query for elements and supports accessibility.
      * See https://testing-library.com/docs/queries/byrole: 
      * "The accessible name is for simple cases equal to e.g. the label of a form element,
      * or the text content of a button, or the value of the aria-label attribute."
    */
    expect(screen.getByRole('heading', { name: 'collection' })).toBeVisible();
    expect(screen.getByRole('button', { name: 'showCollection' })).toBeVisible();

    /*
      * Simulate a user clicking a button
      * This will trigger the event handler `openCollectionDialog` which will change the state
      * and trigger a rerendering of the component.
      * Use `await` to wait for the state change and the rerendering to complete.
      * In this case, although the `collapseSection` button is defined in a different component (CollapsibleSection), because the resulting DOM is rendered in this component (CollectionInfo), we can test the resulting DOM here.
    */
    await user.click(screen.getByRole('button', { name: 'collapseSection' }));
    // Check that the button is no longer visible
    expect(screen.queryByRole('button', { name: 'showCollection' })).not.toBeInTheDocument();
  });

  it('without a collectionPath, renders nothing', () => {
    /* 
      * You can render the component with different props in each block if desired.
      * Here, render the component with an empty collectionPath
    */
    const wrapper = createWrapper({ collectionPath: [] });
    expect(wrapper.container).toBeEmptyDOMElement();
  });

  it('clicking the button fires showCollectionDialog', async () => {
    const user = userEvent.setup();
    // Here we pass a jest mock function to the component as a prop
    const showCollectionDialog = jest.fn();
    createWrapper({ showCollectionDialog });
    /* 
      * This click will trigger the event handler `showCollectionDialog` which is a mock function
      * and can be checked if it was called with the expected arguments.
    */
    await user.click(screen.getByRole('button', { name: 'showCollection' }));
    expect(showCollectionDialog).toHaveBeenCalled();
  });
});
⚠️ **GitHub.com Fallback** ⚠️