单元测试

JavaScript/前端
299
0
0
2024-04-01

测试目的

测试的目的是为了带给我们带来强大的代码信心,如果把测试初衷忘掉,会很容易掉入测试代码细节的陷阱。一旦关注点不是代码的信心,而是测试代码细节,那么测试用例会变得非常脆弱,难以维护。

代码信心的体现

  1. 测试可以确保得到预期的结果
  2. 作为现有代码行为的描述
  3. 促使开发者写可测试的代码,可测试的代码可读性会更高
  4. 如果依赖的组件有修改,受影响的组件能在测试中发现错误

测试内容

什么是细节?使用你代码的人不会用到、看到、知道的东西。

那谁才是我们代码的用户呢?第一种就是跟页面交互的真实用户,第二种则是使用这些代码的开发者。对 React Component 组件来说,用户可以分为 End User 和 Developer,我们只需要关注这两者即可 。

接下来的问题就是:我们代码中的哪部分是这两类用户会看到、用到和知道的呢?

对 End User 来说,他们只会和 render 函数里的内容有交互,而 Developer 则会和组件传入的 Props 有交互。所以,我们的测试用例只和传入的 Props 以及输出内容的 render 函数进行交互就够了。

  1. 不做单测
  2. 百川企业主页、百川临时活动
  3. 研发、测试使用的项目
  4. 官网项目、UI类项目
  5. 创新探索类项目
  6. 需要团队评审
  7. 紧急需求,卡时间需求
  8. 需求业务逻辑变更太快的需求
  9. 做单测(Props 以及 Render 交互),推荐单测之前已评审过测试用例
  10. 公共类
  11. 公共组件
  12. 公共方法
  13. 公共自定义hook
  14. 需求功能类
  15. 组件的Props(组件的入参是否在正确的场景或时机被正确的使用或调用)
  16. Render 交互(基于用户的交互判断关键节点的流程是否在正确的时机被正确执行)

需提前了解的内容

  1. Jest测试框架:https://jestjs.io/zh-Hans/docs/getting-started
  2. Testing Library React 测试工具库:https://testing-library.com/docs/

安装包

为抹平单测环境差异,节省各业务线接入成本,现提供单测接入脚手架工具,该工具包基于jest@29.6.3 @testing-library/react@12.1.5

npm i -D @liepin/js-jest4r-fe@beta

若在安装的过程报错,注意以下可能存在的问题:

  1. typescript版本问题,比如typescript版本过低,@typescript-eslint 相关包版本过低
  2. peer依赖版本不匹配问题

配置单测环境

V6工程配置

V6工程目录下执行

npx jest4r setup4project

这将完成以下工作

  1. 配置工程 jest.config.js
  2. 添加测试脚本到 v6 package.json 中
  3. 更新babel配置,支持单测编译环境
  4. 更新 eslint 配置,支持单测代码检查
  5. 安装单测环境依赖包
cnpm包配置

cnpm包目录下执行

npx jest4r setup4package

这将完成以下工作

  1. 配置cnpm包下的 jest.config.js 文件
  2. 添加测试脚本到 cnpm包下的 package.json 中
  3. 更新babel配置,支持单测编译环境,默认检测 babel.config.js 文件,如果存在babel配置文件,文件名需要保持一致(文件名规则对齐V6工程命名规则)
  4. 更新 eslint 配置,支持单测代码检查,默认检测 .eslintrc.js 文件,如果存在eslint配置文件,文件名需要保持一致(文件名规则对齐V6工程命名规则)
  5. 更新prettier配置,代码格式化,默认检测 .prettierrc.js 文件,如果存在prettier配置文件,文件名需要保持一致(文件名规则对齐V6工程命名规则)
  6. 安装单测环境依赖包
配置jest.config.js

@liepin/js-jest4r-fe 提供的默认配置如下,该预设内容在 @liepin/js-jest4r-fe/jest-preset.js 中

/*
 * For a detailed explanation regarding each configuration property and type check, visit:
 * https://jestjs.io/docs/configuration
 * https://zhuanlan.zhihu.com/p/535048414 详细字段作用说明
 */

module.exports = {
  // 预设配置
  preset: '@liepin/js-jest4r-fe',
  // 生成覆盖率报告所存放的目录,苍穹会根据该目录配置读取覆盖率报告
  coverageDirectory: '<rootDir>/tests/coverage-jest'
}

由于不同的工程的业务方向不同,导致每个工程或cnpm包都有自己的第三方依赖包集合,因此针对第三方包的编译规则有所不同,这里需要根据工程情况自行覆盖预设配置,比如:

/*
 * For a detailed explanation regarding each configuration property and type check, visit:
 * https://jestjs.io/docs/configuration
 * https://zhuanlan.zhihu.com/p/535048414 详细字段配置描述
 */

// 默认设置
const compileModules = ['@babel', '@ant-design', '@liepin', 'uuid'];

// 默认设置
const transformIgnorePatterns = [
  // Ignore modules without es dir.
  // Update: @babel/runtime should also be transformed
  // 匹配以 /node_modules/ 开头,后面跟着一个不以 compileModules 开头的目录名,然后再跟着一个不以 es/ 开头的目录名。
  `/node_modules/(?!${compileModules.join('|')})[^/]+?/(?!(es)/)`
];

module.exports = {
  // 必须配置
  preset: '@liepin/js-jest4r-fe',
  // 生成覆盖率报告所存放的目录,苍穹会根据该目录配置读取覆盖率报告
  coverageDirectory: '<rootDir>/tests/coverage-jest',
  
  // 非必须配置
  // transformIgnorePatterns这个配置项配置的是将一些文件忽略,不使用transform的转换器进行转换
  // 如果遇到第三方包报错,可优先确认此配置
  transformIgnorePatterns,
  
};
配置babel环境
module.exports = (api) => {
  const isTest = api.env('test');
  if (!isTest) {
    return {
      presets: ['@tools/babel-preset']
    };
  }
  return {
    env: {
      test: {
        presets: [
          ['@babel/preset-env', { targets: { node: 'current' } }],
          '@babel/preset-react',
          '@babel/preset-typescript'
        ],
        plugins: [
          [
            '@babel/plugin-proposal-decorators',
            {
              legacy: true
            }
          ],
          [
            '@babel/plugin-proposal-class-properties',
            {
              loose: true
            }
          ],
          // 移除组件的 <style jsx></style> 标签内容
          [
            '@liepin/js-jest4r-fe/config/babel-plugin-remove-element',
            {
              elements: ['style']
            }
          ]
        ]
      }
    }
  };
};
TS类型提示

如果在tsconfig.json配置中设置了 typeRoots 字段,需保证该字段包含 node_modules/@types,方可提供完整类型提示

    "typeRoots": ["node_modules/@types", "其他类型文件位置"]
已安装工具库
  1. @testing-library/react 是一个用于测试 React 组件的 JavaScript 测试工具库,它提供了一组简单且易于使用的 API,可以帮助你编写可读性高、可维护性强的测试代码。
  2. @testing-library/jest-dom 是一个用于增强 Jest 测试框架的库,它提供了一组用于 DOM 断言的定制化匹配器和工具函数。当需要基于DOM元素进行匹配测试时,推荐引入@testing-library/jest-dom。
  3. @testing-library/user-event 是一个用于模拟用户事件的 JavaScript 库。它提供了一组简单易用的 API,可以模拟用户在浏览器中的各种交互行为,如点击、输入、选择等,用于帮助开发者编写更全面、准确的测试用例。
  4. @testing-library/react-hooks 是一个用于测试 React Hooks 的工具库。它提供了一组用于编写可靠和可维护的测试的实用函数和工具。
  5. jest-location-mock 用于在 Jest 测试中模拟浏览器window.location对象的库。它的主要作用是使你能够在测试代码中模拟修改和访问window.location的行为,而无需实际在浏览器环境中执行。(已默认引入,不需要手动再次引入)
  6. jest-canvas-mock 用于在测试环境中模拟 HTML5 Canvas。它的主要作用是使你能够在测试中对使用了 Canvas 的代码进行断言和验证,而无需实际渲染真实的画布。(已默认引入,不需要手动再次引入)

文件命名规则

  1. 在需要测试的目录下新建 __tests__ 目录
  2. 根据要测试的内容命名测试文件
  3. 对于组件文件,可以使用组件的名称作为文件名,并在文件名后面添加 .spec.tsx 后缀。例如,如果组件的名称是 FormPublishBtn,则文件名可以是 FormPublishBtn.spec.tsx
  4. 对于包含多个组件的文件,可以使用文件名作为文件名,并在文件名后面添加 .spec.tsx 后缀。例如,如果文件名是 Form.tsx,则文件名可以是 Form.spec.tsx
  5. 对于组件下并不复杂的子组件,可考虑在父组件的测试文件中进行测试,而不需要单独的测试文件。
  6. 对于层级较深的组件,需在单测文件中增加注释,说明测试组件所在的路径

运行单测

单测执行

安装 VSCode Jest 运行插件

名称: Jest Runner ID: firsttris.vscode-jest-runner 说明: Simple way to run or debug a single (or multiple) tests from context-menu 版本: 0.4.69 发布者: firsttris VS Marketplace 链接: https://marketplace.visualstudio.com/items?itemName=firsttris.vscode-jest-runner
npm run test

基于测试结果生成测试报告如下:

注意:需关注控制台的警告或者报错信息,及时修复

单测工具
  1. screen.debug()

在控制台中打印出当前的 DOM Tree。

describe("screen.debug", () => {
  test("Print the current DOM Tree in the console", () => {
    render(<SomeComponent />);

    screen.debug(); // debug document
    screen.debug(screen.getByText("test")); // debug single element
    screen.debug(screen.getAllByText("multi-test")) // debug multiple elements
  });
});
  1. screen.logTestingPlaygroundURL()

会在控制台中打印一个链接,点开它就可以在 testing-playground 中交互的调试。

testing-playground 是一个交互式的沙盒 (网页),你可以在其中用鼠标选择 DOM 节点,testing-playground 会告诉你查找此 DOM 节点的最佳查询规则。

describe("screen.logTestingPlaygroundURL", () => {
  test("logTestingPlaygroundURL", () => {
    render(<SomeComponent />);

    // 控制台中会打印出来访问链接
    screen.logTestingPlaygroundURL(); // log entire document to testing-playground
    screen.logTestingPlaygroundURL(screen.getByText('test')); // log a single element
  });
});

testing-playground 展示如下

  1. 断点调试,借助 Jest Runner 插件

添加断点

启动调试模式

开始调试

单测覆盖率

覆盖率收集来源

  // An array of glob patterns indicating a set of files for which coverage information should be collected
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.{spec,test}.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/stories/**',
    '!src/**/__tests__/**'
  ]

覆盖率指标(试运行)

  1. @liepin 下公共包:60%
  2. V6项目下的公共方法(common目录)、公共组件(components目录)、公共自定义hook(hooks目录):55%
  3. V6项目下的业务代码(views目录):50%
  4. 需求增量代码:50%
npm run test:coverage

  • Statements: 语句覆盖率,执行到每个语句;
  • Branches: 分支覆盖率,执行到每个 if 代码块;
  • Functions: 函数覆盖率,调用到程序中的每一个函数;
  • Lines: 行覆盖率,执行到程序中的每一行。

注意:

  1. 测试覆盖率可以让我们自检路径覆盖、判定覆盖及语句覆盖,指导我们更好的提前发现代码中的问题
  2. 覆盖率数据只能代表你测试过哪些代码,不能代表你是否测试好这些代码。
  3. 不要过于相信覆盖率数据以及只拿语句覆盖率(行覆盖率)来做单测的好坏的评分。
  4. 分支覆盖率 > 判定覆盖 > 语句覆盖

单测数据统计

2023Q4单测收益统计表

为方便统计,需在miigo需求对应的任务中分类录入

  1. 开发时间
  2. 联调时间
  3. 单测时间

苍穹发布

苍穹执行单测的前置条件
  1. 项目中引用了jest,并完成jest相关配置 (目前)
  2. 项目中使用jest编写了测试用例
  3. 确保项目执行下述jest命令无问题 (生成报告,指定报告位置,生成json数据,指定json数据输出文件)
  4. jest --coverage --coverageDirectory=cq-coverage --json --outputFile=coverage.json
使用苍穹发布
一、苍穹主动发布
  1. 苍穹中搜索要发布的项目,点击更多,选择发布单元测试(目前测试环境单测和打包中心单测是等效的)

  1. 点击unitest插件执行报告查看结果

或在任务管理中,进入单元测试报告界面查看

二、行云流水线发布任务时自动执行

当行云流水线执行项目发布时,根据行云的门禁配置会自动执行项目的单元测试

和苍穹主动执行单测的区别是,苍穹主动执行单测只会执行单元测试,不执行项目发布,而行云会同时执行项目发布和单测

示例

选择元素的方式
  1. getBy* 用于正常的查询元素,找不到元素会报错
  2. queryBy* 用于查询我们希望它不存在的元素并进行断言,找不到元素返回null
  3. findBy* 用于查询需要等待的异步元素,不需要使用waitFor包裹
  4. 批量选择:getAllBy* queryAllBy* findAllBy*

推荐使用 *ByRole 来获取元素,参考官方文档 Which query should I use?

// 假如现在我们有这样的 DOM:
// <button><span>Hello</span> <span>World</span></button>

screen.getByText(/hello world/i)
// ❌ 报错:
// Unable to find an element with the text: /hello world/i. This could be
// because the text is broken up by multiple elements. In this case, you can
// provide a function for your text matcher to make your matcher more flexible.

screen.getByRole('button', {name: /hello world/i})
// ✅ 成功!

其实大家不使用 *ByRole 做查询的原因之一是因为不熟悉在元素上的隐式 Role。这里大家可以参考 MDN,MDN 上有写这些元素上的 Role List,或者参考 “单测工具” 一节

React 组件测试
import { render, screen } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';
import FormCard from './index';

describe('预发职位', () => {
  test('预发职位初始化展示', () => {
    const { getByText } = render(<FormCard />);
    
    // 通过screen.debug()查看渲染出来的HTML DOM树是什么样的,在写测试代码前,先通过debug查看当前页面中可见的元素,再开始查询元素,这会有助于编写测试代码.
    screen.debug();

    expect(getByText('发 布')).toBeInTheDocument();
  });
});
React Hook测试
useCounter.ts


import { useState } from "react";

export interface Options {
  min?: number;
  max?: number;
}

export type ValueParam = number | ((c: number) => number);

function getTargetValue(val: number, options: Options = {}) {
  const { min, max } = options;
  let target = val;
  if (typeof max === "number") {
    target = Math.min(max, target);
  }
  if (typeof min === "number") {
    target = Math.max(min, target);
  }
  return target;
}

function useCounter(initialValue = 0, options: Options = {}) {
  const { min, max } = options;

  const [current, setCurrent] = useState(() => {
    return getTargetValue(initialValue, {
      min,
      max,
    });
  });

  const setValue = (value: ValueParam) => {
    setCurrent((c) => {
      const target = typeof value === "number" ? value : value(c);
      return getTargetValue(target, {
        max,
        min,
      });
    });
  };

  const inc = (delta = 1) => {
    setValue((c) => c + delta);
  };

  const dec = (delta = 1) => {
    setValue((c) => c - delta);
  };

  const set = (value: ValueParam) => {
    setValue(value);
  };

  const reset = () => {
    setValue(initialValue);
  };

  return [
    current,
    {
      inc,
      dec,
      set,
      reset,
    },
  ] as const;
}

export default useCounter;
测试文件
import { renderHook } from "@testing-library/react-hooks";
import useCounter from "@hooks/useCounter";
import { act } from "@testing-library/react";

describe("useCounter", () => {
  test("可以做加法", () => {
    const { result } = renderHook(() => useCounter(0));

    act(() => {
      result.current[1].inc(1);
    });

    expect(result.current[0]).toEqual(1);
  });

  test("可以做减法", () => {
    const { result } = renderHook(() => useCounter(0));

    act(() => {
      result.current[1].dec(1);
    });

    expect(result.current[0]).toEqual(-1);
  });

  test("可以设置值", () => {
    const { result } = renderHook(() => useCounter(0));

    act(() => {
      result.current[1].inc(10);
    });

    expect(result.current[0]).toEqual(10);
  });

  test("可以重置值", () => {
    const { result } = renderHook(() => useCounter(0));

    act(() => {
      result.current[1].inc(1);
      result.current[1].reset();
    });

    expect(result.current[0]).toEqual(0);
  });

  test("可以使用最大值", () => {
    const { result } = renderHook(() => useCounter(100, { max: 10 }));

    expect(result.current[0]).toEqual(10);
  });

  test("可以使用最小值", () => {
    const { result } = renderHook(() => useCounter(0, { min: 10 }));

    expect(result.current[0]).toEqual(10);
  });
});
React 接口测试(NEW)

基于 mswjs Mock Http 请求,升级 @liepin/js-jest4r-fe 到最新 beta 版本即可支持,需重新执行对应的单测环境配置命令。手动安装需安装 msw@1.3.2的版本,msw@2.x版本要求nodejs@18 及以上、typescript@4.7及以上

  1. 在 __tests__ 目录下创建 mockServer 文件夹
  2. 创建 mockServer/handlers.ts
import { rest } from 'msw';
import { lptGatewayDomain } from '../../Header/services/gatewayAxios';
import { userInfo } from './constants';

export const handlers = [
  /** 获取用户信息 */
  rest.post(`${lptGatewayDomain}/api/com.liepin.usere.bpc.get-user-base`, (_req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        flag: 1,
        data: userInfo,
      })
    );
  })
];
  1. 按需创建 mockServer/constants.ts
export const userInfo = {
  groupId: 0,
  usereAuthStatus: '1',
  ecompServiceStatus: '1',
  ecompName: '飞署柱般(昌都)有限责任公司',
  ecompAuditFlagCode: '1',
  photo: '//image0.lietou-static.com/normal/5f8f986bdfb13a7dee342f2108u.jpg',
  showZctArticle: false,
  avatarDecor: '//image0.lietou-static.com/img/605d9761e98efa22cd72659606u.png',
  ecompFirstShortName: '飞署柱般',
  vipAccountRoleCode: '2',
  ecompRootId: '7853689',
  ecompId: 7853689,
  creditScore: 12,
  roleKindCode: '2',
  ejobManagerCode: '1',
  hasPhoneNum: '1',
  accountUsereIdEncode: 'cf9b21c458e54187ddc3763ececc39ac',
  name: 'xxxx',
  lastLoginTime: '2023-12-12',
};
  1. 创建 mockServer/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
  1. 创建 mockServer/setupTests.ts
import { server } from './server';

beforeAll(() => server.listen());

afterEach(() => server.resetHandlers());

afterAll(() => server.close());
  1. 编写接口单测
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';
import { rest } from 'msw';
import { Cookie } from '@liepin/js-bcore-pc';
import BHeader from '../Header';
import { EHeaderType } from '../Header/model';
import { server } from './mockServer/server';
import { userInfo } from './mockServer/constants';
import './mockServer/setupTests';
import { lptGatewayDomain } from '../Header/services/gatewayAxios';

describe('BHeader 导航', () => {
  test('有logo 姓名的 static 导航类型', async () => {
    /** 渲染组件会调用获取用户信息接口 */
    render(<BHeader type={EHeaderType.STATIC} />);

    await waitFor(() => {
      expect(screen.getAllByText('xxxx').length).toBe(2);
      expect(screen.getByText('安全退出')).toBeInTheDocument();
    });
  });

  test('非管理员不展示企业管理Tab', async () => {
    /** 覆盖预定义的接口 */
    server.use(
      rest.post(`${lptGatewayDomain}/api/com.liepin.usere.bpc.get-user-base`, (_req, res, ctx) => {
        return res(
          ctx.status(200),
          ctx.json({
            ...userInfo,
            roleKindCode: '0',
          })
        );
      })
    );

    const { container } = render(<BHeader type={EHeaderType.COMPLETE} selectTab="home" />);

    await waitFor(() => {
      expect(container.querySelector('[data-selector="company"]')).not.toBeInTheDocument();
    });
  });
});
  1. 另外一种 API 方法 Mock
import * as wxApis from '../../BLoginModal/services/wxApi';

// 这种方式设计到代码细节问题需避免使用,如果方法名 getWXSanqrAjax 变更将导致测试用例执行失败
jest.spyOn(wxApis, 'getWXSanqrAjax').mockResolvedValue({
  flag: 1,
  data: {
    expireSeconds: 120,
    qrKey: 'd9dcf50fed64deb45d87d57e78d62062',
    qrLink:
      'https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=gQHi7jwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAyR0dOZ1FGdHhiT2Uxa0h6TWhCY1EAAgSzYnBlAwR4AAAA',
    qrUrl: 'http://weixin.qq.com/q/02GGNgQFtxbOe1kHzMhBcQ',
  },
});
React Mobx Store 测试
简单场景


import { render } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';
import FormCard from './index';
import { store } from '../../store'

describe('预发职位', () => {
  test('预发职位初始化展示', () => {
    // 模拟store方法,注意这种方法会涉及到代码细节问题,应避免使用,这里只做示意
    jest.spyOn(store, 'handleFormInstance');

    const { getByText } = render(<FormCard />);

    expect(getByText('发 布')).toBeInTheDocument();
    expect(store.handleFormInstance).toBeCalledTimes(1);
  });
});
复杂场景


import { render } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import { saveAgreementLog } from '@common/js/utils/safetyAgreement';
import { asyncThrowError } from '@common/js/utils';
import { store } from '../../store';
import FormPublishBtn from './index';

// 这种mock方式需要团队内评审,因为当store中新增方法时,此处mock也需要同步修改,否则可能导致报错:store下方法找不到
jest.mock('../../store', () => {
  const store = {
    formInstance: {
      validateFields: jest.fn().mockResolvedValue(Promise.resolve({})),
      scrollToField: jest.fn()
    },
    submitFormData: jest.fn()
  };
  return {
    __esModule: true,
    store,
    default: () => store
  };
});

jest.mock('@common/js/utils/safetyAgreement', () => {
  return {
    saveAgreementLog: jest.fn()
  };
});

jest.mock('@common/js/utils', () => ({
  asyncThrowError: jest.fn()
}));

describe('预发职位-发布操作', () => {
  test('展示发布按钮,提交表单', () => {
    const { getByText } = render(<FormPublishBtn />);

    expect(getByText('发 布')).toBeInTheDocument();
  });

  test('点击发布按钮', async () => {
    const user = userEvent.setup();

    const { getByText } = render(<FormPublishBtn />);

    const button = getByText('发 布');

    await user.click(button);

    expect(store.formInstance!.validateFields).toHaveBeenCalled();
    expect(saveAgreementLog).toHaveBeenCalledWith({
      sceneTypeCode: 'S0001',
      confirmMode: 1,
      agreementTypes: ['A0003']
    })
    expect(store.submitFormData).toHaveBeenCalled();
  });

  test('校验失败', async () => {
    const user = userEvent.setup();

    (store.formInstance!.validateFields as jest.Mock).mockRejectedValueOnce({
      errorFields: [{ name: ['errorField'] }]
    });

    const { getByText } = render(<FormPublishBtn />);
    const button = getByText('发 布');

    await user.click(button);

    expect(asyncThrowError).toHaveBeenCalled();
    expect(store.formInstance!.scrollToField).toHaveBeenCalledWith('errorField', {
      behavior: 'smooth'
    });
  });
});
Antd Form 组件测试

重点在于对 Form.useForm() 的处理,其返回值包含了Form组件数据管理相关方法。

import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { Form } from 'antd';
import '@testing-library/jest-dom';
import { renderHook } from '@testing-library/react-hooks';
import userEvent from '@testing-library/user-event';
import moment from 'moment';
import DuomianMobileModal from '../LPTVideoInterview/compontents/DuomianMobileModal';
import * as apis from '../LPTVideoInterview/service';

describe('视频面试弹窗', () => {
  beforeEach(() => {
    // 接口mock参考接口测试一节内容介绍,这里只是快速示意
    jest.spyOn(apis, 'createVideoInterviewAjax').mockResolvedValue({
      flag: 1,
      data: {
        usercName: '招工组',
        compName: '猎聘测测',
        curJobPosition: '测试工程师',
        portrait: '',
        ejobId: '1',
        oppositeImId: '',
      },
    });

    jest.spyOn(apis, 'createInterviewAjax').mockResolvedValue({
      flag: 1,
      data: null,
    });
  });

  test('打开视频面试弹窗', async () => {
    render(<DuomianMobileModal />);

    expect(apis.createVideoInterviewAjax).toHaveBeenCalledTimes(1);
    await waitFor(() => {
      expect(screen.getByText('发送面试邀请')).toBeInTheDocument();
    });
  });

  test('表单提交', async () => {
    const user = userEvent.setup();

    // 创建一个真实的表单数据管理实例,由于 useForm 是一个hook方法,因此这里借助 renderHook 方法,详见React Hook 测试
    const { result } = renderHook(() => Form.useForm());

    jest.spyOn(Form, 'useForm').mockImplementation(() => result.current);

    render(<DuomianMobileModal />);

    let btnEle;
    await waitFor(() => {
      btnEle = screen.getByText('发送面试邀请');
    });

    result.current[0].setFieldsValue({
      job: '1',
      date: moment('2023-09-23'),
      time: moment('2023-09-23 8:30'),
    });

    await user.click(btnEle);

    expect(btnEle).toBeInTheDocument();
    expect(apis.createInterviewAjax).toHaveBeenCalledWith({
      oppositeImId: '',
      inputInfo: JSON.stringify({
        ejobId: '1',
        interviewTime: `${moment('2023-09-23').format('YYYYMMDD')}${moment('2023-09-23 8:30').format('HHmm')}00`,
      }),
    });
  });
});
快照测试

快照测试的基本理念:先保存一份副本文件,下次测试时把当前输出和上次副本文件对比就知道此次改动是否破坏了某些东西。

UI快照

应避免UI快照过大,不要无脑地记录整个组件的快照,特别是有别的 UI 组件参与其中的时候(比如antd多层级组件,将会使快照文件过于庞大,另外快照中杂揉了 antd 的 DOM 结构后,快照变得非常难读)。

解决方案是:不要把无关的 DOM 记录到快照里,只记录我们想要的DOM结构就好

const Title: FC<Props> = (props) => {
  const { title, type } = props;

  return (
    <Row style={styleMapper[type]}>
      <Col>
        第一个 Col
      </Col>
      <Col>
        <div>{title}</div>
      </Col>
    </Row>
  )
};
describe("Title", () => {
  test("可以正确渲染大字", () => {
    const { getByText } = render(<Title type="large" title="大字" />);
    const content = getByText('大字');
    expect(content).toMatchSnapshot();
  });

  test("可以正确渲染小字", () => {
    const { getByText } = render(<Title type="small" title="小字" />);
    const content = getByText('小字');
    expect(content).toMatchSnapshot();
  });
});
exports[`Title 可以正确渲染大字 1`] = `
<div>
  大字
</div>
`;

exports[`Title 可以正确渲染小字 1`] = `
<div>
  小字
</div>
`;
Mock 定时器
// after1000ms.ts
type AnyFunction = (...args: any[]) => any;

const after1000ms = (callback?: AnyFunction) => {
  console.log("准备计时");
  setTimeout(() => {
    console.log("午时已到");
    callback && callback();
  }, 1000);
};

export default after1000ms;
import after1000ms from "utils/after1000ms";

describe("after1000ms", () => {
  beforeAll(() => {
    jest.useFakeTimers();
  });

  test("可以在 1000ms 后自动执行函数", () => {
    jest.spyOn(global, "setTimeout");
    const callback = jest.fn();
    
    expect(callback).not.toHaveBeenCalled();

    after1000ms(callback);

    jest.runAllTimers();

    expect(callback).toHaveBeenCalled();
    expect(setTimeout).toHaveBeenCalledTimes(1);
    expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000);
  });
});
Mock 网页地址
describe("网页地址的Mock", () => {
  test("可以获取当前网址的查询参数对象", () => {
    // 使用 jest-location-mock (本包配置中已配置)。 这种方法会监听 window.location.assign,通过它来改变网页地址。
    window.location.assign('https://www.baidu.com?a=1&b=2');

    expect(window.location.search).toEqual("?a=1&b=2");
    expect(getSearchObj()).toEqual({
      a: "1",
      b: "2",
    });
  });

  test("空参数返回空", () => {
    window.location.assign('https://www.baidu.com');

    expect(window.location.search).toEqual("");
    expect(getSearchObj()).toEqual({});
  });
});

常见问题

  1. TypeScript 提示 axios.get 是没有 jest 这些类型的,所以会报以下错误:
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
  const users = [{name: 'Bob'}];
  const resp = {data: users};
  
  // TS2339: Property 'mockResolveValues' does not exist on type '  >(url: string, config?: AxiosRequestConfig | undefined) => Promise '.
  axios.get.mockResolvedValue(resp);

  // 你也可以使用下面这样的方式:
  // axios.get.mockImplementation(() => Promise.resolve(resp))

  return Users.all().then(data => expect(data).toEqual(users));
});

解决方法一:推荐

jest.spyOn(axios, 'get').mockResolvedValue(resp);

// 你也可以使用下面这样的方式:
// jest.spyOn(axios, 'get').mockImplementation(() => Promise.resolve(resp))

解决方法二

import { mocked } from 'jest-mock';

const mockedGet = mocked(axios.get); // 带上 jest 的类型提示
mockedGet.mockResolvedValue(resp); // 含有 jest 的类型提示
  1. jest 单独运行每一个测试用例都可以通过测试,但是当运行一组测试用例时,会出现报错

这种情况通常是由于在一组测试用例中,前一个测试用例没有正确地清理或重置测试环境,导致后续的测试无法找到期望的元素或状态。为了解决这个问题,可以尝试从以下几点入手:

  1. 使用 beforeEach 函数或 beforeAll 函数在每个测试用例开始之前进行初始化设置。这样可以确保每个测试用例都在相同的初始状态下运行,并且没有残留的状态或影响。
  2. 在每个测试用例之后使用 afterEach 函数或 afterAll 函数来清理测试环境。这样可以确保每个测试用例完成后,不会留下任何对后续测试用例有影响的状态。
  3. 确保在每个测试用例中,等待异步操作完成后再进行断言。可以使用 await 关键字或适当的异步测试工具(如 waitFor)来等待异步操作的完成。
  4. 如果测试用例依赖于某些外部资源(例如网络请求),请确保在测试之前和之后进行适当的管理和清理,以确保资源的正确使用和释放。
  5. 检查测试用例代码中是否存在任何可能导致测试环境污染或干扰的因素,例如全局状态、全局变量等。尽量将测试用例代码进行封装和隔离,以确保每个测试的独立性。
  6. act 控制台警告
console.error node_modules/react-dom/cjs/react-dom.development.js:530
  Warning: An update to UsernameForm 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 https://fb.me/react-wrap-tests-with-act
      in UsernameForm
import { render, screen, act } from '@testing-library/react';

act 是一个用于处理 React 组件的异步更新和副作用的工具函数,它的主要作用是确保在测试中正确地触发和等待组件更新。

act 的使用场景如下:

  • 当你在测试中进行与 React 组件的交互(例如模拟用户点击、输入等)时,可以使用 act 来确保组件在更新后进行正确的断言。
  • 当你在测试中进行异步操作(例如使用 setTimeoutPromise 等)时,可以使用 act 来等待异步操作完成后再进行断言。
  1. waitFor的错误使用
  2. 用 waitFor 等待 find* 的查询结果
// ❌
const submitButton = await waitFor(() =>
  screen.getByRole('button', {name: /submit/i}),
)

// ✅
const submitButton = await screen.findByRole('button', {name: /submit/i})
  1. 上面两段代码几乎是等价的(find* 其实也是在内部用了 waitFor),但是第二种使用方法更清晰,而且抛出的错误信息会更友好。
  2. 在 waitFor 中使用副作用
// ❌
await waitFor(() => {
  fireEvent.keyDown(input, {key: 'ArrowDown'})
  expect(screen.getAllByRole('listitem')).toHaveLength(3)
})

// ✅
fireEvent.keyDown(input, {key: 'ArrowDown'})
await waitFor(() => {
  expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
  1. waitFor 适用的情况是:在执行的操作和断言之间存在不确定的时间量。因此,callback 可在不确定的时间和频率(在间隔以及 DOM 变化时调用)被调用(或者检查错误)。所以这也意味着你的副作用可能会被多次调用!
  2. 建议:
  3. 把副作用放在 waitFor 回调的外面,回调里只能有断言
  4. waitFor 的 callback 里只放一个断言
  5. 组件内使用 style jsx 报错
import React from 'react';

function Test() {
  return (
    <>
      <span>test 数据</span>
      <style jsx>{`
        .c-highlight {
          color: #ff6400;
          @media print {
            color: inherit;
            background: inherit;
          }

          :global(&.c-keyword-active) {
            background-color: #ff7c2d;
            color: #fff;
          }
        }
      `}</style>
    </>
  );
}

export default Test;

报错如下:

解决方法在配置babel环境一节,需添加对应babel插件