使用Hooks获取数据的测试React组件

32

我的React应用程序有一个组件,它从远程服务器获取要显示的数据。在没有Hooks的时代,componentDidMount()是去的地方。但现在我想使用Hooks来做这件事。

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

我的使用Jest和Enzyme进行测试的代码如下:

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);
  });  
});

测试成功,但记录了一些警告:

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

应用程序组件的唯一更新是从Promise回调中发生的。我如何确保这在act块内发生?文档明确建议在act块之外进行断言。此外,将它们放在里面不会改变警告。


1
如果fetchData返回不同的数据,此代码将调用fetchData两次或进入无限循环。您应该将[]作为第二个参数传递给useEffect以模拟componentDidMount。否则,useEffect将在每次渲染时被调用。第一个fetchData会导致重新渲染。并且每当setState获得新值时,它都会导致额外的渲染。 - UjinT34
1
似乎此类情况正在讨论中:https://github.com/kentcdodds/react-testing-library/issues/281#issuecomment-461221880 - skyboyer
我不确定,但是https://github.com/threepointone/react-act-examples看起来很有前途。 - skyboyer
感谢@UjinT34的评论。实际上,我已经将[]作为依赖项,但是发现它与这个特定的问题无关。确实,它应该在那里以防止过于频繁地调用fetchData。不过,有关不使用act的警告仍然存在:( - mthmulders
感谢您的建议@skyboyer。您提到的存储库使用“手动”模拟,可以在act语句内手动解决。我更喜欢使用Jest的模拟功能。我的感觉是从Jest模拟中解析Promise发生在act之外,因此会触发警告。但我不知道如何解决这个问题。 - mthmulders
5个回答

24

这个问题是由 Component 内部的多个更新引起的。

我遇到了相同的问题,这将解决该问题。

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

10
警告:不要等待调用TestRenderer.act(...)的结果,它不是一个Promise。 - Alex R

6
我已经创建了用于测试异步钩子的示例。 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;
  }
}

这个实现似乎是在测试按钮是否有一个函数,并且在调用模拟的getData之后,UI是否成功地发生了变化;但是它是如何检查点击按钮是否会调用getData的呢?难道我们不应该完全模仿用户事件吗? - Logan Cundiff

4

2
请注意,最后一半提到的PR描述了act的异步版本,您必须采用它才能使消息“消失”。普通的jest也无法解决这个问题。 - Eric Haynes
@erich2k8 你是对的。无论如何,当我升级到React 16.9.0时,警告消失了。 - Neets

2
我曾经遇到过同样的问题,最终编写了一个库来模拟所有标准的React Hooks以解决此问题。
基本上,`act()`是一个同步函数,就像`useEffect`一样,但是`useEffect`执行的是异步函数。 `act()`无法“等待”其执行。只能快速执行并忘记它!
文章在这里:https://medium.com/@jantoine/another-take-on-testing-custom-react-hooks-4461458935d4 库在这里:https://github.com/antoinejaussoin/jooks 要测试您的代码,首先需要将您的逻辑(例如fetch等)提取到一个单独的自定义Hook中,例如:
const useFetchData = () => {
  const [ state, setState ] = useState(0);
  useEffect(() => {     
    fetchData().then(setState);
  });
  return state;
}

然后,使用Jooks,你的测试将如下所示:

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');
  });
});


-2

我通过以下步骤解决了这个问题

  1. 将react和react-dom更新到16.9.0版本。
  2. 安装regenerator-runtime。
  3. 在setup文件中导入regenerator-runtime。

    import "regenerator-runtime/runtime";
    import { configure } from "enzyme";
    import Adapter from "enzyme-adapter-react-16";
    
    configure({
     adapter: new Adapter()
    });
    
  4. 使用act包裹mount和其他可能会导致状态改变的操作。从simple react-dom/test-utils导入act,像下面这样使用async & await。

    import React from 'react';
    import { mount } from 'enzyme';
    import App from './App';
    import { act } from "react-dom/test-utils";
    
    jest.mock('./api');
    
    import { fetchData } from './api';
    
    describe('<App />', () => {
    it('renders without crashing',  async (done) => {
      fetchData.mockImplementation(() => {
        return Promise.resolve(42);
      });
      await act(() => mount(<App />));
      setTimeout(() => {
        // expectations here
        done();
      }, 500);
     });  
    });
    
希望这能有所帮助。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接