1

I have a mocha based test which finishes before my onChange handler in a jsdom based enzyme test of my React component, despite that handler being synchronous using babel+ES2017. If I do a setTimeout() of 1ms to put my expect() calls in; the test passes.

Just wondering where the break down is? I'm sure there is some simple concept here I'm not considering. I'm thinking jsdom or enzyme does not wait around for the event handler to finish? A problem compounded by the length of time mocking fetch() with fetch-mock is taking (because it is asynchronous normally).

Is it resolvable without setTimeout(), sinon or lolex, and if not; is it possible with simon / lolex?

Tomorrow I expect I'll refactor it to avoid mocking fetch() in the tests.

Test output

</div>
    1) flashes a nice message upon success
Success now!!
End of function now. 


10 passing (4s)
1 failing

 1) <Signup /> flashes a nice message upon success:
 Uncaught AssertionError: expected { Object (root, unrendered, ...) } to have a length of 1 but got 0
  at test/integration/jsx/components/signup.test.js:38:54
  at _combinedTickCallback (internal/process/next_tick.js:67:7)
  at process._tickDomainCallback (internal/process/next_tick.js:122:9)

Bootstrap

require('babel-register')();
require('babel-polyfill');

...

var jsdom = require('jsdom').jsdom;
var exposedProperties = ['window', 'navigator', 'document'];

global.document = jsdom('');
global.window = document.defaultView;
global.FormData = document.defaultView.FormData;
Object.keys(document.defaultView).forEach((property) => {
  if (typeof global[property] === 'undefined') {
    exposedProperties.push(property);
    global[property] = document.defaultView[property];
  }
});

global.navigator = {
  userAgent: 'node.js'
};

documentRef = document;

Test

import React from 'react';
import { expect } from 'chai';
import { shallow, mount, render } from 'enzyme';
import Signup from '../../../../assets/js/components/signup.jsx';
import fetchMock from 'fetch-mock';
import sinon from 'sinon';
import 'isomorphic-fetch';

...

it("flashes a nice message upon success", function(){
  fetchMock.mock("*", {body: {}});
  const wrapper = shallow(<Signup />);

  wrapper.find('#email').simulate('change', {target: {id: 'email', value: validUser.email}});

  const signupEvent = {preventDefault: sinon.spy()};

  wrapper.find('#signupForm').simulate('submit', signupEvent);
  wrapper.update();

  console.log(wrapper.debug());
  expect(signupEvent.preventDefault.calledOnce).to.be.true;
  expect(wrapper.find('.alert-success')).to.have.length(1);
  expect(wrapper.find('.alert-success').text()).to.contain('Your sign up was successful!');

  fetchMock.restore();
});

Component

async handleSubmit(e) {
  e.preventDefault();
  this.setState({ type: 'info', message: 'Sending...', shouldValidateForm: true });
  let form = new FormData(this.form);
  let response;
  let responseJson = {};
  try {
    response = await fetch("/signup", {
      method: "POST",
      body: form
    });
    responseJson = await response.json();
    if(!response.ok){
      throw new Error("There was a non networking error. ");
    }
    this.setState({ type: 'success', message: 'Your sign up was successful!' });
    console.log("Success now!!");
  } catch(err) {
    this.setState({ type: 'danger', message: "There was a technical problem. "});
  }
  console.log("End of function now. ");
}

...

<form method="POST" onSubmit={this.handleSubmit} ref={(form) => {this.form = form;} } id="signupForm">
Ashley Simons
  • 340
  • 4
  • 13

2 Answers2

5

My first answer focussed on the asynchronous nature of simulate, but from comments it became clear enzyme's implementation of that method is not asynchronous as it just calls the click handler synchronously. So this is a rewrite of my answer, focussing on the other causes for asynchronous behaviour.

This test:

expect(wrapper.find('.alert-success')).to.have.length(1);

... fails because at that time the following line has not yet been executed:

this.setState({ type: 'success', message: 'Your sign up was successful!' });

I assume here that this setState call will add the alert-success class to the message element.

To see why this state has not yet been set, consider the execution flow:

wrapper.find('#signupForm').simulate('submit', signupEvent);

This will trigger what is specified in the onsubmit attribute of the form:

onSubmit={this.handleSubmit} 

So handleSubmit is called. Then a state is set:

this.setState({ type: 'info', message: 'Sending...', shouldValidateForm: true });

... but this is not the state you need: it does not add the alert-success class. Then an Ajax call is made:

response = await fetch("/signup", {
    method: "POST",
    body: form
});

fetch returns a promise, and await will pause the execution of the function until that promise is resolved. In the mean time, execution continues with any code that is to be executed following the call to handleSubmit. In this case that means your test continues, and eventually executes:

expect(wrapper.find('.alert-success')).to.have.length(1);

...which fails. The event signalling that the pending Ajax request has a response might have arrived on the event queue, but it will only be processed after the currently executing code has finished. So after the test has failed, the promise, that was returned by fetch, gets resolved. This is because the fetch's internal implementation has a callback notifying that the response has arrived, and thus it resolves the promise. This makes the function handleSubmit "wake up", as the await now unblocks the execution.

There is a second await for getting the JSON, which again will introduce a event queue cycle. Eventually (pun not intended), the code will resume and execute the state the test was looking for:

this.setState({ type: 'success', message: 'Your sign up was successful!' });

So... for the test to succeed, it must have an asynchronous callback implemented that waits long enough for the Ajax call to get a response.

This can be done with setTimeout(done, ms), where ms should be a number of milliseconds that is great enough to ensure the Ajax response has become available.

trincot
  • 317,000
  • 35
  • 244
  • 286
  • I guess why this wasn't obvious to me, is that all the input change events prior to the form submit were successfully rendered in time. So it sounds like the critical mass of work just hits a point where it finishes 'after' the test then? – Ashley Simons Apr 04 '17 at 09:49
  • Indeed, "asynchronous" here means that the event is put in the event queue, and so it does not get processed before the currently running code (your test) finishes to completion (i.e. JS call-stack is empty). The only way to get something done after the event (in the event queue) is processed, is to also put something in that queue. This is what `setTimeout` does. – trincot Apr 04 '17 at 10:00
  • Perhaps enzyme works differently. `simulate()` appears to be synchronous or reliable to me. I setState (via an event handler) 10000 times and a different state once at the end. That state was rendered ok without the use of `setTimeout()`. But as soon as the event handler has something asynchronous (the mocked `fetch()` in my case), it renders on the next loop. I'll answer my own post with an example component + test which seems to illustrate this to me. Unless you disagree or have something further to add? – Ashley Simons Apr 05 '17 at 07:38
  • Please go ahead. From the article I quoted, I had understood `simulate` is asynchronous, but apparently you have a different behaviour for it. – trincot Apr 05 '17 at 07:43
  • In fact, I will rewrite my answer also. – trincot Apr 05 '17 at 08:07
  • I have submitted mine. – Ashley Simons Apr 05 '17 at 08:28
  • OK, I have also updated mine. It shows why the submit handler sets the success status asynchronously. – trincot Apr 05 '17 at 08:37
  • I might just say that regardless of whether I use a regular promise or await in the component, using `setImmediate()` makes the original test pass. Does this contradict your statement that the 2nd await creates a new event/loop cycle? Perhaps the json promise specifically is synchronous. – Ashley Simons Apr 05 '17 at 09:09
  • Promises are never synchronous (by specification), even if the value they expose is immediately available. Anyway the first promise (fetch) is already responsible for the asynchronous nature, so the JSON promise does not really influence that. – trincot Apr 05 '17 at 09:34
1

It appears to me that unlike ReactTestUtils (which @trincot's answer is based on), enzyme's simulate() is in fact synchronous. However my mocked call to fetch() was asynchronous and the promises were resolving on the next event loop. Wrapping the expectations or assertions in a setTimeout(()=>done(), 0) should suffice and perhaps is more reliable than setImmediate() which seemed to have a higher priority than setTimeout() to me (even though they are both probably executing on the same event loop).

Here is a component and test I wrote to demonstrate.

The Test Output

<Example />
updated asynchronously
onChangeError ran. 
SUCCESS SOON: Taking a break...
Setting delayed success. 
      ✓ has a rendered success message on the next event loop 
    updated synchronously
onChangeError ran. 
Setting success. 
      ✓ has a rendered success message on this loop
onChangeError ran. 
onChangeError ran. 
onChangeError ran.  
...
onChangeError ran. 
onChangeError ran. 
onChangeError ran. 
Setting success. 
      ✓ has a rendered success message on this loop despite a large simulation workload (2545ms)

   3 passing (6s)

The Component

import React from 'react';
export default class Example extends React.Component {
  constructor(props){
    super(props);
    this.onChangeError = this.onChangeError.bind(this);
    this.onChangeSuccess = this.onChangeSuccess.bind(this);
    this.onChangeDelayedSuccess = this.onChangeDelayedSuccess.bind(this);
    this.state = { message: "Initial message. " };
  }
  onChangeError(e){
    console.log("onChangeError ran. ");
    this.setState({message: "Error: There was an error. "})
  }
  onChangeSuccess(e) {
    console.log("Setting success. ");
    this.setState({message: "The thing was a success!"});
  };
  onChangeDelayedSuccess(e){
    console.log('SUCCESS SOON: Taking a break...');
    setTimeout(() =>{
      console.log("Setting delayed success. ");
      this.setState({message: "The thing was a success!"});
    }, 0);
  }
  render(){
    return(
     <div>
       <p>{ this.state.message}</p>
       <input type="text" id="forceError" onChange={this.onChangeError} />
       <input type="text" id="forceSuccess" onChange={this.onChangeSuccess} />
       <input type="text" id="forceDelayedSuccess" onChange={this.onChangeDelayedSuccess} />
     </div>
    );
  }
}

The Test

import React from 'react';
import { expect } from 'chai';
import { shallow, mount, render } from 'enzyme';
import Example from '../../../../assets/js/components/example.jsx';

describe("<Example />", function() {
  describe("updated asynchronously", function() {
    it("has a rendered success message on the next event loop ", function(done) {
      const wrapper = shallow(<Example />);
      wrapper.find('#forceError').simulate('change', {target: {value: ""}});
      wrapper.find('#forceDelayedSuccess').simulate('change', {target: {value: ""}});

      setTimeout(function(){
        expect(wrapper.find('p').text()).to.contain('The thing was a success!');
        done();
      }, 0);
    });
  });
  describe("updated synchronously", function(){
    it("has a rendered success message on this loop", function(done) {
      const wrapper = shallow(<Example />);
      wrapper.find('#forceError').simulate('change', {target: {value: ""}});
      wrapper.find('#forceSuccess').simulate('change', {target: {value: ""}});

      expect(wrapper.find('p').text()).to.contain('The thing was a success!');
      done();
    });
    it("has a rendered success message on this loop despite a large simulation workload", function(done) {
      this.timeout(100000);
      const wrapper = shallow(<Example />);
      for(var i=1; i<=10000;i++){
        wrapper.find('#forceError').simulate('change', {target: {value: ""}});
      }
      wrapper.find('#forceSuccess').simulate('change', {target: {value: ""}});

      expect(wrapper.find('p').text()).to.contain('The thing was a success!');
      done();
    });
  });
 });
Ashley Simons
  • 340
  • 4
  • 13