Posted 7 years ago

Unit testing React components, 5 basic techniques

⚠️

Outdated content

This post was written 7 years ago

A lot can change in the development world in that amount of time. This post may contain information that is no longer accurate, refer to libraries that are no longer maintained, or refer to deprecated APIs.

Especially when it comes to testing in React, I strongly discourage using enzyme. Instead look at something like React Testing Library.

This post will go through a few simple techniques that are great to know when you are testing your React components.

What packages will be used, and what do they do.

If you want to get a project up and running to try these things just use create-react-app and add enzyme to it.

Let's get started. Firstly, all test files are required to have these imports:

import { shallow } from 'enzyme';

1. Almost everything should be shallow(<Component />)

For the most part we want to create unit tests, which means we are not interested in life cycle or deep rendering of components. Anything that is not directly related to the logic we are testing is either stubbed out or simply not executed.

By default, shallow will call componentDidMount on mount and componentDidUpdate after setting props via setProps. This can be disabled by passing in disableLifeCycleMethods: true. Usually you don't have to worry about this.

describe('Simple example', () => {
  it('should always use shallow if you can', () => {
    const wrapper = shallow(<Blog />);

    expect(wrapper.state().someValue).toEqual('Something');
  });
});

2. Executing only what you are testing

Use the .instance() method to access class methods. Any stateful component's class methods will be accessible here as well as most React life cycle methods. This is very useful for making sure you're only testing what you need.

describe('Execute example', () => {
  it('should only execute what you need', () => {
    const mockFetch = jest.fn();
    const wrapper = shallow(<Blog fetchPost={mockFetch} />);

    wrapper.instance().someClassMethodThatFetchesData(true);

    expect(mockFetch).toHaveBeenCalledTimes(1);
  });
});

3. Assert that stuff is happening

Using jests built in jest.spyOn(...) and jest.fn() will let you do proper assertions easily.

describe('Jests function example', () => {
  it('lets you assert your flow', () => {
    const mockFunction = jest.fn();
    const wrapper = shallow(<Blog someMethodThatsCalledOnMount={mockFuntion} />);

    // Expect in different ways
    expect(mockSomeFunction).toHaveBeenCalled();
    expect(mockSomeFunction).toHaveBeenCalledTimes(1);
    expect(mockSomeFunction).toHaveBeenCalledWith('some param', 'or even two params');
  });

  it('lets you assert your flow', () => {
    const wrapper = shallow(<Blog someFunctionThatsCalledOnMount={mockSomeFunction} />);
    const spy = jest.spyOn(wrapper.instance(), 'someClassMethod');

    wrapper.instance().someOtherClassMethodThatInvokesTheFirstOneMaybe();

    expect(spy).toHaveBeenCalled();
  });
});

4. Mock, stub and otherwise remove execution of code that you're not testing

This can range from something simple as passing in a jest.fn() as a prop, to mutating the instance object of the component to stubbing out imported libraries.

4.1 Passing props

Especially when working with a Redux connected component you will have to pass in a lot of empty functions (if you don't care whether they are executed or not) or jest.fn() if you need to assert that they have been executed.

import ConnectedBlog from './MyConnectedBlog';

// This is very useful for being able to get the normal non redux-connected component.
const Blog = ConnectedComponent.WrappedComponent;

describe('Redux connected or similarly prop-heavy component', () => {
  it('should only execute what you need', () => {
    const mockedAction = jest.fn();
    const wrapper = shallow(<Blog goodAction={mockedAction} otherAction={() => {}} />);

    // Alternative to calling instance().componentWillRecieveProps() directly.
    wrapper.setProps({ bestProp: true });

    expect(mockedAction).toHaveBeenCalledWith(true);
  });
});

4.2 Overwriting instance methods

In some scenarios you only want to test MethodA() which also calls MethodB(), but B is heavy and cumbersome and you don't want to mock everything B is using and then some. The easiest thing is to just remove B from the equation all together.

describe('Overwriting methods', () => {
  it('the simplest way of removing complexity', () => {
    const mockedMethodB = jest.fn();
    const wrapper = shallow(<Blog />);

    wrapper.instance().methodB = mockedMethodB;
    wrapper.instance().methodA();

    expect(mockedMethodB).toHaveBeenCalledTimes(1);
  });
});

4.3 Stubbing out libraries

Some times external code can be a big complexity in our tests, and might even slow them down if we let them execute (even though they don't do any other harm). But we can very simply stop them from executing, and even mock the result if our code is dependent on it.

Because of the way JavaScript works when it comes to loading modules, if we import a module in our test and then mutate it, those changes will also apply to the import in our module. This can be used for stubbing external libraries or even other files that our component uses.

// OurButton.js
import { someHeavyFunction } from '../myHeavyUtils.js';

export const OurButton = (props) => (
  <div>Result from heavy method is: {someHeavyFunction(props.data)}</div>
);

// OurButton.test.js
import * as heavyUtils from '../myHeavyUtils.js'; // Note how we import it

describe('Stubbing methods', () => {
  it('is the best way to remove external complexity', () => {
    const heavyUtilsSpy = jest.spyOn(heavyUtils, 'someHeavyFunction').mockImplementation((data) => {
      // Data is the parameter our method would normally recieve
      return data + 15;
    });
    const wrapper = shallow(<Blog data={10} />);

    expect(wrapper.text()).toEqual('Result from heavy method is: 25');
  });
});

5 Working with Promises (not React components specific)

I like to keep fetching and data transformation out of my components, but you don't always need Redux, so here are a few tips for working with Promises. These tips also apply to test execution around promises in general.

// FetchingThing.js
import { Component } from 'react';

class FetchingThing extends Component {
  constructor() {
    super();

    this.state = {
      data: null,
      error: null,
      loading: true,
    };
  }

  componentWillMount() {
    this.fetchStuff();
  }

  fetchStuff() {
    return fetch('/api/stuff') // Note the return
      .then((response) => {
        this.setState({ loading: false, data: response.json() });
      })
      .catch((err) => {
        this.setState({ loading: false, error });
      });
  }

  render() {
    // A sick render method
  }
}

You might think that testing this is impossible because of the fetch and the promise, but in reality it is very simple. Step one is to remove the fetch from the equation, step two is to tell the test runner that we are waiting for a promise!

describe('Working with promises', () => {
  it('is quite simple if you give the promise to the test runner', () => {
    const mockData = { goodResponse: 'yes' };

    /* First we need stub out fetch, we do this my providing an
       already resolved promise with our preferred data. */
    jest.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(mockData));

    const wrapper = shallow(<FetchingThing />, { disableLifecycleMethods: true });

    /* We need to return the promise to the test runner
       so that it doesn't move on until we are done */
    return wrapper
      .instance()
      .fetchStuff()
      .then(() => {
        // We hook into the end of the promise chain
        expect(wrapper.state().data).toEqual(mockData);
      });
  });

  it('take in done parameter to tell the runner your self', (done) => {
    // Note the done
    const mockError = { error: 'boo!' };

    // We can even test error paths
    jest.spyOn(window, 'fetch').mockImplementation(() => Promise.reject(mockError));

    const wrapper = shallow(<FetchingThing />, { disableLifecycleMethods: true });

    // We are not returning the promise
    wrapper
      .instance()
      .fetchStuff()
      .then(() => {
        expect(wrapper.state().error).toEqual(mockData);

        done(); // Now jest can move on to the next test!

        // If we end up never calling done() the test will time out, fail and move on.
      });
  });
});

If you felt that I left out something fundamental or that I'm doing something wrong, please let me know!