TTFB: The Traveloka Frontend Blog

Testing React Component using Enzyme + Jest Part 2: React Lifecycle

November 01, 2017 by Fatih Kalifa

As promised from previous post, we’re going to dive into some common scenarios when testing our React component: lifecycle method. Some components may not need these tests because they don’t have one. For that I’d recommend snapshot testing or simple render assertion.

Enzyme render types

Most of the time we only need to learn about mount function from enzyme, but enzyme also provides shallow and render to help with testing.

Shallow rendering doesn’t render react composite component, which means if we have <Button /> component in our render method, it stays rendered as <Button />, not DOM component (e.g: <button></button>).

This rendering method is usually useful to test component in isolation, and snapshot testing. One thing to note that shallow rendering won’t trigger componentDidMount and componentDidUpdate lifecycle. For that, we need to use mount.

Static rendering using render will return a CheerioWrapper. Common use cases for this is to assert the HTML output using .html() method. Using returned HTML for snapshot testing is not preferable because it’s a string, we can use mount for that.

import { render } from 'enzyme';

test('render button', () => {
  const wrapper = render(<Button />);
  expect(wrapper.html()).toEqual('<button>test button</button>');
});

Common Enzyme APIs

Enzyme APIs is quite big, but we need 20% of them 80% of the time: mount is one of them. This full rendering API will returns ReactWrapper that we can use to find child node or get its props and state.

  • .find() method will allow us to get either DOM node or react composite node. Both of them will return ReactWrapper regardless of the number of node they’re matching. We can chain it with .at() to get more specific node.

  • .simulate() can be used to trigger fake DOM event. First we need to find the DOM node via .find(). This method expects the number of node returned by .find() to be exactly 1. We can use .at to get exact index

We can also retrieve component props and state using .props() and .state() method in ReactWrapper. This is especially useful when asserting prop change / function spy.

import { mount } from 'enzyme';
import Component from '../Component';

const wrapper = mount(<Component />);

// find DOM node, like document.querySelectorAll
wrapper.find('.byClass');

// find react composite node, use PascalCase react display name as selector
wrapper.find('DisplayName');

// find specific node between nodes
// e.g: get 2nd div
wrapper.find('div').at(1);

// get rendered props, returns props object
wrapper.props();

// get child react component state, returns state object
wrapper.find('LanguagePicker').state();

componentDidMount

One of the most common lifecycle method we use is componentDidMount, usually used to set up event listener, or fetch data from API. To test this we’re going to use mount method.

For example we want to test flight search component that fetches data and renders list of that data:

class Component extends React.Component {
  state = {
    isFetching: true,
    results: [],
  }

  async componentDidMount() {
    const spec = this.getSpec();
    const result = await this.props.dispatch(
      post('/v2/flight/search', spec)
    );

    this.setState({
      isFetching: false,
      results: result.data,
    });
  }

  render() {
    if (this.state.isFetching) {
      return <div>{'loading'}</div>
    }

    return (
      <div>
        {this.state.results.map(result => (
          <FlightSearchResult {...result} />
        ))}
      </div>
    );
  }
}

There’s a lot of ways to test this scenario, one of them is by mocking the actual API call with predefined result so we can assert that it renders correct number of result.

Mount will always trigger componentDidMount, so we can make sure that our API call mock will be executed:

test('render results from API', () => {
  // we use mock dispatch to simplify our test logic
  const dispatch = jest.fn().mockReturnValue(Promise.resolve({
    data: Array(5).fill({ price: 1000 });
  }));

  const wrapper = mount(<Component dispatch={dispatch} />);

  // make sure our dispatch props is called
  expect(dispatch).toBeCalled();

  // trigger force update to bypass async set state
  wrapper.update();
  expect(wrapper.find('FlightSearchResult').length).toEqual(5);
});

We can also use actual redux store and <Provider /> but that’s for another post.

componentWillUnmount

This lifecycle generally used as cleanup mechanism for either event, timer or any subscription. We can use .unmount() method from ReactWrapper instance to trigger this lifecycle.

class Component extends React.Component {
  componentDidMount() {
    document.addEventListener('scroll', this.handleScroll)
  }

  componentWillUnmount() {
    document.removeEventListener('scroll', this.handleScroll);
  }
}

Testing this scenario is straightforward, we can use spy on removeEventListener and make sure that it’s called with correct event name. Don’t forget to restore the spy after assertion.

import { mount } from 'enzyme';

test('cleanup event', () => {
  const spy = jest.spyOn(document, 'removeEventListener');
  const wrapper = mount(<Component />);
  wrapper.unmount();

  // assert lifecycle
  expect(spy).toBeCalled();

  // assert fn argument
  expect(spy.mock.calls[0][0]).toEqual('scroll');

  // cleanup mock because we change global object
  spy.mockClear();
  spy.mockRestore();
})

componentWillReceiveProps

This lifecycle method is used to update new state in response to new props.

class Component extends React.Component {
  state = {
    value: this.props.value,
  }

  componentWillReceiveProps(nextProps) {
    this.setState({ value: nextProps.value });
  }

  render() {
    return null;
  }
}

We can do .setProps() to trigger this lifecycle and assert its state

test('update state in response to props', () => {
  const wrapper = mount(<Component value="initial" />);

  // assert state construction
  expect(wrapper.state().value).toEqual('initial');

  wrapper.setProps({ value: 'next' });

  // assert state change using state object
  // or we can assert using rendered component
  expect(wrapper.state().value).toEqual('next');
});

We can also use .setProps() as a event handler callback, so our controlled component will always be re-rendered.

test('increase counter', () => {
  let counter = 1;
  const props = {
    onChange: () => {
      counter++;
      // we can use `wrapper` here because it will be hoisted
      wrapper.setProps({ counter });
    },
    value: counter,
  }

  const wrapper = mount(<Counter {...props} />)
  // this will trigger onChange props
  wrapper.find('button').simulate('click');

  // assert the new props
  expect(wrapper.props().value).toEqual(2);
  // or to make sure the value rendered correctly
  expect(wrapper.find('span').text()).toEqual('2');
});

Next, we’ll cover manual DOM handling and side effect inside window.location object. Stay tuned!