Testing with React's Jest and Enzyme when simulated clicks call a function that calls a promise

Caspar picture Caspar · May 24, 2016 · Viewed 19.4k times · Source
  • React v15.1.0
  • Jest v12.1.1
  • Enzyme v2.3.0

I'm trying to figure out how to test a component that calls a promise in a function invoked by a click. I was expecting Jest's runAllTicks() function to help me out here, but it doesn't seem to be executing the promise.

Component:

import React from 'react';
import Promise from 'bluebird';

function doSomethingWithAPromise() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 50);
  });
}

export default class AsyncTest extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      promiseText: '',
      timeoutText: ''
    };

    this.setTextWithPromise = this.setTextWithPromise.bind(this);
    this.setTextWithTimeout = this.setTextWithTimeout.bind(this);
  }

  setTextWithPromise() {
    return doSomethingWithAPromise()
      .then(() => {
        this.setState({ promiseText: 'there is text!' });
      });
  }

  setTextWithTimeout() {
    setTimeout(() => {
      this.setState({ timeoutText: 'there is text!' });
    }, 50);
  }

  render() {
    return (
      <div>
        <div id="promiseText">{this.state.promiseText}</div>
        <button id="promiseBtn" onClick={this.setTextWithPromise}>Promise</button>
        <div id="timeoutText">{this.state.timeoutText}</div>
        <button id="timeoutBtn" onClick={this.setTextWithTimeout}>Timeout</button>
      </div>
    );
  }
}

And the tests:

import AsyncTest from '../async';
import { shallow } from 'enzyme';
import React from 'react';

jest.unmock('../async');

describe('async-test.js', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = shallow(<AsyncTest />);
  });

  // FAIL
  it('displays the promise text after click of the button', () => {
    wrapper.find('#promiseBtn').simulate('click');

    jest.runAllTicks();
    jest.runAllTimers();

    wrapper.update();

    expect(wrapper.find('#promiseText').text()).toEqual('there is text!');
  });

  // PASS
  it('displays the timeout text after click of the button', () => {
    wrapper.find('#timeoutBtn').simulate('click');

    jest.runAllTimers();

    wrapper.update();

    expect(wrapper.find('#timeoutText').text()).toEqual('there is text!');
  });
});

Answer

Jonathan Stray picture Jonathan Stray · May 8, 2017

Updated answer: using async / await leads to cleaner code. Old code below.

I've successfully solved this problem by combining the following elements:

  • Mock out the promise and make it resolve immediately
  • Make the test asynchronous by marking the test function async
  • After simulating the click, wait until the next macrotask to give the promise time to resolve

In your example, that might look like this:

// Mock the promise we're testing
global.doSomethingWithAPromise = () => Promise.resolve();

// Note that our test is an async function
it('displays the promise text after click of the button', async () => {
    wrapper.find('#promiseBtn').simulate('click');
    await tick();
    expect(wrapper.find('#promiseText').text()).toEqual('there is text!');
});

// Helper function returns a promise that resolves after all other promise mocks,
// even if they are chained like Promise.resolve().then(...)
// Technically: this is designed to resolve on the next macrotask
function tick() {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  })
}

Enzyme's update() is neither sufficient nor needed when using this method, because Promises never resolve in the same tick they are created -- by design. For a very detailed explanation of what is going on here, see this question.

Original answer: same logic but slightly less pretty. Use Node's setImmediate to defer the test until the next tick, which is when the promise will resolve. Then call Jest's done to finish the test asynchronously.

global.doSomethingWithAPromise = () => Promise.resolve({});

it('displays the promise text after click of the button', (done) => {
    wrapper.find('#promiseBtn').simulate('click');

  setImmediate( () => {
    expect(wrapper.find('#promiseText').text()).toEqual('there is text!');
    done();
  })
});

This isn't as nice because you'll get big nested callbacks if you have to wait for more than one promise.