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

Posted

技术标签:

【中文标题】测试使用 Hooks 获取数据的 React 组件【英文标题】:Testing React components that fetches data using Hooks 【发布时间】:2019-07-29 13:27:16 【问题描述】:

我的 React 应用程序有一个从远程服务器获取数据以显示的组件。在 pre-hooks 时代,componentDidMount() 是个好去处。但现在我想为此使用钩子。

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

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

【问题讨论】:

此代码将调用fetchData 两次,如果fetchData 返回不同的数据则进入无限循环。您应该将[] 作为第二个参数传递给useEffect 以模拟componentDidMount。否则每次渲染都会调用useEffect。首先fetchData 导致重新渲染。每当setState 获得新值时,它都会导致额外的渲染。 github.com/kentcdodds/react-testing-library/issues/… 似乎正在讨论这种情况 我不确定,但github.com/threepointone/react-act-examples 看起来很有希望 感谢@UjinT34 的评论。事实上,我有 [] 作为部门,但认为它与这个特定问题无关。事实上,它应该在那里防止过于频繁地调用fetchData。尽管如此,关于不使用act 的警告仍然存在:( 感谢@skyboyer 的建议。您提到的回购的重点是他们使用“手动”模拟,他们可以手动解决 - 在 act 语句中。我更喜欢使用 Jests 模拟可能性。我的感觉是,从 Jest 模拟中解决 Promise 发生在 act 之外,从而触发了警告。但我不知道如何解决。 【参考方案1】:

我遇到了同样的问题,最后编写了一个库,通过模拟所有标准 React Hooks 来解决这个问题。

基本上,act() 是一个同步函数,就像useEffect,但useEffect 执行一个异步函数。 act() 不可能“等待”它执行。开火即忘!

文章在这里:https://medium.com/@jantoine/another-take-on-testing-custom-react-hooks-4461458935d4

这里的库:https://github.com/antoinejaussoin/jooks

要测试您的代码,您首先需要将您的逻辑(获取等)提取到一个单独的自定义挂钩中:类似于:

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】:

Enzyme 不支持挂钩,因为它是一个相对较新的功能: https://github.com/airbnb/enzyme/issues/2011

也许你可以同时使用简单的 Jest? 也不要担心警告,它应该会在 React 16.9.0 发布时消失(请参阅此拉取请求 https://github.com/facebook/react/pull/14853)

【讨论】:

请注意,后半部分提到的 PR 描述了 act 的异步版本,您必须采用它才能使消息“消失”。简单的玩笑也不会解决它。 @erich2k8 你是对的。无论如何,当我升级到 React 16.9.0 时,警告消失了。【参考方案3】:

我已经创建了测试异步钩子的示例。

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;
  

【讨论】:

【参考方案4】:

我使用以下步骤解决了这个问题

    将 react 和 react-dom 更新到 16.9.0 版本。 安装再生器运行时

    在安装文件中导入 regenerator-runtime。

    import "regenerator-runtime/runtime";
    import  configure  from "enzyme";
    import Adapter from "enzyme-adapter-react-16";
    
    configure(
     adapter: new Adapter()
    );
    

    包装挂载和其他可能导致动作内部状态变化的动作。从简单的 react-dom/test-utils、async 和 await 导入 act,如下所示。

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

希望这会有所帮助。

【讨论】:

【参考方案5】:

这个问题是由组件内部的许多更新引起的。

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

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

【讨论】:

我收到Warning: Do not await the result of calling TestRenderer.act(...), it is not a Promise.

以上是关于测试使用 Hooks 获取数据的 React 组件的主要内容,如果未能解决你的问题,请参考以下文章

React Hooks ,函数 fetchData 不是反应组件?

如何在 React hooks 和 redux 中只获取一次且不再获取数据

使用 React Hooks 获取帖子并将它们作为附加道具传递给另一个组件

React Hooks用法大全

使用 React Hooks 多次获取数据 axios

React Hooks/上下文和弹性 UI。函数组件中获取数据 (REST) 的问题