Antonis Christofides
Antonis Christofides

Reputation: 6949

How to unit test a React component that renders after fetch has finished?

I'm a Jest/React beginner. In jest's it I need to wait until all promises have executed before actually checking.

My code is similar to this:

export class MyComponent extends Component {
    constructor(props) {
        super(props);
        this.state = { /* Some state */ };
    }

    componentDidMount() {
        fetch(some_url)
            .then(response => response.json())
            .then(json => this.setState(some_state);
    }

    render() {
        // Do some rendering based on the state
    }
}

When the component is mounted, render() runs twice: once after the constructor runs, and once after fetch() (in componentDidMount()) finishes and the chained promises finish executing).

My testing code is similar to this:

describe('MyComponent', () => {

    fetchMock.get('*', some_response);

    it('renders something', () => {
        let wrapper = mount(<MyComponent />);
        expect(wrapper.find(...)).to.have.something();
    };
}

Whatever I return from it, it runs after the first time render() executes but before the second time. If, for example, I return fetchMock.flush().then(() => expect(...)), the returned promise executes before the second call to render() (I believe I can understand why).

How can I wait until the second time render() is called before running expect()?

Upvotes: 9

Views: 17119

Answers (3)

Malvineous
Malvineous

Reputation: 27340

I've had some success with this, as it doesn't require wrapping or modifying components. It is however assuming there's only one fetch() in the component, but it can be easily modified if needed.

// testhelper.js

class testhelper
{
    static async waitUntil(fnWait) {
        return new Promise((resolve, reject) => {
            let count = 0;
            function check() {
                if (++count > 20) {
                    reject(new TypeError('Timeout waiting for fetch call to begin'));
                    return;
                }
                if (fnWait()) resolve();
                setTimeout(check, 10);
            }
            check();
        });
    }

    static async waitForFetch(fetchMock)
    {
        // Wait until at least one fetch() call has started.
        await this.waitUntil(() => fetchMock.called());

        // Wait until active fetch calls have completed.
        await fetchMock.flush();
    }
}

export default testhelper;

Then you can use it just before your assertions:

import testhelper from './testhelper.js';

it('example', async () => {
    const wrapper = mount(<MyComponent/>);

    // Wait until all fetch() calls have completed
    await testhelper.waitForFetch(fetchMock);

    expect(wrapper.html()).toMatchSnapshot();
});

Upvotes: 1

Antonis Christofides
Antonis Christofides

Reputation: 6949

I found a way to do what I originally asked. I have no opinion (yet) whether it is good strategy or not (in fact I had to refactor the component immediately afterwards, so this question is no longer relevant to what I'm doing). Anyway, here is the testing code (explanation below):

import React from 'react';
import { mount } from 'enzyme';
import { MyComponent } from 'wherever';
import fetchMock from 'fetch-mock';

let _resolveHoldingPromise = false;

class WrappedMyComponent extends MyComponent {

    render() {
        const result = super.render();
        _resolveHoldingPromise && _resolveHoldingPromise();
        _resolveHoldingPromise = false;
        return result;
    }

    static waitUntilRender() {
        // Create a promise that can be manually resolved
        let _holdingPromise = new Promise(resolve =>
            _resolveHoldingPromise = resolve);

        // Return a promise that will resolve when the component renders
        return Promise.all([_holdingPromise]);
    }
}

describe('MyComponent', () => {

    fetchMock.get('*', 'some_response');

    const onError = () => { throw 'Internal test error'; };

    it('renders MyComponent appropriately', done => {
        let component = <WrappedMyComponent />;
        let wrapper = mount(component);
        WrappedMyComponent.waitUntilRender().then(
            () => {
                expect(wrapper.find('whatever')).toBe('whatever');
                done();
            },
            onError);
    });
});

The main idea is that, in the testing code, I subclass the component (if this was Python I'd probably monkey-patch it, which works more or less the same way in this case) so that its render() method sends a signal that it executed. The way to send the signal is by manually resolving a promise. When a promise is created, it creates two functions, resolve and reject, which when called terminate the promise. The way to have code outside the promise resolve the promise is by having the promise store a reference to its resolve function in an external variable.

Thanks to fetch-mock author Rhys Evans who kindly explained the manually-resolve-promise trick to me.

Upvotes: 0

Crysfel
Crysfel

Reputation: 8158

I'd separate concerns, mainly because is easier to maintain and to test. Instead of declaring the fetch inside the component I'd do it somewhere else, for example in a redux action (if using redux).

Then test individually the fetch and the component, after all this is unit testing.

For async tests you can use the done parameter on the test. For example:

describe('Some tests', () => {
  fetchMock.get('*', some_response);

  it('should fetch data', (done) => { // <---- Param
    fetchSomething({ some: 'Params' })
      .then(result => {
        expect(result).toBe({ whatever: 'here' });
        done(); // <--- When you are done
      });
  });
})

The you can tests your component by just sending the loaded data in the props.

describe('MyComponent', () => {

  it('renders something', () => {
    const mockResponse = { some: 'data' };
    let wrapper = mount(<MyComponent data={mockResponse}/>);

    expect(wrapper.find(...)).to.have.something();
  });
});

When it comes to testing you need to keep it simple, if your component is difficult to test, then there's something wrong with your design ;)

Upvotes: 3

Related Questions