Why does my test finish before my (enzyme simulate

2019-07-26 06:15发布

问题:

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">

回答1:

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.



回答2:

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();
    });
  });
 });