前端单测学习(7)—— mock

2,125 阅读4分钟

前言

在之前的文章我们讲解了很多关于单测的各种场景,还有一些实用的api,今天要讲到的是jest单测里面非常重要的一个功能,mock!我们之前的例子中都是比较简单的,在实际开发中我们的组件的复杂度是比我们的Demo组件复杂的多,可能会涉及一些第三方包的调用,后端服务的调用,以及一些自定义模块变量的引用等等,而这些也是我们单测需要考虑到的东西。但是我们的单测需要要侧重点,考虑到某些模块的实现过于复杂,无法在单测中实现,所以就必须要使用到我们的mock,主要的目标就是保证我们当前关注的这个组件的具体细节,其他可以用mock的方式替代。

项目改造

这里我们要做一件事情,就是使用第三方的包,然后在我们的组件中使用这些第三方的包,尽可能的模拟到我们现实项目中会碰到的情况 我们这里考虑引入react-router-dom这个包,给我们的项目增加一些路由及各种控制,笔者引入的时候react-router-dom的最新版本是6.0+,新的特性和之前的有不少区别,这里我们先用了之前的版本

pnpm add react-router-dom@~5.2.0

除此之外我们还可以引入对应的type包

pnpm add @types/react-router-dom --save-dev

btw,之前的一些type包也可以放在devDependencies中就好,这里我们顺便也改一下

"devDependencies": {
    "@types/react-router-dom": "^5.3.2",
    "@types/jest": "^26.0.15",
    "@types/node": "^12.0.0",
    "@types/react": "^17.0.0"
}

迁移完之前的依赖之后运行一下就可以

pnpm install

新增组件

跟之前一样,我们也是新增一个组件用于我们的单测,并且使用上我们的react-router-dom
src/components目录下新建todo-report目录

import { Tooltip, Card } from "antd";
import { useParams } from "react-router-dom";

interface TodoReportProp {
  title: string;
  extraMsg?: string;
  content: string;
}

function CardHeader(props: Pick<TodoReportProp, "title" | "extraMsg">) {
  const { title, extraMsg } = props;
  const { reportId } = useParams<{ reportId: string }>();
  return (
    <Tooltip title={extraMsg}>
      <span>{title}</span>
      <span>{`报告:${reportId}`}</span>
    </Tooltip>
  );
}

export default function TodoReport({ content, ...rest }: TodoReportProp) {
  return (
    <Card title={<CardHeader {...rest} />} style={{ width: 400 }}>
      <span>{content}</span>
    </Card>
  );
}

简单走读一下这个组件,也是比较简单的一个卡边展示,然后在我们的CardHeader中我们使用上了useParams这个api,用来获取路由中的reportId参数,然后显示在我们的视图中

改造路由&引入新组件

然后我们来改造一下入口App.tsx,给项目增加路由,把我们之前的一些内容按路由来显示

import "./App.css";
import { Button, message } from "antd";
import { useCallback } from "react";
import TodoHeader from "./components/todo-header";
import logo from "./logo.svg";
import TodoContent from "./components/todo-content";
import TodoReport from "./components/todo-report";
import { BrowserRouter, Switch, Route } from "react-router-dom";

function App() {
  const showMessage = useCallback(() => {
    message.info(`展示一个提示`);
  }, []);

  const onClickTitle = useCallback((title: string) => {
    window.alert(title);
  }, []);

  return (
    <div className="App">
      <BrowserRouter>
        <Switch>
          <Route exact={true} path="/">
            <Button
              type="primary"
              onClick={showMessage}
              style={{ visibility: "hidden" }}
            >
              这是一个按钮
            </Button>
            <div>
              <TodoHeader
                title="这是一个标题"
                containerStyle={{ border: "1px solid blue" }}
                isFinish={true}
                iconUrl={logo}
                onClickTitle={onClickTitle}
              />
              <TodoContent
                title="这是标题"
                content="这是一个很长很长的内容呀..."
              />
            </div>
          </Route>
          <Route exact={true} path="/report/:reportId">
            <div>
              <TodoReport
                title="这是一个标题"
                extraMsg="补充信息"
                content="这是一个内容"
              />
            </div>
          </Route>
        </Switch>
      </BrowserRouter>
    </div>
  );
}

export default App;

简单走读一下这次的改动,我们新增了两个路由,将之前的内容置于默认的/路径下,/report/:reportId路由下则是引入我们这一期新建的组件
运行一下看下效果

pnpm start

随便设置一个reportId,跳转到我们的指定页面

image.png 可以看到页面中读取到了url中的reportId的参数,然后正常显示出来

编写单测

首先我们按我们之前的方法来写一遍单测,看看有没有什么问题
依旧是在我们的src/components/__tests__目录下新增todo-report.test.tsx文件,编写我们的单测

import { render } from "@testing-library/react";
import TodoReport from "../todo-report";

describe("测试TodoHeader组件", () => {
  it("正确渲染TodoReport", () => {
    const title = "title";
    const content = "content";
    const extraMsg = "extraMsg";
    const { queryByText } = render(
      <TodoReport title={title} content={content} extraMsg={extraMsg} />
    );
    const titleElement = queryByText(title);
    const contentElement = queryByText(content);
    expect(titleElement).not.toBeNull();
    expect(contentElement).not.toBeNull();
  });
});

我们运行下看看

pnpm test -- src/components/__tests__/todo-report.test.tsx

image.png

好吧,报错了,而且是我们之前没有见过的报错,具体原因就会因为这个第三方的包提供的方法在我们单测的运行环境中无法被正确识别出来,这里就是需要用到我们一开始说到的方法,mock!

每个测试用例单独mock

在一个测试文件中,如果我们在不同的测试用例中需要有不同的实现,也就是不同的mock实现,这时候推荐用doMock来做

我们改造一下这个单测文件

import { render } from "@testing-library/react";

describe("测试TodoHeader组件", () => {
  it("正确渲染TodoReport", async () => {
    const title = "title";
    const content = "content";
    const extraMsg = "extraMsg";
    const reportId = `14`;
    jest.doMock("react-router-dom", () => ({
      useParams: () => ({
        reportId,
      }),
    }));
    const { default: TodoReport } = await import("../todo-report");
    const { queryByText } = render(
      <TodoReport title={title} content={content} extraMsg={extraMsg} />
    );
    const titleElement = queryByText(title);
    const contentElement = queryByText(content);
    const reportIdElement = queryByText(`报告:${reportId}`);
    expect(titleElement).not.toBeNull();
    expect(contentElement).not.toBeNull();
    expect(reportIdElement).not.toBeNull();
  });
});

我们走读一下这次的改动,增加了一个jest.doMock的实现,针对我们useParams的方法指定了默认的返回,还有一个改动就是引入的部分,之前我们的引入是在最上面的,就是在运行之前我们已经有引入了这个组件,但是我们的mock是在后面的,如果依旧是在运行前就引入,组件实际上是没办法正确被mock,这里需要在执行Mock之后再引入,可以用动态import,或者require也可以。然后我们增加一个在视图中获取reportId组件的断言,增加我们的单测覆盖场景。
运行一下看看

pnpm test -- src/components/__tests__/todo-report.test.tsx

image.png

这回就正常运行通过啦,当然除了运行通过外我们还要注意清除副作用,尤其是这种mock的操作,所以我们可以在每个用例执行之后做重置的动作,这里需要用到afterEach这个生命周期

describe("测试TodoHeader组件", () => {
  afterEach(() => {
    jest.resetModules()
  });
  ......
});

顶层mock

jest.doMock也可以在每个测试用例中重复做,但是当mock的实现一致的时候,这里我们推荐是在顶层做这个mock实现,这里就需要注意组件或者模块的引入顺序,要先进行mock之后才引入对应的模块,这时候才能保证我们的mock实现能够正常响应。

我们在上面的基础上改一下实现

import { render } from "@testing-library/react";

const reportId = `14`;

jest.mock("react-router-dom", () => ({
  useParams: () => ({
    reportId,
  }),
}));

describe("测试TodoHeader组件", () => {
  afterEach(() => {
    jest.resetModules();
  });
  it("正确渲染TodoReport", async () => {
    const title = "title";
    const content = "content";
    const extraMsg = "extraMsg";
    const { default: TodoReport } = await import("../todo-report");
    const { queryByText } = render(
      <TodoReport title={title} content={content} extraMsg={extraMsg} />
    );
    const titleElement = queryByText(title);
    const contentElement = queryByText(content);
    const reportIdElement = queryByText(`报告:${reportId}`);
    expect(titleElement).not.toBeNull();
    expect(contentElement).not.toBeNull();
    expect(reportIdElement).not.toBeNull();
  });
});

简单解释一下,这里我们在顶层中使用jest.mock,所以测试文件中的所有测试用例都会生效,我们的组件也是保证在mock之后做引入,这样才能保证我们的mock实现能够在组件中实现

看下运行结果

image.png

spyOn

spyOn同jest.fn类似,也是返回一个mock的方法,同时也支持object[methodName]这样的mock函数。或者当我们想覆盖一些默认的方法或者行为的时候,spyOn是一个非常不错的选择

我们新建一个todo-spy组件,里面获取window.location.pathname的值然后在页面中显示出来

export default function TodoSpy() {
  const { pathname } = window.location;
  return <span>{pathname}</span>;
}

用spyOn编写对应的单测

import { render } from "@testing-library/react";
import TodoSpy from "../todo-spy";

describe("测试TodoSpy组件", () => {
  it("正确渲染pathname", () => {
    const pathname = "/test/eason";
    const spy = jest.spyOn(window, "location", "get").mockReturnValueOnce({
      ...window.location,
      pathname,
    });
    const { queryByText } = render(<TodoSpy />);
    const element = queryByText(pathname);
    expect(element).not.toBeNull();
    spy.mockRestore();
  });
});

简单走读一下我们这次的单测,我们通过spyOn这个api对window.location进行mock操作,mock了一次返回值的,因为我们这里只是用到了值而没有修改到什么值,所以第三个参数只需要get就可以,我们指定了一个pathname,这样就可以保证页面中获取到的pathname是我们指定的这个,最后我们通过mockRestore这个api来清除副作用,达到重置的效果。
运行下看看效果,可以看到符合我们的预期输出

pnpm test -- src/components/__tests__/todo-spy.test.tsx

image.png

上面是get的形式,就单单获取一个值,如果我们是要mock一些方法的时候要咋整呢,往下看,改一下之前的组件,增加一个调用querySelectorAll获取到所有元素,然后在页面中显示元素的个数

export default function TodoSpy() {
  const { pathname } = window.location;
  const list = document.querySelectorAll("img");
  return (
    <div>
      <span key="pathname">{pathname}</span>
      <span key="result">{`size: ${list.length}`}</span>
    </div>
  );
}

对应的单测,这里就用到了mockImplementationOnce这个方法,mock了一个返回,返回我们指定的一个数组,这样页面中就是能够正常渲染我们的个数

it("正确渲染querySelectorAll的结果", () => {
    const list = [1, 2, 3];
    const spy = jest
      .spyOn(document, "querySelectorAll")
      .mockImplementationOnce((_str: string) => list);
    const { queryByText } = render(<TodoSpy />);
    const element = queryByText(`size: ${list.length}`);
    expect(element).not.toBeNull();
    spy.mockRestore();
  });

image.png

结尾

mock在单测中起到一个非常重要的作用,本文做了简单的介绍和实践,关于mock的相关api可以去看下jest的官方api,还有不少相关的api,因为平时工作中用到的不多,这里就不展开一个个解释

传送门

前端单测学习(1)—— 单测入门之react单测项目初步
前端单测学习(2)—— react 组件单测初步
前端单测学习(3)—— react组件单测进阶
前端单测学习(4)—— react 组件方法&fireEvent
前端单测学习(5)—— 快照
前端单测学习(6)—— 定时器
前端单测学习(7)—— mock
前端单测学习(8)—— react hook
前端单测学习(9)—— 覆盖率报告
前端单测学习(10)—— 状态管理redux
前端单测学习(11)—— react hook 进阶
前端单测学习(12)—— 性能优化
前端单测学习(13)—— 自动化测试

代码仓库:github.com/liyixun/rea…