Shane
Shane

Reputation: 1075

Testing React Components with asynchronous methods

I have a component which behaves like the following.

  1. Render, showing the 'Loading'.
  2. Fetch some data.
  3. Once that's loaded, populate the state.
  4. Rerender, showing that the data loaded.

The code is like this:

import React from 'react';

class IpAddress extends React.Component {
  state = {
    ipAddress: null
  };

  constructor(props) {
    super(props);

    this.fetchData();
  }

  fetchData() {
    return fetch(`https://jsonip.com`)
      .then((response) => response.json())
      .then((json) => {
        this.setState({ ipAddress: json.ip });
      });
  }

  render() {
    if (!this.state.ipAddress) return <p class="Loading">Loading...</p>;

    return <p>Pretty fly IP address you have there.</p>
  }
}

export default IpAddress;

This works fine. The Jest test is a struggle though. Using jest-fetch-mock works well.

import React from 'react';
import ReactDOM from 'react-dom';
import { mount } from 'enzyme';

import IpAddress from './IpAddress';

it ('updates the text when an ip address has loaded', async () => {
  fetch.mockResponse('{ "ip": "some-ip" }');

  const address = mount(<IpAddress />);
  await address.instance().fetchData();

  address.update();

  expect(address.text()).toEqual("Pretty fly IP address you have there.");
});

It's a bit sad that I have to call await address.instance().fetchData(), just to make sure that the update has happened. Without this, the promise from fetch or the async nature of setState (I'm not sure which) don't run until after my expect; the text is left as "Loading".

Is this the sane way to test code like this? Would you write this code completely differently?

My problem has since escalated. I'm using a high order component, which means I can no longer do .instance() and use the methods on it - I'm not sure how to get back to my unwrapped IpAddress. Using IpAddress.wrappedComponent doesn't give me back the original IpAddress, like I expected.

This fails with the following error message, which I unfortunately don't understand.

Invariant Violation: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.

Check the render method of `WrapperComponent`.

Upvotes: 0

Views: 1759

Answers (2)

Paul Mucur
Paul Mucur

Reputation: 208

You could use react-testing-library's waitForElement to avoid having to explicitly await on your fetch call and simplify things a little:

import React from "react";
import IpAddress from "./IpAddress";
import { render, cleanup, waitForElement } from "react-testing-library";

// So we can use `toHaveTextContent` in our expectations.
import "jest-dom/extend-expect";

describe("IpAddress", () => {
  beforeEach(() => {
    fetch.resetMocks();
  });

  afterEach(cleanup);

  it("updates the text when an IP address has loaded", async () => {
    fetch.mockResponseOnce(JSON.stringify({ ip: "1.2.3.4" }));

    const { getByTestId } = render(<IpAddress />);

    // If you add data-testid="ip" to your <p> in the component.
    const ipNode = await waitForElement(() => getByTestId("ip"));

    expect(ipNode).toHaveTextContent("Pretty fly IP address you have there.");
  });
});

This will automatically wait for your element to appear and fail if it doesn't appear by some timeout. You still have to await but hopefully this is a little closer to what you originally wanted.

Upvotes: 1

40thieves
40thieves

Reputation: 11

I must admit haven't used really jest-fetch-mock before, but from the docs and my little experiments, it looks like it replaces the global fetch with a mocked version. Notice how in this example isn't awaiting any promises: https://github.com/jefflau/jest-fetch-mock#simple-mock-and-assert. It's merely checking that fetch was called with the right arguments. So therefore I think you can remove the async/await and assert there is a call to jsonip.com.

What I think is tripping you up is actually the React lifecycle. Essentially it boils down to where you put the fetch call. The React team discourages you from putting "side-effects" (like fetch) in the constructor. Here's the official React docs description: https://reactjs.org/docs/react-component.html#constructor. Unfortunately I've not really found good documentation on why. I believe it's because React may call the constructor at odd times during the lifecycle. I think it is also the reason that you're having to manually call the fetchData function in your test.

The best practice for putting side-effects is in componentDidMount. Here's an ok explaination of why: https://daveceddia.com/where-fetch-data-componentwillmount-vs-componentdidmount/ (although it's worth noting that componentWillMount is now deprecated in React 16.2). componentDidMount is called exactly once, only after the component is rendered into the DOM.

It's also worth noting that this is all going to change soon with upcoming versions of React. This blog post/conference video goes into a lot more details: https://reactjs.org/blog/2018/03/01/sneak-peek-beyond-react-16.html

This approach means it will render initially in the loading state, but once the request has resolved you can trigger a re-render by setting state. Because you're using mount from Enzyme in your test, this will call all of the necessary lifecycle methods, including componentDidMount and so you should see the mocked fetch being called.

As for the higher order component, there's a trick that I sometimes use that is maybe not the best practice, but I think is a pretty useful hack. ES6 modules have a single default export, as well as as many "regular" exports as you like. I leverage this to export the component multiple times.

The React convention is to use the default export when importing components (i.e. import MyComponent from './my-component'). This means you can still export other things from the file.

My trick is to export default the HOC-wrapped component, so that you can use it in your source files as you would with any other component, but also export the unwrapped component as a "regular" component. That would look something like:

export class MyComponent extends React.Component {
  ...
}

export default myHOCFunction()(MyComponent)

Then you can import the wrapped component with:

import MyComponent from './my-component'

And the unwrapped component (i.e. for use in tests) with:

import { MyComponent } from './my-component'

It's not the most explicit pattern in the world, but it is quite ergonomic imo. If you wanted explicitness, you could do something like:

export const WrappedMyComponent = myHOCFunction()(MyComponent)
export const UnwrappedMyComponent = MyComponent

Upvotes: 1

Related Questions