1

I am trying to test AsyncTypeahead from react-bootstrap-typeahead.

I have a very simple test component :

class AsyncTypeahead2 extends Component<Props, State> {

    constructor(props: Props) {
        super(props);
        this.state = {
            isLoading: false,
        };
    }
    render() {
        return ( <AsyncTypeahead
            isLoading={this.state.isLoading}
            onSearch={query => {
                this.setState({isLoading: true});
                fetch("http://www.myHTTPenpoint.com")
                    .then(resp => resp.json())
                    .then(json => this.setState({
                        isLoading: false,
                        options: json.items,
                    }));
            }}
            options={this.state.options}
            labelKey={option => `${option.stateName}`}
        /> )
    }
}

const url = "http://www.myHTTPenpoint.com"
fetchMock
    .reset()
    .get(
        url,
        {
            items: [
                {id:1, stateName:"Alaska"},
                {id:2, stateName:"Alabama"}
            ]
        },
    );

(Note that the URL is mocked to return two elements)

When I run this in my storybook it looks fine :

enter image description here

But if I want to test it (with Enzyme) it does not recognise the < li > items that pop up.

    let Compoment =
        <div>Basic AsyncTypeahead Example
            <AsyncTypeahead2/>
        </div>

    const wrapper = mount(Compoment);
    let json = wrapper.html();


    let sel = wrapper.find(".rbt-input-main").at(0)

    sel.simulate('click');
    sel.simulate('change', { target: { value: "al" } });

    expect(wrapper.find(".rbt-input-main").at(0).getElement().props.value).toBe("al")

    expect(wrapper.find(".dropdown-item").length).toBe(2) //but get just 1 element "Type to Search..."

Instead of finding two "dropdown-item" items there is just one item with the text "Type to Search...".

Is the AynchTypeahead not updating the DOM correctly with respect to Enzyme?

Oliver Watkins
  • 12,575
  • 33
  • 119
  • 225

2 Answers2

1

<AsyncTypeahead> is asynchronous. On the other hand simulate() is synchronous. So at the time you get to expect() AsyncTypeahead not even started to populate the dropdown with <li> elements. You need to wait for it.

It's not specified, but it looks like you are using fetch-mock package. There is the flush function which

Returns a Promise that resolves once all fetches handled by fetch-mock have resolved

So this:

...

sel.simulate('click');
sel.simulate('change', { target: { value: "al" } });

await fetchMock.flush() // !!!

expect(wrapper.find(".rbt-input-main").at(0).getElement().props.value).toBe("al")
expect(wrapper.find(".dropdown-item").length).toBe(2)

should work.

...But probably it won't. Because

fetchMock.mock(...)
fetch(...)
await fetchMock.flush()

does work, but

fetchMock.mock(...)
setTimeout(() => fetch(...), 0)
await fetchMock.flush()

does not. await fetchMock.flush() returns right away if there was no call of fetch. And probably there won't be. Because <AsyncTypeahead> debounces.

(By the way, you can also try to mock fetch on a per-test basis. Just in case.)

So I see two options:

  1. Use something else instead of fetch-mock package. Where you can resolve your own Promises on mocked requests completion.
  2. https://tech.travelaudience.com/how-to-test-asynchronous-data-fetching-on-a-react-component-ff2ee7433d71
    import waitUntil from 'async-wait-until';
    ...
    test("test name", async () => {
        let Compoment = <AsyncTypeahead2/>
        ...
        await waitUntil(() => wrapper.state().isLoading === false);
        // or even
        // await waitUntil(() => wrapper.find(".dropdown-item").length === 2, timeout);
        expect(...)
    })
    
    This options if not pretty. But maybe it's your only option - there is not only the fetch-mock you should worry about. setState also asynchronous... and it looks like there is no pretty way to check when it's done updating the state and the DOM without changing the real code (which is quite undesirable).
x00
  • 13,643
  • 3
  • 16
  • 40
  • yes you are right. I am using fetch-mock. I did try how you suggested but I am still not having any luck... trying a few other things but the async keyword seems to be the key – Oliver Watkins Mar 02 '20 at 14:43
  • I think I need to do some kind of await on the fetch defined in the onSearch callback – Oliver Watkins Mar 02 '20 at 14:52
  • Yes. But the sad thing is that this would require changing your real code for the sake of tests. – x00 Mar 02 '20 at 15:00
  • yeah... i was thinking the same. :( – Oliver Watkins Mar 02 '20 at 15:08
  • Maybe another mock lib? https://github.com/jefflau/jest-fetch-mock#functions It looks like with it you can defined your own promises to wait for. – x00 Mar 02 '20 at 15:30
  • Played a little with `fetch-mock` and `AsyncTypeahead`. Updated the answer. There is a change in the `flush` call. Initially I was wrong: `await promise` must be changed to `await fetchMock.flush()` but... read on :) – x00 Mar 02 '20 at 23:02
  • alright thanks very much for your help :) I'll leave the bounty up until the end incase someone else comes up with an answer. I'll let you know if i find something – Oliver Watkins Mar 03 '20 at 09:11
  • thanks for your help :) I managed to fix this but you got me on the right track. I will post my answer – Oliver Watkins Mar 03 '20 at 14:01
1

The exact solution to my problem is in the following code (copy and paste into a JS file to see it work).

Things to note :

  • I needed to use the waitUntil function from the async-wait-until library. fetch-mock on its own does not provide the functionality to test async code.
  • I needed to add an ugly hack at global.document.createRange because of some tooltip issue with react-bootstrap-typeahead and jest.
  • use waitUntil to wait on changes on the internal state of the component
  • It is very important to call wrapper.update() to update the DOM afterwards.

..

import React, {Component} from 'react';

import waitUntil from 'async-wait-until';

import {mount} from "enzyme";
import fetchMock from "fetch-mock";
import {AsyncTypeahead} from "react-bootstrap-typeahead";


describe('Autocomplete Tests ', () => {

    test(' Asynch AutocompleteInput  ', async () => {

        class AsyncTypeaheadExample extends Component<Props, State> {

            constructor(props: Props) {
                super(props);
                this.state = {
                    isLoading: false,
                    finished: false
                };
            }

            render() {
                return (<AsyncTypeahead
                    isLoading={this.state.isLoading}
                    onSearch={query => {
                        this.setState({isLoading: true});
                        fetch("http://www.myHTTPenpoint.com")
                            .then(resp => resp.json())
                            .then(json => this.setState({
                                isLoading: false,
                                options: json.items,
                                finished: true
                            }));
                    }}
                    options={this.state.options}
                    labelKey={option => `${option.stateName}`}
                />)
            }
        }

        const url = "http://www.myHTTPenpoint.com"
        fetchMock
            .reset()
            .get(
                url,
                {
                    items: [
                        {id: 1, stateName: "Alaska"},
                        {id: 2, stateName: "Alabama"}
                    ]
                },
            );

        let Compoment =
            <AsyncTypeaheadExample/>


        // ugly hacky patch to fix some tooltip bug
        // https://github.com/mui-org/material-ui/issues/15726
        global.document.createRange = () => ({
            setStart: () => {
            },
            setEnd: () => {
            },
            commonAncestorContainer: {
                nodeName: 'BODY',
                ownerDocument: document,
            },
        });

        let wrapper = mount(Compoment);

        let sel = wrapper.find(".rbt-input-main").at(0)

        sel.simulate('click');
        sel.simulate('change', {target: {value: "al"}});
        expect(wrapper.find(".rbt-input-main").at(0).getElement().props.value).toBe("al")




        //now the async stuff is happening ...

        await waitUntil(() => {
            return wrapper.state().finished === true;
        }, 3000); //wait about 3 seconds

        wrapper.update() //need to update the DOM!

        expect(wrapper.find(".dropdown-item").length).toBe(2) //but get just 1 element "Type to Search..."
    })
});

UPDATE


I can also compare on wrapper items rather than doing a direct comparison on the state :

//now the async stuff is happening ...
await waitUntil(() => {
    wrapper.update() //need to update the DOM!

    return wrapper.find(".dropdown-item").length > 1
}, 3000); //wait about 3 seconds

This is probably better because it means i dont need to know about the component internals.

Oliver Watkins
  • 12,575
  • 33
  • 119
  • 225