Skip to content

M3 Hints for unit testing react components in Mirador 3

Marlo Longley edited this page Mar 15, 2024 · 3 revisions

(this page was mistakenly deleted and recreated. It was last edited by Mathias Maaß in 2019)

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 Component

We will use the following component as an example throughout this article.

// src/components/TodoList.js

import React from 'react';
import PropTypes from 'prop-types';
/*
 * Some of the elements that should be rendered are
 * connected container components.
 */
import ListItem from '../containers/ListItem';
import Button from '../containers/Button';

/*
 * This component renders a list of todos that it gets passed via props.
 */
class TodoList extends React.Component {
  constructor(props) {
    super(props);
    this.state = { upperCase: false };
  }

  /*
  * Toggle upper case property in state
  */
  toggleUpperCase() {
    this.setState(prev => { upperCase: !prev.upperCase });
  }

  render() {
    const { todos, invertAlphaOrder } = this.props;
    const { upperCase } = this.state;
    return (
      <div>
        <ul>
          /*
          * Render a list of todos using <ListItem/> component
          */
          {todos.map(todo => <ListItem text={todo.text} case={upperCase} />)}
        </ul>
        /*
         * This button should invert the alphabetical order of the todo list.
         * The event handler `invertAlphaOrder` is passed via props.
         * Imagine it is a redux action.
         */
        <Button onClick={invertAlphaOrder} text="Invert Order" />
        /*
         * This button toggles between upper and lower case text. Quite useful!
         * The event handler `toggleUpperCase` is defined within the component.
         * It changes the state and triggers a rerendering.
         */
        <Button onClick={toggleUpperCase} text="Toggle Upper Case" />
      </div>
    );
  }
}

TodoList.propTypes = {
  todos: PropTypes.array.isRequired,
  invertAlphaOrder: PropsTypes.func.isRequired,
};

export default TodoList;

Test Code

And here is the test code with comments.

// __tests__/src/components/TodoList.js

import React from 'react';
/*
* Beside Jest as a test runner and assertion library
* we use Enzyme as a testing framework for react components.
*/
import { shallow } from 'enzyme';
/*
* Here we import the constructors of the child components that
* are rendered by the <TodoList/>. We need them for convenient
* query of the rendered output of <TodoList/>. See below.
*/
import ListItem from '../../../src/containers/ListItem';
import Button from '../../../src/containers/Button';
/*
* 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 TodoList from '../../../src/components/TodoList';

/*
* This is an alternative for the `beforEach` setup that creates an
* enzyme wrapper either with default props or with custom props. It's
* useful when your test needs different test setups. You can provide
* custom props by using the `props` argument of the function. E.g.:
*
*     createWrapper({ todos: customTodos })
*/
function createWrapper(props) {
  /*
  * Shallow render the component.
  *
  * As we writing unit tests for a certain component we are only interested
  * in the outcome of that component. Therefore use the `shallow()` function
  * of enzyme. It only renders the component's element tree (the result of the
  * `render()` function) but not those of its children.
  */
  return shallow(
    <TodoList
      /*
      * Provide all 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.
      */
      todos={[ { text: 'foo' }, { text: 'bar' } ]}
      invertAlphaOrder={() => {}}
      {...props}
    />,
  );
}

describe('TodoList', () => {
  /*
  * Test that the important elements are present.
  *
  * What elements are important depends on the specific case. If you're
  * not sure, test that all elements are present in the rendered tree.
  */
  it('should render all needed elements', () => {
    const wrapper = createWrapper();
    expect(wrapper.find('div').length).toBe(1);
    expect(wrapper.find('ul').length).toBe(1);
    /*
    * Use constructors instead of component name strings.
    *
    * When querying the shallow rendered tree for child components you can use
    * either the component's name string or the component's constructor.
    * Especially in case of container components it's easier and less error
    * prone to import and use the constructor. Using the name string is less
    * reliable and includes to figure out how the differnt component wrappers
    * create the name string. Heres an example of using name strings:
    *   
    *     wrapper.find('Connect(WithStyles(Button))')
    *
    * In contrast, using component constructors is more convenient:
    */
    expect(wrapper.find(ListItem).length).toBe(2);
    expect(wrapper.find(Button).length).toBe(2);
  });
  /*
  * Test that props are passed correctly to child elements.
  *
  * We don't want to test always each and every prop because this is tedious
  * and probably clutters the code without a big testing benefit. So we always
  * have to find a trade-off. I would say that testing logical/behavioral things
  * is more important then testing things that deal with style and appearance
  * because the latter can be easily observed by looking at the user interface
  * of the application.
  */
  it('should pass correct props to list items', () => {
    const wrapper = createWrapper();
    let props = wrapper.find(ListItem).at(0).props();
    expect(props.text).toBe('foo');
    expect(props.case).toBe(false);
    props = wrapper.find(ListItem).at(1).props();
    expect(props.text).toBe('bar');
    expect(props.case).toBe(false);
  });

  it('should pass correct props to the invert order button', () => {
    const invertAlphaOrder = jest.fn();
    const wrapper = createWrapper({ invertAlphaOrder });
    expect(wrapper.find(Button).at(0).props().onClick).toBe(invertAlphaOrder);
  });

  it('should pass correct props to the upper case button', () => {
    const wrapper = createWrapper();
    const { toggleUpperCase } = wrapper.instance();
    expect(wrapper.find(Button).at(1).props().onClick).toBe(toggleUpperCase);
  });
  /*
  * Test different conditions and execution paths.
  *
  * We should not only test the likeliest use cases. Some components have several
  * execution paths or conditions that we need to test. In the example component there is
  * actually only one execution path, so it's a bad example for this point. But let's
  * test that the component works correctly with an empty `todos` prop.
  */
  it('works correctly if todos are empty', () => {
    const wrapper = createWrapper({ todos: [] });
    expect(wrapper.find('ul').length).toBe(1);
    expect(wrapper.find(ListItem).length).toBe(0);
  });
  /*
  * Test the event handlers of the component.
  *
  * We should test event handlers that are defined within the component.
  * It is not necessary to test event handlers or functions that are provided
  * as a prop and that will only be passed to children. Take for example the
  * `ìnvertAlphaOrder` function: The logic of that function should be tested at
  * a different place as the component does not define it. Also, there is no need
  * to simulate a click on the <Button/> the function is passes to as it would test
  * the behavior of <Button/>, but not of <TodoList/>. So it's sufficent to test whether
  * the function was correctly passed to the button, as we did in the test above.
  *
  * The `toggleUpperCase` function on the other hand needs to be tested because it
  * is defined within the component and effects it behavior directly (it triggers a
  * rerendering).
  */
  it('should upper case list items on button click', () => {
    const wrapper = createWrapper();
    expect(wrapper.find(ListItem).at(0).props().case).toBe(false);
    expect(wrapper.find(ListItem).at(1).props().case).toBe(false);
    wrapper.find(Button).at(1).simulate('click');
    expect(wrapper.find(ListItem).at(0).props().case).toBe(true);
    expect(wrapper.find(ListItem).at(1).props().case).toBe(true);
  });
});