【送红宝书】JavaScript 测试系列实战(四):掌握 React Hooks 测试技巧

时间:2022-07-24
本文章向大家介绍【送红宝书】JavaScript 测试系列实战(四):掌握 React Hooks 测试技巧,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

「为了回馈图雀社区的读者,图雀酱特地挑选了几本书籍送给大家,文末有送书活动详情哦~」

React Hooks 作为复用共同业务逻辑的强大工具,已经在开源库和业务代码中得到了广泛的使用。但是如果一个钩子没有完善的测试覆盖,我们就很难有信心去使用或者分享它。在这篇文章中,我们将体验强大的 react-hooks-testing-library,学习如何去测试钩子的同步和异步逻辑,并最终通过一个完整的例子去了解如何结合 Redux 框架进行测试。

开始使用 react-hooks-testing-library

在上一篇教程中,我们手工编写了非常原始的 React Hooks 测试代码。所幸,由于测试 React Hooks 的需求非常普遍,因此就有了测试 Hooks 的神器:react-hooks-testing-library。它提供了一系列专门用于测试 Hook 的工具函数,能够模拟在真实组件中使用 Hooks。

提示 如果你不熟悉 React Hooks 相关的知识,推荐先学习我们的 React Hooks 相关实战教程。

让我们先安装 react-hooks-testing-library:

npm install @testing-library/react-hooks

react-hooks-testing-library 中最重要的工具之一就是 renderHook 函数,它的工作方式与我们之前创建的 testHook 函数类似。它的参数是至少调用一个 Hook 的回调函数,返回值是一个对象,其中我们需要关心的是其中的 result 属性。result 属性又包含两个属性:

  • current:所测试 Hook 的返回值
  • error:所测试 Hook 抛出的错误(如果有的话)

让我们来结合实际的例子看一下。在之前 useModalManagement 钩子的测试代码中,我们仅仅只测试了调用 Hook 时不会报错。实际上,我们还希望测试以下用例:

  • 默认渲染一个关闭的模态框
  • 当调用 openModal 函数时,能够打开模态框

我们来看看新的测试代码:

// src/useModalManagement.test.js
import useModalManagement from './useModalManagement';
import { renderHook, act } from '@testing-library/react-hooks';

describe('The useModalManagement hook', () => {
  it('should not throw an error', () => {
    renderHook(() => useModalManagement());
  });

  it('should describe a closed modal by default', () => {
    const { result } = renderHook(() => useModalManagement());
    expect(result.current.isModalOpened).toBe(false);
  });

  describe('when the openModal function is called', () => {
    it('should describe an opened modal', () => {
      const { result } = renderHook(() => useModalManagement());
      act(() => {
        result.current.openModal();
      });
      expect(result.current.isModalOpened).toBe(true);
    });
  });
});

内容有点多,我们来逐个用例讲解:

  1. 测试 Hook 不会报错:我们将原来的 testHook 函数改成 react-hooks-testing-library 的 renderHook 函数,这个函数接受的参数是一个调用 Hook 的函数
  2. 测试模态框默认关闭:还是通过 renderHook 渲染 Hook,然后获取到之前提到的 result 对象,进一步通过 result.current.isModalOpened 来获取到模态框的状态,然后用断言语句测试这个状态是 false(关闭状态)
  3. 测试打开模态框:这个测试的难点在于怎么去触发 openModal ,所幸 react-hooks-testing-library 提供了 act 工具函数来模拟浏览器中 Hook 的工作方式;act 函数同样接受一个函数执行一系列同步操作

注意 如果不使用 act 函数,而是直接将操作写在用例中,Jest 会抛出警告,并且可能会遇到一些棘手的边界情况。

通过 npm test 运行测试,全部通过!由于我们丰富了测试用例,对 useModalManagement 钩子的信心也大增!

测试异步钩子

刚才的 useModalManagement 涉及到的都是同步操作,然而在实际应用中,很多钩子都涉及到异步操作,例如 API 数据获取等。那么我们该怎么测试这些异步钩子呢?

实际上,刚才我们用到了 renderHook 的一个重要返回对象 result ,它实际上还提供了 waitForNextUpdate 函数。这个函数调用后会返回 Promise,这个 Promise 在下次渲染 Hook 时进入 Resolve 状态,非常适合用来测试异步更新的逻辑。

提示 react-hooks-testing-library 还提供了一些工具函数用来辅助异步钩子的测试,可参考官方文档的 Async Utilities 部分。

编写一个异步钩子

首先,让我们来写一个简单的异步钩子 useCommentsManagement ,用于从一个公共 API 获取一些评论数据,代码如下:

// src/useCommentsManagement.js
import { useState } from 'react';

function useCommentsManagement() {
  const [comments, setComments] = useState([]);

  function fetchComments() {
    return fetch('https://jsonplaceholder.typicode.com/comments')
      .then((response) => response.json())
      .then((data) => {
        setComments(data);
      });
  }

  return {
    comments,
    fetchComments,
  };
}

export default useCommentsManagement;

编写测试代码

然后我们来编写 useCommentsManagement 的测试代码如下:

// src/useCommentsManagement.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import useCommentsManagement from './useCommentsManagement';

describe('The useCommentsManagement hook', () => {
  describe('when the fetchComments function is called', () => {
    it('should update the state after a successful request', async () => {
      const { result, waitForNextUpdate } = renderHook(() => useCommentsManagement());

      act(() => {
        result.current.fetchComments();
      });
      await waitForNextUpdate();

      return expect(result.current.comments.length).not.toBe(0);
    });
  });
});

act 函数中触发 fetchComments 拉取评论后,我们调用 waitForNextUpdate 并去 await 它返回的 Promise,当重渲染完成后,就可以使用调用断言语句来进行判断啦。这里我们还是通过 result.current 来获取评论数量。

注意 在编写 Jest 异步测试用例时,如果涉及到 Promise 的使用(包括 async/await ),要确保 return 一个值,否则测试会超时。详细介绍请参考 Jest 异步测试文档。

继续 npm test,一路绿灯!

提示 你也许还记得前面的课程中,我们讲到了如何用 Jest Mock 去避免发起真正的 HTTP 请求,从而能够保证测试不会因为网络问题而挂掉。至于怎么用 Mock 来写,就留给作业给你吧~

测试 Redux + Hooks

在规模较大的应用中,我们通常会使用一个状态管理库来解决复杂的数据流问题,而最受欢迎的选择无疑是 Redux。在这一节中,我们将手把手带你搭建一个完整的 Redux 模型,并且为之编写测试。

提示 这篇文章的重心不是 Redux,因此不会花太多的笔墨在这上面。如果不熟悉或者想复习一下的话,推荐阅读图雀社区的《Redux 包教包会》系列教程。

让我们先安装一下相关的依赖:

npm install redux react-redux

三件套:Action、Reducer 和 Store

之前的模态框钩子 useModalManagement 在内部维护了 isOpened 状态,这里我们将这个状态放到 Redux 中来进行管理。

首先定义相关的 Actions,创建 src/actions/modal.js ,代码如下:

// src/actions/modal.js
const OPEN_MODAL = 'OPEN_MODAL';
const CLOSE_MODAL = 'CLOSE_MODAL';

function openModal() {
  return {
    type: OPEN_MODAL,
  };
}

function closeModal() {
  return {
    type: CLOSE_MODAL,
  };
}

export { OPEN_MODAL, CLOSE_MODAL, openModal, closeModal };

然后是相关的 Reducer,代码如下:

// src/reducers/modal.js
import { OPEN_MODAL, CLOSE_MODAL } from '../actions/modal';

const initialState = {
  isOpened: false,
};

export default function modal(state = initialState, action) {
  if (action.type == OPEN_MODAL) {
    return { isOpened: true };
  } else if (action.type == CLOSE_MODAL) {
    return { isOpened: false };
  } else {
    return state;
  }
}

我们通过 combineReducers 将所有的 Reducer 结合成 rootReducer(虽然这里只有一个 Reducer,但是这里为了完整地演示):

// src/reducers/index.js
import { combineReducers } from 'redux';
import modal from './modal';

const rootReducer = combineReducers({ modal });

export default rootReducer;

最后则是 Store,代码如下:

// src/store.js
import { createStore } from 'redux';
import rootReducer from './reducers';

const store = createStore(rootReducer);

export default store;

用 Redux 重写 useModalManagement

由于接入了 Redux,我们对之前的 useCommentsManagement 要来进行一波大刀阔斧的修改。修改后的代码如下:

// src/useModalManagement.js
import { useDispatch, useSelector } from 'react-redux';
import * as modalActions from './actions/modal';

function useModalManagement() {
  const isModalOpened = useSelector((state) => state.modal.isOpened);
  const dispatch = useDispatch();

  function openModal() {
    dispatch(modalActions.openModal());
  }

  function closeModal() {
    dispatch(modalActions.closeModal());
  }

  return {
    isModalOpened,
    openModal,
    closeModal,
  };
}

export default useModalManagement;

这里我们使用 react-redux 提供的 useSelectoruseDispatch 钩子来分别获取状态和派发函数。

OK,让我们把测试跑起来……居然报错了,主要的提示信息如下:

Invariant Violation: could not find react-redux context value; please ensure the component is wrapped in a <Provider>

含义很明确,我们没有提供 Redux 上下文。如果你熟悉 Redux 的话,你应该记得 react-redux 提供了 Provider 组件来向所有子组件提供 Store 对象,但是在测试的时候,我们该怎么让 Provider 去包裹待测试的钩子呢?

通过 wrapper 来提供上下文

幸运的是,renderHook 支持传入第二个参数,用于调节钩子的渲染配置,其中一个我们需要的配置就是 wrapper 。这个 wrapper 专门用来提供 React Context,当然也适用于 Redux 的 Provider

修改 useCommentsManagement 的测试代码如下:

// src/useModalManagement.test.js
import React from 'react';
import useModalManagement from './useModalManagement';
import { renderHook, act } from '@testing-library/react-hooks';
import { Provider } from 'react-redux';
import store from './store';

describe('The useModalManagement hook', () => {
  // ...

  it('should describe a closed modal by default', () => {
    const { result } = renderHook(() => useModalManagement(), {
      wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
    });
    expect(result.current.isModalOpened).toBe(false);
  });

  describe('when the openModal function is called', () => {
    it('should describe an opened modal', () => {
      const { result } = renderHook(() => useModalManagement(), {
        wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
      });
      act(() => {
        result.current.openModal();
      });
      expect(result.current.isModalOpened).toBe(true);
    });
  });
});

再次运行测试,又全部通过了!

小结

在这篇文章中,我们体验了强大的 react-hooks-testing-library,先后测试了同步和异步的钩子,最后还结合 Redux 来测了一波。在下一篇教程中,我们终于要接触激动人心的端到端(E2E)测试了,敬请期待吧!