编写单元测试

430 阅读15分钟

在实际开发中,为了保证组件的质量一般会要求写单元测试组件,记录总结一下便于了解和深入学习 首先我们应该了解目前单元测试工具

前端测试工具

而说起前端的测试框架和工具,比较主流的 JavaScript 测试框架有 Jest、Jasmine、Mocha 等等,并且还有一些 UI 组件测试工具,比如 testing-libraray,enzyme 等等。

测试模式

在软件开发中常见有两种开发模式,一种叫TDD,另外一种叫BDD,笔者在此之前听到比较多的是TDD,BDD也是在后面工作中听到后才了解的,网上有不少关于这方面的讲解,笔者这里就不做过多的讲解,只从自己的认识简单讲一讲

TDD (Test Driven Development 测试驱动开发)

在这种模式下一般会先写测试用例,或者是开发和测试同时编写,当对应的功能模块开发完成的时候,对应的测试用例也是开发完成。以笔者的工作经验距离,在一些版本迭代的时候会有一些技术评审,这时候前端开发可以对当前的一些功能或者模块做组件设计,一些可能通用的类或者方法会提出来,或者在开发的过程中发现某些功能或者过程可以抽象出通用的方法,这时候就可以提前先针对预设的组件或者方法先写好单测,或者在开发过程中抽取类或者方法的同时顺便编写单测用例,这时候通过单测的编写可以明确类或方法提供的功能,还有预期的输入和对应输出结果。

BDD (Behavior Driven Development 行为驱动开发)

这种模式一般是去到用户行为的维度,一般是在完成业务代码开发之后,以用户行为指导编写测试用例。以笔者的工作经验举例,在版本迭代开始的时候会有QA的同事进行用例编写,同时QA会将写好的测试用例(一般是以脑图的形式)发出来做一个评审,QA同事提供的这个脑图相当于是一个开发这边的测试用例参考,因为在需求和实际的一些交互还有一些边界问题可能在开发过程中才发现,所以一般会早业务代码完成开发之后才编写测试用例,避免重复的修改测试用例。

具体工作中的实践选择

TDD一般是偏向于类 方法 工具等维度,如果是开发一个工具包或者类、方法等,这种TDD的指导思想是比较适合的,不过TDD也是会偏向于开发过程理想化的一个,在实际开发中可能给到的开发时间是相对紧凑的,所以提前写单测或者一边开发一边写单测会占用一部分时间,所以不一定会进行。BDD偏向于用户行为的编写,会做整体业务模块的测试,这种在软件开发中很多时候是通过人工完成的,笔者见到的对用户行为的用例编写并不是很多。总的来说两种模式还是得结合着来,根据实际编写的是业务逻辑还是类方法等采用不同的模式。

编写单元测试组件关注点

在我们实际编写单元测试用例中,我们实际编写过程中只会关注以下几点:

  • 组件渲染是否符合预期
  • 普通事件点击的回调函数是否正确执行
  • 业务埋点函数是否被正常调用
  • 异步接口请求
  • setimeout 等异步操作是否按预期执行
  • 类组件的state,生命周期,方法的调用是否正常
  • 函数组件的hooks使用导致state,生命周期, 数据处理等如何处理能正常反馈组件实例
  • window,document的单元测试
  • 关于引入他人组件如何编写单元测试

而且在react官网中明确表示了以后可能会以使用函数式组件为主,所以其实学习函数式组件s来编写单侧是很有用

技术选型

因为笔者平时开发的技术栈是用的react,这里就主要是针对react的单测进行学习

  • Jest 单测的集大成者,Facebook出品
  • react-tesing-library 提供一些操作react component的API,而且使用create-react-app脚手架生成的已经默认用react-testing-libray,可见官方也是比较推荐这个react-test
  • enzyme 也是同react-tesing-libray相类似的一个库,react-tesing-library相对比较简单,对于enzyme我们这里不做介绍和使用
  • pnpm 包管理工具,没有装pnpm的也可以用npm和yarn
  • pnpx 同npx一样的命令行工具
  • typescript 看个人开发习惯,笔者比较习惯ts开发所以选择

项目

image.png

  • @testing-library/jest-dom 写单测的时候我们会有需要用到检查元素的属性、文本内容,样式类名等,这个库就是拓展了jest的能力,提供jest machers来增强能力,将使测试更具声明性、阅读和维护更清晰。

  • @testing-library/react testing-libray里面关于react的部分,因为我们是针对react来做单测,所以要用这个库

  • @testing-library/user-event 提供一些模拟用户与浏览器交互的事件,方便我们断言测试操作后预期的一些效果

  • @types/jest jest的一些类型定义,因为这里用到了ts,所以加上这个包增加代码提示

image.png

这里主要关心两个文件

  • setupTest.ts 全局的文件,会帮我们引入一些全局的额东西,比如我们用到的这个**@testing-library/jest-dom**
  • App.test.tsx 针对App.tsx的单测文件

看一下这个App.test.tsx

import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';

test('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});

首先是定义了一个test,可以理解为一个测试单元,然后引入了App这个入口文件,通过render这个Api来渲染这个组件,因为我们做单测的环境里面实际上是没有浏览器环境的,所以我们需要通过拓展的这个render方法来渲染出一个组件,然后就是第二步,在页面中根据文案查找对应的元素,这里用到了screen这个范围,实际上我们也可以通过render返回值中获取对应的查询方法,例如getByText,这里先不展开细说,注意这里用到了正则表达式,最下面是一个断言,断言这个元素是存在于document中的。

  • describe 定义了一些相关测试的集合,以当前我们的这个测试文件为例,我们可以把这个组件相关的测试用例全部写在一个集合中

  • it 相当于一个测试用例,我们保证一个测试用例只测一个功能或者一个属性,就像一个函数只做一件事情意义

  • render testing-library提供的用于渲染组件的能力

  • screen 可以理解为testing-library提供给我们的一个可查询的对象,因为查询整个document.body非常常见,DOM Testing library还导出一个screen对象,其中包含预先绑定到document.body的每个查询

  • expect 一些断言,这里断言查询到的这个元素是出于这个document中

其他判断正确渲染的方法

通过queryByText

it("正确渲染title组件通过queryByTest", () => {
    const title = "测试的标题";
    const { queryByText } = render(<TodoHeader title={title} />);
    const titleElement = queryByText(title);
    expect(titleElement).not.toBeNull();
    expect(titleElement).toBeInTheDocument();
});

通过render暴露出来的queryByText方法来查询是否存在,这里返回的titleElement可能是null,所以我们断言不等于null,并且该节点出于document中

通过getByText

it("正确渲染title组件通过getByText", () => {
    const title = "测试的标题";
    const { getByText } = render(<TodoHeader title={title} />);
    const titleElement = getByText(title);
    expect(titleElement).toBeInTheDocument();
});

和queryByText比较像,这不过这里不会返回null,所以我们不做不等于null的断言

通过container的query

it("正确渲染title组件通过container的query", () => {
    const title = "测试的标题";
    const { container } = render(<TodoHeader title={title} />);
    const titleElement = container.querySelector("span");
    expect(titleElement).toHaveTextContent(title);
});

render会返回一个container,我们就可以通过container的querySelector方法来查询对应的节点,这里我们的title属性是处于span标签中的,而且目前有且只有一个,所以selector写的比较简单,直接写span,获得节点后断言节点的textContent等于title

通过testid查询

我们在原先的组件中增加data-testid属性,设置我们约定的id

<div className="report-header">
  <span className="title" data-testid="todo-header-title">
    {title}
  </span>
</div>

it("正确渲染title组件通过getByTestId", () => {
    const title = "测试的标题";
    const { getByTestId } = render(<TodoHeader title={title} />);
    const titleElement = getByTestId("todo-header-title");
    expect(titleElement).toHaveTextContent(title);
});

样式: toHaveStyle

it(`正确渲染containerStyle的样式`, () => {
    const borderStyle = "1px solid blue";
    const containerStyle: CSSProperties = {
      border: borderStyle,
    };
    const { container } = render(
      <TodoHeader title="标题" containerStyle={containerStyle} />
    );
    expect(container.children[0]).toHaveStyle(`border: ${borderStyle}`);
});

这里我们用到了一个api,toHaveStyle来判断我们的样式是否有渲染出来,因为我们的样式是挂载在最顶层的div,所以我们直接用container.children[0]来获取这个div即可,当然可以换成跟我们上一篇文章里面用到的方法一样。

样式: fireEvent

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

<TodoHeader
  title="这是一个标题"
  containerStyle={{ border: "1px solid blue" }}
  isFinish={true}
  iconUrl={logo}
  onClickTitle={onClickTitle}
/>
复制代码

看一下效果,符合预期

image.png

这里我们需要用到几个知识点,一个是mock函数,通过jest.fn()我们创建了一个用于测试的函数,传入到组件中,第二个是toBeCalled这个api,通过这个我们可以来断言我们的方法是否有被调用到。第三个是fireEvent,这个是testing libray提供的一个模拟用户操作的一个功能,比如我们这里用到的一个click事件

it(`正确响应onClickTitle的事件`, () => {
    const mockClickFn = jest.fn();
    const title = "标题";
    const { getByText } = render(
      <TodoHeader title={title} onClickTitle={mockClickFn} />
    );
    fireEvent.click(getByText(title));
    expect(mockClickFn).toBeCalled();
});
<Input type="text" style={{ width: 300, display: "flex" }} value={currentTitle} onChange={e => setCurrentTitle(e.target.value)} />
it(`正确处理Input change 事件`, async () => {
    const title = "标题";
    const newTitle = "新的标题";
    const { container } = render(<TodoHeader title={title} />);
    const inputElement = container.querySelector("input");
    expect(inputElement).not.toBeNull();
    await act(async () => {
      fireEvent.change(inputElement!, { target: { value: newTitle } });
    });
    const element = screen.queryByText(newTitle);
    expect(element).not.toBeNull();
});

判断异步方法的调用

我们知道,在js中充满着各种异步,我们在此之前碰到过的各种单测基本上都没有涉及异步的东西,但是在实际项目中异步是必不可少的,所以这里我们也做一下异步这种情况的单测

import "./index.css";
import {
  PropsWithChildren,
  ReactNode,
  CSSProperties,
  useCallback,
  useState,
  useEffect,
} from "react";

interface TodoHeaderProps {
  // 待办事项的标题
  title: string;
  // 最外层容器的样式
  containerStyle?: CSSProperties;
  // 是否结束
  isFinish?: boolean;
  // 图标的链接
  iconUrl?: string;
  // 额外的信息
  extraInfo?: ReactNode;
  // 点击标题的事件
  onClickTitle?: (title: string) => void;
  // 初始化的方法
  onInit?: () => Promise<string>;
}

export default function TodoHeader({
  title,
  containerStyle,
  iconUrl,
  isFinish = false,
  children,
  extraInfo,
  onClickTitle,
  onInit,
}: PropsWithChildren<TodoHeaderProps>) {
  const [currentTitle, setCurrentTitle] = useState<string>(title);
  // 点击标题的方法
  const clickTitleFn = useCallback(() => {
    onClickTitle?.(title);
  }, [onClickTitle, title]);

  useEffect(() => {
    if (onInit) {
      (async () => {
        const result = await onInit();
        setCurrentTitle(result);
      })();
    }
  }, [onInit]);

  return (
    <div className="report-header" style={containerStyle}>
      {iconUrl && <img src={iconUrl} alt="icon" />}
      <span
        className="title"
        data-testid="todo-header-title"
        style={{ background: isFinish ? "red" : "white" }}
        onClick={clickTitleFn}
      >
        {currentTitle}
      </span>
      <span className="extra">{extraInfo}</span>
      {children}
    </div>
  );
}

首先我们先按我们之前的思路来写单测 ,mock一个异步函数,返回一个新的字符串,然后查询这个字符串是否存在于视图中

it(`正确响应onInit事件`, () => {
    const title = "标题";
    const newTitle = "新的标题";
    const mockInitFn = jest.fn(() => Promise.resolve(newTitle));
    const { queryByText } = render(
      <TodoHeader title={title} onInit={mockInitFn} />
    );
    const element = queryByText(newTitle);
    expect(element).not.toBeNull();
});
复制代码

看下运行结果,果不其然已经报错了

image.png

这时候我们可以看到报错提示中已经非常明显的提示我们要怎么做,提示大概的意思就是因为我们内部有update state,然后这个update state实际上是异步的,我们做的断言是基于update之后的内容,所以解决方案我们就是需要用act包一下这个render方法 改一下上面的测试用例

it(`正确响应onInit事件`, async () => {
    const title = "标题";
    const newTitle = "新的标题";
    const mockInitFn = jest.fn(() => Promise.resolve(newTitle));
    await act(async () => {
      render(<TodoHeader title={title} onInit={mockInitFn} />);
    });
    const element = screen.queryByText(newTitle);
    expect(element).not.toBeNull();
});
复制代码

看下运行的结果,这回是已经通过了

另外的解决方案,实际上我们在之前的用例中实际上也是有用过,就是用的waitFor这个api
我们同样基于上面的case改写一下,其实就是将act改为了waitFor,其他都不变

it(`正确响应onInit事件`, async () => {
    const title = "标题";
    const newTitle = "新的标题";
    const mockInitFn = jest.fn(() => Promise.resolve(newTitle));
    await waitFor(async () => {
      render(<TodoHeader title={title} onInit={mockInitFn} />);
    });
    const element = screen.queryByText(newTitle);
    expect(element).not.toBeNull();
});

对一些异步和延时的处理

使用单个参数调用 done,而不是将测试放在一个空参数的函数,Jest 会等 done 回调函数执行结束后,结束测试

test('the data is peanut butter', done => {
  function callback(data) {
    try {
      expect(data).toBe('peanut butter');
      done();
    } catch (error) {
      done(error);
    }
  }

  fetchData(callback);
});

快照测试

可以回顾下我们往期的文章,之前已经提过了单测的作用以及在研发流程上带来的一些收益,这里我们举个例子,我们在实际项目中会出现某些模块写的不好然后进行代码上的重构,改造完成之后我们需要对我们重构或者改造后的功能进行冒烟测试,而快照测试也是单测中一种保证冒烟的有效手段。如果我们想要确保我们改造后UI上不会有以外的变化,快照测试就是一款非常有用的工具。典型的做法就是在渲染了UI组件之后,保存一个快照文件,检测这个快照文件是否与保存在单测旁的快照文件想匹配,若两个快照文件不匹配,测试即视为失败,有可能做了以外的更改,或者UI组件已经更新到了新版本。

快照初步

同之前的例子一样,我们先针对这个TodoContent组件编写单测
src/components/__tests__目录下创建关于这个组件的单测文件todo-content.test.tsx

import { render } from "@testing-library/react";
import TodoContent from "../todo-content";

const TITLE = "这是一个标题";
const CONTENT = "这是一个内容";

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

  it("正确匹配快照", () => {
    const { asFragment } = render(
      <TodoContent title={TITLE} content={CONTENT} />
    );
    expect(asFragment()).toMatchSnapshot();
  });
});

image.png

这时候我们留意一下我们这个单测文件的同级目录,这时候已经出现了__snapshots__这个目录,还有生成了一个todo-content.test.tsx.snap

看下快照的内容

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`测试TodoContent 正确匹配快照 1`] = `
<DocumentFragment>
  <div
    class="ant-card ant-card-bordered"
    style="width: 300px;"
  >
    <div
      class="ant-card-head"
    >
      <div
        class="ant-card-head-wrapper"
      >
        <div
          class="ant-card-head-title"
        >
          这是一个标题
        </div>
      </div>
    </div>
    <div
      class="ant-card-body"
    >
      <textarea
        class="ant-input"
        rows="4"
      >
        这是一个内容
      </textarea>
    </div>
  </div>
</DocumentFragment>
`;
复制代码

可以看到根据我们render的组件然后创建了一个dom结构并且保存了下来,因为是第一次运行,这时候就不存在匹配快照的情况。
我们来走读下我们刚刚写的单测,我们在通过render之后用到了asFragment这个方法,通过这个方法我们生成类似于document的结构,然后就是断言部分,我们通过toMatchSnapshot方法来匹配这个快照
我们改一下我们的组件的传参,然后重新运行下这个单测

const { asFragment } = render(
  <TodoContent title={TITLE} content="我改了这个内容" />
);
复制代码

重新运行这个单测看看结果

image.png

看到这时候原本已经可以运行通过的单测因为快照匹配失败而直接报错了 除了这种常见的组件入参之外,我们可以改一下组件的样式,把宽度改为了400,这时候看下这个快照能否匹配上

<Card title={title} style={{ width: 400 }}>
  <TextArea rows={4} value={content} />
</Card>

image.png

可以看到这时候我们的快照匹配也是失败的,而且在报错信息中也明确的提示出来,我们的style方面匹配失败,这也是给了我们修改的方向

更新快照

从上面的例子我们可以看出,当我们写了单测之后,我们的snap是在第一次运行时候生成出来的,后面我们的一些改造实际上是没有问题的,但是由于快照没有更新,所以匹配就一直错误,这里就需要更新快照了
这里我们就需要用到jest的updateSnapshot这个命令了,我们在package.json中增加一个更新快照的命令

"updateSnapshot": "react-scripts test --updateSnapshot"

image.png 这时候快照已经保存进去了

image.png

定制快照内容

在上面的代码中,我们都是通过一个代码片段保存成为了一个快照,实际上我们可以定制这个快照保存的内容,除了上面的代码片段外,例如json,或者对象字符串等等都是可以成为我们的定制快照内容

我们单独写一个定制快照内容的单测

describe("测试定制对象的快照内容", () => {
  it("正确匹配快照", () => {
    const user = {
      name: "eason",
      age: 18,
    };
    expect(user).toMatchSnapshot({
      name: "eason",
      age: 18,
    });
  });
});
复制代码

然后运行这个单测生成快照文件

image.png

定时器

useEffect(() =>
{ const timerId = setTimeout(() => { setTodoContent("延迟1s后的内容"); }, 1000); 
return () => { clearTimeout(timerId); }; 
}, []); 
return <span>{todoContent}</span>;

编写Timeout单测

和之前一样我们还是在components/__tests__目录下新建对应的单测文件,命名为todo-timer.test.tsx

import { act, render } from "@testing-library/react";
import TodoTimer from "../todo-timer";

describe("测试TodoTimer组件", () => {
  it("正确运行定时器", async () => {
    jest.useFakeTimers();
    const { queryByText } = render(<TodoTimer />);
    act(() => {
      jest.runAllTimers();
    });
    const element = queryByText("延迟1s后的内容");
    expect(element).not.toBeNull();
  });
});

这里我们通过这两个api来运行定时器,因为在实际应用中我们定时器的等待时间可能非常的长,而我们单测的目的只是想保证我们的输入输出是否符合预期,按定时器设置的事件来等待没有太大意义,而且也不现实,所以我们通过这两个api的作用直接运行模拟定时器的执行即可。
然后就是跟我们之前的判断基本一致,判断页面中是否有符合预期的输出即可。

编写Interval单测

import { act, render } from "@testing-library/react";
import TodoInterval from "../todo-interval";

describe("测试TodoInterval组件", () => {
  it("正确运行定时器", async () => {
    jest.useFakeTimers();
    const callback = jest.fn();
    render(<TodoInterval callback={callback} />);
    act(() => {
      jest.runOnlyPendingTimers();
    });
    expect(callback).toBeCalled();
  });
});

复制代码

这里我们补充了一个新的api,jest.runOnlyPendingTimers();,这个和之前的runAllTimer的区别在于这个api主要是用来处理一些有循环定时器的情况,例如setTimeout里面嵌套setTimeout的情况或者这种setInterval的情况

mock

<Route exact={true} path="/report/:reportId"> 
<div> <TodoReport title="这是一个标题" extraMsg="补充信息" content="这是一个内容" /> </div> 
</Route>

每个测试用例单独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组件的断言,增加我们的单测覆盖场景。

顶层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();
  });
});

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来清除副作用,达到重置的效果。
运行下看看效果,可以看到符合我们的预期输出

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了一个返回,返回我们指定的一个数组,这样页面中就是能够正常渲染我们的个数

mock 组件内系统函数的返回结果

对于组件内调用了 document 上的方法,可以通过 mock 指定方法的返回值,来保证一致性

const getBoundingClientRectMock = jest.spyOn(
    HTMLHeadingElement.prototype,
    'getBoundingClientRect',
);

beforeAll(() => {
    getBoundingClientRectMock.mockReturnValue({
        width: 100,
        height: 100,
        top: 1000,
    } as DOMRect);
});

afterAll(() => {
    getBoundingClientRectMock.mockRestore();
});
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();
  });

直接调用组件方法

通过 wrapper.instance()获取组件实例,再调用组件内方法,如:wrapper.instance().handleScroll() 测试系统方法的调用

const scrollToSpy = jest.spyOn(window, 'scrollTo');

const calls = scrollToSpy.mock.calls.length;

expect(scrollToSpy.mock.calls.length).toBeGreaterThan(calls);

使用属性匹配器代替时间

当快照有时间时,通过属性匹配器可以在快照写入或者测试前只检查这些匹配器是否通过,而不是具体的值

it('will check the matchers and pass', () => {
  const user = {
    createdAt: new Date(),
    id: Math.floor(Math.random() * 20),
    name: 'LeBron James',
  };

  expect(user).toMatchSnapshot({
    createdAt: expect.any(Date),
    id: expect.any(Number),
  });
});

# react hook

useCheckbox.ts

import { useState } from 'react';

export default function useCheckbox<T>(
  values: T[],
  checkboxKey: keyof T,
  defaultSelected?: T[],
) {
  // 选中项
  const [selected, setSelected] = useState<T[]>(defaultSelected ?? []);

  // 判断该选项是否被选中
  function isSelected(checkItem: T) {
    return selected.some(item => {
      return item[checkboxKey] === checkItem[checkboxKey];
    });
  }

  // 判断是否全部被选中
  function isAllSelected() {
    return values.every(item => isSelected(item));    
  }

  // 是否只有部分选项选中
  function isPartialSelected() {
    const checkHasSelected = values.some(item => isSelected(item));
    // 有一部分被选中但是又不是全部都选中
    return checkHasSelected && !isAllSelected();
  }

  // 选中全部
  function selectAll() {
    setSelected(values);
  }

  // 取消全部选中
  function unSelectAll() {
    setSelected([]);
  }

  return {
    selected,
    setSelected,
    isAllSelected,
    isPartialSelected,
    selectAll,
    unSelectAll,
    isSelected,
  }
}
import useQueryCheckbox from '../useCheckbox';
import { renderHook } from '@testing-library/react-hooks';

interface MockCheckboxItemProps {
  key: string;
  value: string;
}

// 获取用于测试的默认checkbox对象列表
function generateCheckboxList(endIndx: number, startIndex = 1) {
  const defaultCheckboxList: MockCheckboxItemProps[] = [];
  for (let i = startIndex; i <= endIndx; i++) {
    defaultCheckboxList.push({
      key: `${i}`,
      value: `mock-${i}`,
    });
  }
  return defaultCheckboxList;
}

describe('hook useQueryCheckbox', () => {
  it('should return the total selected item', () => {
    const checkboxList = generateCheckboxList(8);
    const { result } = renderHook(() => useQueryCheckbox(checkboxList, 'key'));
    const { selected } = result.current;
    expect(selected).toEqual([]);
  });

  it('should return the total default selected item', () => {
    const checkboxList = generateCheckboxList(8);
    const { result } = renderHook(() =>
      useQueryCheckbox(checkboxList, 'key', checkboxList),
    );
    const { selected } = result.current;
    expect(selected).toEqual(checkboxList);
  });
});

状态管理redux

reduer是纯函数,操作原先的状态之后放回一个新的状态,我们对于reducer具体的细节不需要过多的关注,只要关注状态的input和output是否符合预期即可,这个也是我们编写单测的重点,中间的实现视为黑盒,只关注input的值能否对应准确的output即可。

在这种情况下,matchMedia在测试文件中模拟应该可以解决问题:

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(), // deprecated
    removeListener: jest.fn(), // deprecated
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

如果window.matchMedia()在测试中调用的函数(或方法)中使用,则此方法有效。如果window.matchMedia()直接在被测文件中执行,Jest 报同样的错误。在这种情况下,解决方案是将手动模拟移动到一个单独的文件中,并将这个模拟包含在测试文件之前的测试中:

import './matchMedia.mock'; // Must be imported before the tested file
import {myMethod} from './file-to-test';

describe('myMethod()', () => {
  // Test the method here...
});
import {
  act,
  fireEvent,
  render,
  RenderResult,
  screen,
} from "@testing-library/react";
import { Provider } from "react-redux";
import { store } from "../../store";

import Counter from "../Counter";

const renderBook = (): RenderResult =>
  render(
    <Provider store={store}>
      <Counter />
    </Provider>
  );

describe("counter页面单测", () => {
  it("正确响应加一的状态更新", async () => {
    renderBook();
    await act(async () => {
      fireEvent.click(screen.getByTestId("addOne"));
    });
    const resultElement = screen.getByTestId("result");
    expect(resultElement.innerHTML).toEqual("1");
  });

  it("正确响应减一的状态更新", async () => {
    renderBook();
    await act(async () => {
      fireEvent.click(screen.getByTestId("reduceOne"));
    });
    const resultElement = screen.getByTestId("result");
    expect(resultElement.innerHTML).toEqual("0");
  });
});

生成测试覆盖报告

我们可以通过执行下面这样的命令来生成我们覆盖率报告

npm test -- --coverage a

可以看到在我们的控制台出现了一个报告,为了方便我们运行,我们在package.json中的scripts加上我们的这个覆盖率的命令

"utCoverage": "npm test -- --coverage a"

覆盖率

Jest 还提供了生成测试覆盖率报告的命令,只需要添加上 --coverage 这个参数即可生成,再加上--colors 可根据覆盖率生成不同颜色的报告(<50%红色,50%~80%黄色, ≥80%绿色)

  • % Stmts 是语句覆盖率(statement coverage):是否每个语句都执行了
  • % Branch 分支覆盖率(branch coverage):是否每个分支代码块都执行了(if, ||, ? : )
  • % Funcs 函数覆盖率(function coverage):是否每个函数都调用了
  • % Lines 行覆盖率(line coverage):是否每一行都执行了

附录

JEST 语法

匹配器

expect:返回一个'期望‘的对象

toBe:使用 object.is 去判断相等

toEqual:递归检测对象或数组的每个字段

not:测试相反的匹配

真值

toBeNull:只匹配 null

toBeUndefined:只匹配 undefined

toBeDefined:与 toBeUndefined 相反

toBeTruthy:匹配任何 if 语句为真

toBeFalsy:匹配任务 if 语句为假

数字

toBeGreaterThan:大于

toBeGreaterThanOrEqual:大于等于

toBeLessThan:小于

toBeLessThanOrEqual:小于等于

toBeCloseTo:比较浮点数相等

字符串

toMatch:匹配字符串

Array

toContain:检测一个数组或可迭代对象是否包含某个特定项

例外

toThrow:测试某函数在调用时是否抛出了错误

自定义匹配器

// The mock function was called at least once
expect(mockFunc).toHaveBeenCalled();

// The mock function was called at least once with the specified args
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);

// The last call to the mock function was called with the specified args
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);

// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();
复制代码

安装和移除

为多次测试重复设置:beforeEach、afterEach 来为多次测试重复设置的工作

一次性设置:beforeAll、afterAll 在文件的开头做一次设置

作用域:可以通过 describe 块将测试分组,before 和 after 的块在 describe 块内部时,则只适用于该 describe 块内的测试

模拟函数

Mock 函数允许你测试代码之间的连接——实现方式包括:擦除函数的实际实现、捕获对函数的调用(以及在这些调用中传递的参数)、在使用 new 实例化时捕获构造函数的实例、允许测试时配置返回值。

两种方法可以模拟函数:1.在测试代码中创建一个 mock 函数,2.编写一个手动 mock 来覆盖模块依赖

mock 函数

const mockCallback = jest.fn((x) => 42 + x);
forEach([0, 1], mockCallback);

// 此 mock 函数被调用了两次
expect(mockCallback.mock.calls.length).toBe(2);

// 第一次调用函数时的第一个参数是 0
expect(mockCallback.mock.calls[0][0]).toBe(0);

// 第二次调用函数时的第一个参数是 1
expect(mockCallback.mock.calls[1][0]).toBe(1);

// 第一次函数调用的返回值是 42
expect(mockCallback.mock.results[0].value).toBe(42);
复制代码

.mock 属性

所有的 mokc 函数都有这个特殊的.mock 属性,它保存了关于此函数如何被调用、调用时的返回值的信息。.mock 属性还追踪每次调用时的 this 的值,所以我们同样可以检查 this

// 这个函数被实例化两次
expect(someMockFunction.mock.instances.length).toBe(2);

// 这个函数被第一次实例化返回的对象中,有一个 name 属性,且被设置为了 'test’
expect(someMockFunction.mock.instances[0].name).toEqual('test');
复制代码

Mock 的返回值

const myMock = jest.fn();
console.log(myMock());
// > undefined

myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);

console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true
复制代码

模拟模块

可以 用 jest.mock(...)函数自动模拟 axios 模块,一旦模拟模块,我们可为.get 提供一个 mockResolveValue,它会返回假数据用于测试

// users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
  const users = [{ name: 'Bob' }];
  const resp = { data: users };
  axios.get.mockResolvedValue(resp);

  // or you could use the following depending on your use case:
  // axios.get.mockImplementation(() => Promise.resolve(resp))

  return Users.all().then((data) => expect(data).toEqual(users));
});
复制代码

Mock 实现

用 mock 函数替换指定返回值:jest.fn(cb => cb(null, true))

用 mockImplementation 根据别的模块定义默认的 mock 函数实现:jest.mock('../foo'); const foo = require('../foo');foo.mockImplementation(() => 42);

当你需要模拟某个函数调用返回不同结果时,请使用 mockImplementationOnce 方法

.mockReturnThis()函数来支持链式调用

Mock 名称

可以为你的 Mock 函数命名,该名字会替代 jest.fn() 在单元测试的错误输出中出现。 用这个方法你就可以在单元测试输出日志中快速找到你定义的 Mock 函数

const myMockFn = jest
  .fn()
  .mockReturnValue('default')
  .mockImplementation((scalar) => 42 + scalar)
  .mockName('add42');
复制代码

Enzyme

nzyme 来自 airbnb 公司,是一个用于 React 的 JavaScript 测试工具,方便你判断、操纵和历遍 React Components 输出。Enzyme 的 API 通过模仿 jQuery 的 API ,使得 DOM 操作和历遍很灵活、直观。Enzyme 兼容所有的主要测试运行器和判断库。

安装与配置

  • npm install --save-dev enzyme
  • 安装 Enzyme Adapter 来对应 React 的版本 npm install --save-dev enzyme-adapter-react-16

渲染方式

shallow 浅渲染

返回组件的浅渲染,对官方 shallow rendering 进行封装。浅渲染 作用就是:它仅仅会渲染至虚拟 dom,不会返回真实的 dom 节点,这个对测试性能有极大的提升。shallow 只渲染当前组件,只能能对当前组件做断言

render 静态渲染

将 React 组件渲染成静态的 HTML 字符串,然后使用 Cheerio 这个库解析这段字符串,并返回一个 Cheerio 的实例对象,可以用来分析组件的 html 结构,对于 snapshot 使用 render 比较合适

mount 完全渲染

将组件渲染加载成一个真实的 DOM 节点,用来测试 DOM API 的交互和组件的生命周期,用到了 jsdom 来模拟浏览器环境

常用 API

.simulate(event, mock):用来模拟事件触发,event 为事件名称,mock 为一个 event object

.instance():返回测试组件的实例

.find(selector):根据选择器查找节点,selector 可以是 CSS 中的选择器,也可以是组件的构造函数,以及组件的 display name 等

.get(index):返回指定位置的子组件的 DOM 节点

.at(index):返回指定位置的子组件

.first():返回第一个子组件

.last():返回最后一个子组件

.type():返回当前组件的类型

.contains(nodeOrNodes):当前对象是否包含参数重点 node,参数类型为 react 对象或对象数组

.text():返回当前组件的文本内容

.html():返回当前组件的 HTML 代码形式

.props():返回根组件的所有属性

.prop(key):返回根组件的指定属性

.state([key]):返回根组件的状态

.setState(nextState):设置根组件的状态

.setProps(nextProps):设置根组件的属性

仅执行一个测试用例

{566972AC-8FA7-6FC2-032A-4F3724648DD2}.jpg

jest单测会遇到的问题

jest 单测运行 ReferenceError: Cannot access ‘xxx‘ before initialization : stackoverflow.com/questions/6…

参考

JEST 文档
Enzyme 文档

juejin.cn/post/684490…

zhuanlan.zhihu.com/p/469999927

www.jianshu.com/p/1bef70cfe…

zhuanlan.zhihu.com/p/28247899

juejin.cn/post/702782…

juejin.cn/post/699065…