Testing React components that fetches data using Hooks

39,124

Solution 1

That issue is caused by many updates inside Component.

I got the same issue, this would solve the issue.

await act( async () => mount(<App />));

Solution 2

I have created examples for testing async hooks.

https://github.com/oshri6688/react-async-hooks-testing

CommentWithHooks.js:

import { getData } from "services/dataService";

const CommentWithHooks = () => {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  const fetchData = () => {
    setIsLoading(true);

    getData()
      .then(data => {
        setData(data);
      })
      .catch(err => {
        setData("No Data");
      })
      .finally(() => {
        setIsLoading(false);
      });
  };

  useEffect(() => {
    fetchData();
  }, []);

  return (
    <div>
      {isLoading ? (
        <span data-test-id="loading">Loading...</span>
      ) : (
        <span data-test-id="data">{data}</span>
      )}

      <button
        style={{ marginLeft: "20px" }}
        data-test-id="btn-refetch"
        onClick={fetchData}
      >
        refetch data
      </button>
    </div>
  );
};

CommentWithHooks.test.js:

import React from "react";
import { mount } from "enzyme";
import { act } from "react-dom/test-utils";
import MockPromise from "testUtils/MockPromise";
import CommentWithHooks from "./CommentWithHooks";
import { getData } from "services/dataService";

jest.mock("services/dataService", () => ({
  getData: jest.fn(),
}));

let getDataPromise;

getData.mockImplementation(() => {
  getDataPromise = new MockPromise();

  return getDataPromise;
});

describe("CommentWithHooks", () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it("when fetching data successed", async () => {
    const wrapper = mount(<CommentWithHooks />);
    const button = wrapper.find('[data-test-id="btn-refetch"]');
    let loadingNode = wrapper.find('[data-test-id="loading"]');
    let dataNode = wrapper.find('[data-test-id="data"]');

    const data = "test Data";

    expect(loadingNode).toHaveLength(1);
    expect(loadingNode.text()).toBe("Loading...");

    expect(dataNode).toHaveLength(0);

    expect(button).toHaveLength(1);
    expect(button.prop("onClick")).toBeInstanceOf(Function);

    await getDataPromise.resolve(data);

    wrapper.update();

    loadingNode = wrapper.find('[data-test-id="loading"]');
    dataNode = wrapper.find('[data-test-id="data"]');

    expect(loadingNode).toHaveLength(0);

    expect(dataNode).toHaveLength(1);
    expect(dataNode.text()).toBe(data);
  });

testUtils/MockPromise.js:

import { act } from "react-dom/test-utils";

const createMockCallback = callback => (...args) => {
  let result;

  if (!callback) {
    return;
  }

  act(() => {
    result = callback(...args);
  });

  return result;
};

export default class MockPromise {
  constructor() {
    this.promise = new Promise((resolve, reject) => {
      this.promiseResolve = resolve;
      this.promiseReject = reject;
    });
  }

  resolve(...args) {
    this.promiseResolve(...args);

    return this;
  }

  reject(...args) {
    this.promiseReject(...args);

    return this;
  }

  then(...callbacks) {
    const mockCallbacks = callbacks.map(callback =>
      createMockCallback(callback)
    );

    this.promise = this.promise.then(...mockCallbacks);

    return this;
  }

  catch(callback) {
    const mockCallback = createMockCallback(callback);

    this.promise = this.promise.catch(mockCallback);

    return this;
  }

  finally(callback) {
    const mockCallback = createMockCallback(callback);

    this.promise = this.promise.finally(mockCallback);

    return this;
  }
}

Solution 3

Enzyme doesn't have support for hooks since it's a relatively new feature: https://github.com/airbnb/enzyme/issues/2011

Maybe you can use plain Jest in the meantime? Also don't worry about the warning, it's supposed to go away when React 16.9.0 is released (see this pull request https://github.com/facebook/react/pull/14853)

Solution 4

I had that exact same problem, and ended up writing a library that solves this issue by mocking all the standards React Hooks.

Basically, act() is a synchronous function, like useEffect, but useEffect executes an async function. There's no way that act() would be able to "wait" for that to execute. Fire and forget!

Article here: https://medium.com/@jantoine/another-take-on-testing-custom-react-hooks-4461458935d4

Library here: https://github.com/antoinejaussoin/jooks

To test your code, you would first need to extract your logic (the fetch, etc.) into a separate custom hook: something like:

const useFetchData = () => {
  const [ state, setState ] = useState(0);
  useEffect(() => {     
    fetchData().then(setState);
  });
  return state;
}

Then, using Jooks, your test would look like:

import init from 'jooks';
[...]
describe('Testing my hook', () => {
  const jooks = init(() => useFetchData());

  // Mock your API call here, by returning 'some mocked value';

  it('Should first return 0', () => {
    const data = jooks.run();
    expect(data).toBe(0);
  });

  it('Then should fetch the data and return it', async () => {
    await jooks.mount(); // Fire useEffect etc.
    const data = jooks.run();
    expect(data).toBe('some mocked value');
  });
});

Share:
39,124

Related videos on Youtube

mthmulders
Author by

mthmulders

Enthusiastic software developer with a passion for elegant solutions. Eager to learn new things, willing to help others do the same.

Updated on July 09, 2022

Comments

  • mthmulders
    mthmulders almost 2 years

    My React-application has a component that fetches data to display from a remote server. In the pre-hooks era, componentDidMount() was the place to go. But now I wanted to use hooks for this.

    const App = () => {
      const [ state, setState ] = useState(0);
      useEffect(() => {
        fetchData().then(setState);
      });
      return (
        <div>... data display ...</div>
      );
    };
    

    And my test using Jest and Enzyme looks like this:

    import React from 'react';
    import { mount } from 'enzyme';
    import App from './App';
    import { act } from 'react-test-renderer';
    
    jest.mock('./api');
    
    import { fetchData } from './api';
    
    describe('<App />', () => {
      it('renders without crashing', (done) => {
        fetchData.mockImplementation(() => {
          return Promise.resolve(42);
        });
        act(() => mount(<App />));
        setTimeout(() => {
          // expectations here
          done();
        }, 500);
      });  
    });
    

    The test succeeds, but it logs a few warnings:

    console.error node_modules/react-dom/cjs/react-dom.development.js:506
        Warning: An update to App inside a test was not wrapped in act(...).
    
        When testing, code that causes React state updates should be wrapped into act(...):
    
        act(() => {
        /* fire events that update state */
        });
        /* assert on the output */
    
        This ensures that you're testing the behavior the user would see in the browser. Learn more at (redacted)
            in App (created by WrapperComponent)
            in WrapperComponent
    

    The only update to the App component happens from the Promise callback. How can I ensure this happens within the act block? The docs clearly suggest to have assertions happen outside the act block. Besides, putting them inside doesn't change the warning.

    • UjinT34
      UjinT34 over 5 years
      This code will call fetchData twice or go to an infinite loop if fetchData returns different data. You should pass [] as second argument to useEffect to emaulate componentDidMount. Otherwise useEffect will be called on every render. First fetchData causes a rerender. And it will cause additional renders whenever setState gets a new value.
    • skyboyer
      skyboyer over 5 years
      github.com/kentcdodds/react-testing-library/issues/… seems like such cases are under discussion
    • skyboyer
      skyboyer over 5 years
      I'm not sure but github.com/threepointone/react-act-examples looks promising
    • mthmulders
      mthmulders over 5 years
      Thanks @UjinT34 for the remark. In fact, I had [] as deps, but figured it wasn't relevant for this particular question. Indeed, it should be there to prevent invoking fetchData too often. Still, the warning about not using act is there :(
    • mthmulders
      mthmulders over 5 years
      Thanks for the suggestion @skyboyer. The point with the repo you refer to is that they use "manual" mocks, which they can manually resolve - inside an act statement. I prefer to use Jests mocking possibilities instead. My feeling is that resolving the Promise from the Jest mock happens outside act, thus triggering the warning. But I don't know how to fix that.
  • Eric Haynes
    Eric Haynes over 4 years
    Note that the PR mentioned in the last half describes the async version of act, which you'll have to adopt for the message to "go away". Plain jest wouldn't address it either.
  • Neets
    Neets over 4 years
    @erich2k8 you're right. In any case, when I upgraded to React 16.9.0 the warning disappeared.
  • Alex R
    Alex R about 3 years
    I get Warning: Do not await the result of calling TestRenderer.act(...), it is not a Promise.
  • Logan Cundiff
    Logan Cundiff about 2 years
    This implementation appears to test if the button has a function and that after the mocked getData is called, the UI changes successfully; but how is it checking that clicking the button will call getData? Shouldn't we mimic the user event exactly as it is?