Dirkos
Dirkos

Reputation: 676

Mock multiple fetch calls with state updates in ReactJS

I am having a ReactJS component which does two things: - on ComponentDidMount it will retrieve a list of entries - on Button click it will submit the select entry to a backend

The problem is that i need to mock both requests (made with fetch) in order to test it properly. In my current testcase i want to test a failure in the submit on the button click. However due some odd reason the setState is triggered however the update from that is received after i want to compare it.

Dumps i did for the test. First one is the state as listen in the test. The second is from the code itself where it is setting state().error to the error received from the call

FAIL  react/src/components/Authentication/DealerSelection.test.jsx (6.689s)
● Console

  console.log react/src/components/Authentication/DealerSelection.test.jsx:114
    { loading: true,
      error: null,
      options: [ { key: 22, value: 22, text: 'Stationstraat 5' } ] }
  console.log react/src/components/Authentication/DealerSelection.jsx:52
    set error to: my error

The actual test code:

it('throws error message when dealer submit fails', done => {
  const mockComponentDidMount = Promise.resolve(
    new Response(JSON.stringify({"data":[{"key":22,"value":"Stationstraat 5"}],"default":22}), {
      status: 200,
      headers: { 'content-type': 'application/json' }
    })
  );
  const mockButtonClickFetchError = Promise.reject(new Error('my error'));

  jest.spyOn(global, 'fetch').mockImplementation(() => mockComponentDidMount);
  const element = mount(<DealerSelection />);

  process.nextTick(() => {
    jest.spyOn(global, 'fetch').mockImplementation(() => mockButtonClickFetchError);
    const button = element.find('button');
    button.simulate('click');
    process.nextTick(() => {
      console.log(element.state()); // state.error null even though it is set with setState but arrives just after this log statement
      global.fetch.mockClear();
      done();
    });
  });
});

This is the component that i actually use:

import React, { Component } from 'react';
import { Form, Header, Select, Button, Banner } from '@omnius/react-ui-elements';
import ClientError from '../../Error/ClientError';
import { fetchBackend } from './service';
import 'whatwg-fetch';
import './DealerSelection.scss';

class DealerSelection extends Component {

  state = {
    loading: true,
    error: null,
    dealer: '',
    options: []
  }

  componentDidMount() {
    document.title = "Select dealer";

    fetchBackend(
      '/agent/account/dealerlist',
      {},
      this.onDealerListSuccessHandler,
      this.onFetchErrorHandler
    );
  }

  onDealerListSuccessHandler = json => {
    const options = json.data.map((item) => {
      return {
        key: item.key,
        value: item.key,
        text: item.value
      };
    });
    this.setState({
      loading: false,
      options,
      dealer: json.default
    });
  }

  onFetchErrorHandler = err => {
    if (err instanceof ClientError) {
      err.response.json().then(data => {
        this.setState({
          error: data.error,
          loading: false
        });
      });
    } else {
      console.log('set error to', err.message);
      this.setState({
        error: err.message,
        loading: false
      });
    }
  }

  onSubmitHandler = () => {
    const { dealer } = this.state;
    this.setState({
      loading: true,
      error: null
    });

    fetchBackend(
      '/agent/account/dealerPost',
      {
        dealer
      },
      this.onDealerSelectSuccessHandler,
      this.onFetchErrorHandler
    );
  }

  onDealerSelectSuccessHandler = json => {
    if (!json.error) {
      window.location = json.redirect; // Refresh to return back to MVC
    }
    this.setState({
      error: json.error
    });
  }

  onChangeHandler = (event, key) => {
    this.setState({
      dealer: event.target.value
    });
  }

  render() {
    const { loading, error, dealer, options } = this.state;
    const errorBanner = error ? <Banner type='error' text={error} /> : null;

    return (
      <div className='dealerselection'>
        <Form>
          <Header as="h1">Dealer selection</Header>
          { errorBanner }
          <Select
            label='My dealer'
            fluid
            defaultValue={dealer}
            onChange={this.onChangeHandler}
            maxHeight={5}
            options={options}
          />
          <Button
            primary
            fluid
            onClick={this.onSubmitHandler}
            loading={loading}
          >Select dealer</Button>
        </Form>
      </div>
    );
  }
}

export default DealerSelection;

Upvotes: 3

Views: 4716

Answers (2)

Brian Adams
Brian Adams

Reputation: 45780

Interesting, this one took a little while to chase down.


Relevant parts from the Node.js doc on Event Loop, Timers, and process.nextTick():

process.nextTick() is not technically part of the event loop. Instead, the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of the event loop.

...any time you call process.nextTick() in a given phase, all callbacks passed to process.nextTick() will be resolved before the event loop continues.

In other words, Node starts processing the nextTickQueue once the current operation is completed, and it will continue until the queue is empty before continuing with the event loop.

This means that if process.nextTick() is called while the nextTickQueue is processing, the callback is added to the queue and it will be processed before the event loop continues.

The doc warns:

This can create some bad situations because it allows you to "starve" your I/O by making recursive process.nextTick() calls, which prevents the event loop from reaching the poll phase.

...and as it turns out you can starve your Promise callbacks as well:

test('Promise and process.nextTick order', done => {
  const order = [];
  
  Promise.resolve().then(() => { order.push('2') });
  
  process.nextTick(() => {
    Promise.resolve().then(() => { order.push('7') });
    order.push('3');  // this runs while processing the nextTickQueue...
    process.nextTick(() => {
      order.push('4');  // ...so all of these...
      process.nextTick(() => {
        order.push('5');  // ...get processed...
        process.nextTick(() => {
          order.push('6');  // ...before the event loop continues...
        });
      });
    });
  });

  order.push('1');

  setTimeout(() => {
    expect(order).toEqual(['1','2','3','4','5','6','7']);  // ...and 7 gets added last
    done();
  }, 0);
});

So in this case the nested process.nextTick() callback that logs element.state() ends up running before the Promise callbacks that would set state.error to 'my error'.


It is because of this that the doc recommends the following:

We recommend developers use setImmediate() in all cases because it's easier to reason about


If you change your process.nextTick calls to setImmediate (and create your fetch mocks as functions so Promise.reject() doesn't run immediately and cause an error) then your test should work as expected:

it('throws error message when dealer submit fails', done => {
  const mockComponentDidMount = () => Promise.resolve(
    new Response(JSON.stringify({"data":[{"key":22,"value":"Stationstraat 5"}],"default":22}), {
      status: 200,
      headers: { 'content-type': 'application/json' }
    })
  );
  const mockButtonClickFetchError = () => Promise.reject(new Error('my error'));

  jest.spyOn(global, 'fetch').mockImplementation(mockComponentDidMount);
  const element = mount(<DealerSelection />);

  setImmediate(() => {
    jest.spyOn(global, 'fetch').mockImplementation(mockButtonClickFetchError);
    const button = element.find('button');
    button.simulate('click');
    setImmediate(() => {
      console.log(element.state()); // state.error is 'my error'
      global.fetch.mockClear();
      done();
    });
  });
});

Upvotes: 2

user3099140
user3099140

Reputation:

There are several asynchronous calls required to update the state, so your process.nextTick() isn't sufficient. To update the state, this needs to happen:

  • your test code clicks, and the event handler callback is queued
  • the event handler callback runs, runs fetch, gets a promise rejection, and runs the error handler
  • the error handler runs setState, which queues the state update (setState is asynchronous!)
  • your test code runs, checking the element's state
  • the state update runs

In short, you need to wait longer before asserting on the state.

A useful idiom to "wait" without nested process.nextTick() calls is to define a test helper

function wait() {
    return new Promise((resolve) => setTimeout(resolve));
}

and then do

await wait();

as many times as required in your test code. Note that this requires you to define test functions as

test(async () => {

})

rather than

test(done => {

})

Upvotes: 0

Related Questions