React Hook 测试

上一章讲了如何给 Redux 代码写测试,我们日常写的 React App 还有一个很重要的部分:React Hooks,这一章就来讲讲如何测试这部分的代码。

useCounter

我们依然从一个需求开始讲起。假如要实现一个 useCounter 的 Hook,在 src/hooks/useCounter.ts 添加:

  1. // src/hooks/useCounter.ts
  2. import { useState } from "react";
  3. export interface Options {
  4. min?: number;
  5. max?: number;
  6. }
  7. export type ValueParam = number | ((c: number) => number);
  8. function getTargetValue(val: number, options: Options = {}) {
  9. const { min, max } = options;
  10. let target = val;
  11. if (typeof max === "number") {
  12. target = Math.min(max, target);
  13. }
  14. if (typeof min === "number") {
  15. target = Math.max(min, target);
  16. }
  17. return target;
  18. }
  19. function useCounter(initialValue = 0, options: Options = {}) {
  20. const { min, max } = options;
  21. const [current, setCurrent] = useState(() => {
  22. return getTargetValue(initialValue, {
  23. min,
  24. max,
  25. });
  26. });
  27. const setValue = (value: ValueParam) => {
  28. setCurrent((c) => {
  29. const target = typeof value === "number" ? value : value(c);
  30. return getTargetValue(target, {
  31. max,
  32. min,
  33. });
  34. });
  35. };
  36. const inc = (delta = 1) => {
  37. setValue((c) => c + delta);
  38. };
  39. const dec = (delta = 1) => {
  40. setValue((c) => c - delta);
  41. };
  42. const set = (value: ValueParam) => {
  43. setValue(value);
  44. };
  45. const reset = () => {
  46. setValue(initialValue);
  47. };
  48. return [
  49. current,
  50. {
  51. inc,
  52. dec,
  53. set,
  54. reset,
  55. },
  56. ] as const;
  57. }
  58. export default useCounter;

这个 Hook 很简单,就是经典的计数器,拥有增加、减少、设置和重置 4 个操作。

误区

有些同学会觉得 hook 不就是纯函数么?为什么不能直接像纯函数那样去测呢?

  1. describe("useCounter", () => {
  2. it("可以加 1", () => {
  3. const [counter, utils] = useCounter(0);
  4. expect(counter).toEqual(0);
  5. utils.inc(1);
  6. expect(counter).toEqual(1);
  7. });
  8. });

由于这里用到了 useState,而 React 规定 只有在组件中才能使用这些 Hooks,所以这样测试的结果就会得到下面的报错:

React Hook 测试 - 图1

那我们是否可以通过前面讲的 Mock 手段来处理掉 useState 呢?千万别这么做! 假如 Hook 里不仅有 useState,还有 useEffect 这样的呢? 难道你要每个 React API 都要 Mock 一遍么?

想想我们做测试的初衷,我们做测试是为什么? 测试的初衷是为了带我们带来强大的代码信心。 好了,我知道你听这句话都听烦了,我自己都说烦了。

如果你把测试初衷忘掉,会很容易掉入测试代码细节的陷阱。一旦你的关注点不是代码的信心,而是测试代码细节,那么你的测试用例会变得非常脆弱,难以维护。 这样写出来的测试不仅不能给你带来代码信心,还会拖垮开发进程,真的不如不做测试。

不好意思,唠叨了一会,我们回来看看这个例子。要解决测试代码细节这个问题,唯一的办法就是把这个东西看成整体,比如……我们真的写一个组件来做测试?可以!安排!

测试组件

安装 @testing-library/user-event ,用于处理点击事件:

  1. npm i -D @testing-library/user-event@14.1.0

添加 tests/hooks/useCounter/TestComponent.test.tsx

  1. import useCounter from "hooks/useCounter";
  2. import { render, screen } from "@testing-library/react";
  3. import userEvent from "@testing-library/user-event";
  4. import React from "react";
  5. // 测试组件
  6. const UseCounterTest = () => {
  7. const [counter, { inc, set, dec, reset }] = useCounter(0);
  8. return (
  9. <section>
  10. <div>Counter: {counter}</div>
  11. <button onClick={() => inc(1)}>inc(1)</button>
  12. <button onClick={() => dec(1)}>dec(1)</button>
  13. <button onClick={() => set(10)}>set(10)</button>
  14. <button onClick={reset}>reset()</button>
  15. </section>
  16. );
  17. };
  18. describe("useCounter", () => {
  19. it("可以做加法", async () => {
  20. render(<UseCounterTest />);
  21. const incBtn = screen.getByText("inc(1)");
  22. await userEvent.click(incBtn);
  23. expect(screen.getByText("Counter: 1")).toBeInTheDocument();
  24. });
  25. it("可以做减法", async () => {
  26. render(<UseCounterTest />);
  27. const decBtn = screen.getByText("dec(1)");
  28. await userEvent.click(decBtn);
  29. expect(screen.getByText("Counter: -1")).toBeInTheDocument();
  30. });
  31. it("可以设置值", async () => {
  32. render(<UseCounterTest />);
  33. const setBtn = screen.getByText("set(10)");
  34. await userEvent.click(setBtn);
  35. expect(screen.getByText("Counter: 10")).toBeInTheDocument();
  36. });
  37. it("可以重置值", async () => {
  38. render(<UseCounterTest />);
  39. const incBtn = screen.getByText("inc(1)");
  40. const resetBtn = screen.getByText("reset()");
  41. await userEvent.click(incBtn);
  42. await userEvent.click(resetBtn);
  43. expect(screen.getByText("Counter: 0")).toBeInTheDocument();
  44. });
  45. });

上面我们写了一个 UseCounterTest 的组件,然后在组件内使用 useCounter,并把增加、减少、设置和重置功能绑定到 <Button/>。 在每个用例中,我们通过点击按钮来模拟这函数的调用,最后 expect 一下 Counter:n 的文本结果来完成测试。

当然,这个方法并不好,因为要用 <Button/> 来绑定一些操作,再通过点击按钮来触发它们,太 Low 了。为什么不直接操作 inc, dec, setreset 这几个函数呢?

setup

当然可以!我们要做的就是创建一个 setup 函数,在里面生成组件,同时把 useCounter 的结果返回出来就可以了:

  1. // tests/hooks/useCounter/setupTestComponent.test.tsx
  2. import useCounter from "hooks/useCounter";
  3. import { act, render } from "@testing-library/react";
  4. import React from "react";
  5. const setup = (initialNumber: number) => {
  6. const returnVal = {};
  7. const UseCounterTest = () => {
  8. const [counter, utils] = useCounter(initialNumber);
  9. Object.assign(returnVal, {
  10. counter,
  11. utils,
  12. });
  13. return null;
  14. };
  15. render(<UseCounterTest />);
  16. return returnVal;
  17. };
  18. describe("useCounter", () => {
  19. it("可以做加法", async () => {
  20. const useCounterData: any = setup(0);
  21. act(() => {
  22. useCounterData.utils.inc(1);
  23. });
  24. expect(useCounterData.counter).toEqual(1);
  25. });
  26. it("可以做减法", async () => {
  27. const useCounterData: any = setup(0);
  28. act(() => {
  29. useCounterData.utils.dec(1);
  30. });
  31. expect(useCounterData.counter).toEqual(-1);
  32. });
  33. it("可以设置值", async () => {
  34. const useCounterData: any = setup(0);
  35. act(() => {
  36. useCounterData.utils.set(10);
  37. });
  38. expect(useCounterData.counter).toEqual(10);
  39. });
  40. it("可以重置值", async () => {
  41. const useCounterData: any = setup(0);
  42. act(() => {
  43. useCounterData.utils.inc(1);
  44. useCounterData.utils.reset();
  45. });
  46. expect(useCounterData.counter).toEqual(0);
  47. });
  48. });

在第一个方法里,我们要一直和 <UseCounterTest/> 进行交互才能做测试,而这个方法则只是借了 <UseCounterTest/> 组件环境来生成一下 useCounter 结果, 用完就把别人抛弃了。

::: warning 注意:由于 inc 里面的 setState 是一个异步逻辑,因此我们在 @testing-library/react 提供的 act 里调用它。 :::

renderHook

基于这样的想法,@testing-library/react-hooks 把上面的步骤封装成了一个公共函数 renderHook

  1. npm i -D @testing-library/react-hooks@8.0.0

然后,在 renderHook 回调中使用 useCounter

  1. // tests/hooks/useCounter/renderHook.test.ts
  2. import { renderHook } from "@testing-library/react-hooks";
  3. import useCounter from "hooks/useCounter";
  4. import { act } from "@testing-library/react";
  5. describe("useCounter", () => {
  6. it("可以做加法", () => {
  7. const { result } = renderHook(() => useCounter(0));
  8. act(() => {
  9. result.current[1].inc(1);
  10. });
  11. expect(result.current[0]).toEqual(1);
  12. });
  13. it("可以做减法", () => {
  14. const { result } = renderHook(() => useCounter(0));
  15. act(() => {
  16. result.current[1].dec(1);
  17. });
  18. expect(result.current[0]).toEqual(-1);
  19. });
  20. it("可以设置值", () => {
  21. const { result } = renderHook(() => useCounter(0));
  22. act(() => {
  23. result.current[1].inc(10);
  24. });
  25. expect(result.current[0]).toEqual(10);
  26. });
  27. it("可以重置值", () => {
  28. const { result } = renderHook(() => useCounter(0));
  29. act(() => {
  30. result.current[1].inc(1);
  31. result.current[1].reset();
  32. });
  33. expect(result.current[0]).toEqual(0);
  34. });
  35. it("可以使用最大值", () => {
  36. const { result } = renderHook(() => useCounter(100, { max: 10 }));
  37. expect(result.current[0]).toEqual(10);
  38. });
  39. it("可以使用最小值", () => {
  40. const { result } = renderHook(() => useCounter(0, { min: 10 }));
  41. expect(result.current[0]).toEqual(10);
  42. });
  43. });

useQuery

实际上 renderHook 只是第二种方法里 setupTestComponent 的高度封装而已,更通用的方法依然是 setupTestComponent

这里我来再举一个更复杂的例子:useQuery。先安装必需的 NPM 包:

  1. npm i react-router-dom@6.3.0 history@5.3.0
  1. // src/hooks/useQuery.ts
  2. import React from "react";
  3. import { useLocation } from "react-router-dom";
  4. // 获取查询参数
  5. const useQuery = () => {
  6. const { search } = useLocation();
  7. return React.useMemo(() => new URLSearchParams(search), [search]);
  8. };
  9. export default useQuery;

useQuery 是我在 StackOverflow 上看到的一个 Hook。它的作用就是能够获取查询参数的值:

  1. // https://mysite.com?id=123
  2. const component = () => {
  3. const query = useQuery()
  4. return (
  5. // 123
  6. <div>{query.get("id")}</div>
  7. )
  8. }

这个例子的难点在于:我们的测试组件要和 React Router 做交互了,否则使用 useLocation 时会报错:

React Hook 测试 - 图2

要创建 React Router 环境,我们可以使用 createMemoryHistory 这个 API:

  1. // tests/hooks/useQuery.test.tsx
  2. import React from "react";
  3. import useQuery from "hooks/useQuery";
  4. import { createMemoryHistory, InitialEntry } from "history";
  5. import { render } from "@testing-library/react";
  6. import { Router } from "react-router-dom";
  7. const setup = (initialEntries: InitialEntry[]) => {
  8. const history = createMemoryHistory({
  9. initialEntries,
  10. });
  11. const returnVal = {
  12. query: new URLSearchParams(),
  13. };
  14. const TestComponent = () => {
  15. const query = useQuery();
  16. Object.assign(returnVal, { query });
  17. return null;
  18. };
  19. // 此处为 react router v6 的写法
  20. render(
  21. <Router location={history.location} navigator={history}>
  22. <TestComponent />
  23. </Router>
  24. );
  25. // 此处为 react router v5 的写法
  26. // render(
  27. // <Router history={history}>
  28. // <TestComponent />
  29. // </Router>
  30. // );
  31. return returnVal;
  32. };
  33. describe("userQuery", () => {
  34. it("可以获取参数", () => {
  35. const result = setup([
  36. {
  37. pathname: "/home",
  38. search: "?id=123",
  39. },
  40. ]);
  41. expect(result.query.get("id")).toEqual("123");
  42. });
  43. it("查询参数为空时返回 Null", () => {
  44. const result = setup([
  45. {
  46. pathname: "/home",
  47. },
  48. ]);
  49. expect(result.query.get("id")).toBeNull();
  50. });
  51. });

::: warning 注意,react-router-dom v5 和 v6 两个版本的 Router 传入 history 方式是不一样的。 :::

总结

总结一下 React Hook 的测试方法:

  1. 声明 setup,在里面通过渲染测试组件为 React Hook 提供 React 组件环境
  2. 把 React Hook 的返回结果返回给每个用例
  3. 每个用例从 setup 返回拿到 React Hook 的返回值,并对其进行测试

通过这一章,我们再次看到了集成测试的强大作用 —— 让测试忽略实现细节,只关注功能是否完好。