6

In my tests, I would like to block my main thread until one of my components finishes going through its lifecycle methods, through componentDidUpdate(), after I trigger an event that causes it to add children components to itself. How can I do so?

Something like this:

describe('my <Component />', () => {
  it('should do what I want when its button is clicked twice', () => {
    const wrapper = mount(<Component />);
    const button = wrapper.find('button');

    // This next line has some side effects that cause <Component /> to add
    // some children components to itself...
    button.simulate('click', mockEvent);

    // ... so I want to wait for those children to completely go through
    // their lifecycle methods ...
    wrapper.instance().askReactToBlockUntilTheComponentIsFinishedUpdating();

    // ... so that I can be sure React is in the state I want it to be in
    // when I further manipulate the <Component />
    button.simulate('click', mockEvent);

    expect(whatIWant()).to.be.true;
  });
});

(I want to do this because, right now, I get this warning:

Warning: setState(...): Can only update a mounted or mounting component. This usually means you called setState() on an unmounted component. This is a no-op.

I believe I'm getting it because my tests cause my component to change its internal state more quickly than React's internal multithreading magic can keep up with, so by the time I i.e. run button.simulate('click') the second time, React has instantiated the new child components but hasn't finished mounting them yet. I think that waiting for React to finish updating my Component and its children is the best way to solve that problem.)

Kevin
  • 14,655
  • 24
  • 74
  • 124
  • 1
    What does the click handler do? All state changes should be synchronous unless you specifically have some timer/async stuff going on. – Jacob Dec 22 '16 at 17:52
  • 1
    DOM updates are also synchronous unless you're doing something weird or are using an exotic flavor of React. I wouldn't be surprised if your `setState` warning is because of your component itself doing a `setState` before mount. – Jacob Dec 22 '16 at 18:29
  • 1
    @Jacob I'm using a third-party library, [React Widgets](https://jquense.github.io/react-widgets/docs/). My `` has a React Widgets `DateTimePicker` as one of its children, and the warning appears to be emitted from within the `DateTimePicker`. So unfortunately, I'm exactly sure what changes my event causes and I easily can't try to look into the component to make sure it isn't misbehaving. – Kevin Dec 22 '16 at 19:07
  • 1
    I'm not getting the warning when I run my app in the browser, even when I perform the same actions I simulate in the test, so I believe this problem is specific to how my test environment interacts with my component, not a bug within my component (although I could very well be wrong about that). – Kevin Dec 22 '16 at 19:09
  • 1
    Finally, are you sure that React should synchronously update components? In [a conversation I had in chat](http://chat.stackoverflow.com/rooms/17/conversation/settimeout-on-unmounted-component-warning), someone [suggested that's not the case](http://chat.stackoverflow.com/transcript/17?m=34732693#34732693). And when I surround parts of my code in `settimeout` blocks to give a split-second between the events I simulate, the warning goes away, which really makes it seem to me like React is doing something asynchronously that I want my test to wait for. – Kevin Dec 22 '16 at 19:12
  • 1
    React fiber is an experimental thing they're working on for non-synchronous DOM updates, but that isn't "live" yet. But that's not to say that the component you're testing isn't doing async stuff manually. – Jacob Dec 22 '16 at 19:20

1 Answers1

4

Try wrapping your expect() blocks in a setImmediate() block:

describe('my <Component />', () => {
  it('should do what I want when its button is clicked twice', (done) => {
    const wrapper = mount(<Component />);
    const button = wrapper.find('button');

    button.simulate('click', mockEvent);
    button.simulate('click', mockEvent);

    setImmediate(() => {
      expect(whatIWant()).to.be.true;
      done();
    });
  });
});

Here's what's going on: To handle asynchronicity, Node and most browsers have an event queue behind the scenes. Whenever something, like a Promise or an IO event, needs to run asynchronously, the JS environment adds it to the end of the queue. Then, whenever a synchronous piece of code finishes running, the environment checks the queue, picks whatever is at the front of the queue, and runs that.

setImmediate() adds a function to the back of the queue. Once everything that is currently in the queue finishes running, whatever is in the function passed to setImmediate() will run. So, whatever React is doing asynchronously, wrapping your expect()s inside of a setImmediate() will cause your test to wait until React is finished with whatever asynchronous work it does behind the scenes.

Here's a great question with more information about setImmediate(): setImmediate vs. nextTick

Here's the documentation for setImmediate() in Node: https://nodejs.org/api/timers.html#timers_setimmediate_callback_args

Community
  • 1
  • 1
Kevin
  • 14,655
  • 24
  • 74
  • 124
  • I've tried this `setImmediate()` trick in some of my tests, but not in the specific situation described in my question. So there be dragons, and this solution might not work. – Kevin Apr 07 '17 at 15:46
  • When an assertion (`expect`) fails inside `setImmediate()`, the test terminates with an error instead of marking it as `Fail` and continue. Any work around? – Vicky Leong Aug 21 '17 at 00:33
  • @VLeong Are you writing your tests as asynchronous tests by using the `done` function in your `it` blocks? See https://mochajs.org/#asynchronous-code – Kevin Aug 21 '17 at 06:15