在实际项目中积累的 jest 编写单元测试攻略

1,316 阅读4分钟

为什么要写单元测试,测哪些点?

单元测试是对单个单元或软件组件进行测试的一种软件测试。它的目的是验证每个代码单元是否按预期执行。单元可以是一行代码、一个方法、一个组件。 为什么要编写单元测试呢?我个人认为:单元测试可以将手动验证自动化。代码首次编写的时候,可以验证被测试单元的输出是否符合预期。更重要的价值我认为是体现在后期对于代码的维护上,当修改了相关的功能后,如果能通过单元测试,至少能够保证过往已经实现的功能是不受影响的(这一点单元测试的覆盖率很重要)。 编写单元测试也是有工作量滴。那在业务开发的时候,哪些代码是值得写单元测试的呢?

  1. utils 工具函数或者工具类。在业务开发的时候,通常会将一些比较基础、常用的能力抽出来作为一个工具函数,以便于后面复用,比如:拼接 URL、对于数字的格式化处理等。

  2. 基础的组件。比如,封装的 Button,NavHeader 等纯 UI 组件。

  3. 抽出来的自定义 Hooks

  4. 核心,又需要频繁调整的流程。(这类单元测试通常也不太好写,因为在业务代码中,这类流程通常都跟业务是相关的,所以可能需要开发人员 mock 很多方法,才能得以让单元测试能够顺利的跑起来)。

以上除了最后一点,其他列举出来的点其实都有一个共同的特性:即项目中可能有多个地方是使用了这些能力的。这就是值得写单元测试的点,因为如果某次改动出现 Bug,将会造成多个地方受到影响。这种代码写上单元测试,在修改功能的时候将会让开发人员放心很多。而且有单元测试的话,也可以让其他开发人员看看测试用例就能明白这个方法具体是干啥的,怎么使用。

jest 配置文件

jest 配置 - 官方文档 可以在 package.json目录下添加 “jest” 字段用于设置 jest 的配置,也可以将配置拎成单独的配置文件,在启动的时候使用 --config=path来设置配置文件,在这里我采用的是第二种方式。

配置字段类型功能
setupFilesstring[]接收一组文件路径,这些文件将在测试文件被执行之前执行。
setupFilesAfterEnvstring[]和 setupFiles 类似,但是比 setuoFiles 后执行。根据官网上的说明,setupFiles 在环境中安装测试框架之前执行,而 setupFilesAfterEnv 测试框架安装之后执行。推荐用法:在 setupFiles 中设置环境变量,在 setupFilesAfterEnv 中设置 jest 测试环境相关的东西。github.com/facebook/je…
testMatchstring[]用来匹配单元测试执行哪些文件。默认规则是:[ "**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)" ]
比如,我希望只匹配 __test__目录下的含有 .test 的文件名,可以配置:
**/__tests__/**/*.test.[jt]s?(x)
moduleNameMapperObject用正则表达式来匹配模块名,将被测试文件中匹配到的模块名映射到文件。
比如项目中设置了别名:
'^@components/(.*)$': ['<rootDir>/client/components/$1']
比如配置解析 css 且支持 css modules:
'\\\\.(css&#124;scss)$': 'identity-obj-proxy'
transformIgnorePatternsstring[]用来指定哪些文件不需要被 transform 转换的正则表达式,默认为:["/node_modules/", "\\\\.pnp\\\\.[^\\\\\\/]+$"]
默认情况下,node_modules 目录下的文件不会被 transform。所以,如果项目中如果有引用的三方模块是没有编译的,需要在这里进行配置。如:react-native
["node_modules/(?!(react-native)/)"]
transformObject正则匹配文件名,然后配置转换器。默认规则:
{"\\\\.[jt]sx?$": "babel-jest"}

测试函数

测试函数比较简单。一般来说都是去测试函数的返回值是否符合预期,或是函数直接修改的入参,去判断入参经过函数处理后是否符合预期。 比如我们项目中有一个比较版本号的方法,它的功能就是比较两个版本字符串,如果相等则返回 0,参数1大于参数2 返回 1,参数 1 小于 参数 2 则返回 -1。

it('versionCompare', () => {
  expect(versionCompare('1.1.0', '1.1.0')).toBe(0);
  expect(versionCompare('1.1.0', '1.1.1')).toBe(-1);
  expect(versionCompare('1.1.2', '1.1.1')).toBe(1);
  expect(versionCompare('1.1.2', '1.1.1.1')).toBe(1);
  expect(versionCompare('1.1.2', '1.1.2.1')).toBe(-1);
});

toBe断言类似 Javascript 中的 ===。Jest 有着很丰富的断言库:断言-api

测试 组件快照、Dom

每当想要确保组件的 UI 不会有意外的改变,快照测试是非常有用的工具。 具体的做法是,组件开发完成后,为组件生成一个快照文件,后续再执行单元测试的时候,jest 会对比两个快照是否匹配,不匹配的话测试将不通过,此时开发人员可以对比不匹配的部分是否符合预期。 GeneralPay.test.js.snap:

exports[`GeneralPay normal 2`] = `
<DocumentFragment>
  <div
    class="ipc-mask"
    style="display: none; justify-content: center; align-items: center; background: rgba(0, 0, 0, 0); opacity: 0;"
  />
  <div
    class="ipc-popup"
    style="display: unset;"
  >
    <div
      class="ipc-mask"
      style="background: rgba(0, 0, 0, 0.55); opacity: 1; display: unset;"
    >
 ...   

react 提供了 react-test-render用于测试组件快照。

react-test-render是 react 提供的专用于测试的渲染器,其中重新实现了与 react 渲染相关的一系列方法,摒除了 dom 操作,将 react 组件渲染成了纯纯的 Javascript 对象。注意版本,react-test-render 的版本需要和 react、react-dom 的版本保持一致

这里不继续介绍 react-test-render的用法,因为更加推荐直接使用 dom 测试库,即可以输出组件快照,又可以模拟真实的 dom 环境做触发事件等操作。能够更全面的测试组件。 测试 Dom 主要有两个库可以选择:enzymetesting-library 这两库在理念上有很大的区别。 enzyme 提倡测试组件本身,在测试过程中可以拿到组件实例并且拿到挂载在组件实例上的 state、props、组件自身的方法等。testing-library 则更关注于测试组件的产物而并不关心组件的细节。 这里直接推荐使用 @testing-library

@testing-library/react

我们工作中大多使用的是 React,所以可以直接使用 testing-library 提供的 @testing-llibrary/react来进行测试,它是基于 @testing-library/domreact-dom/test-utils实现的。 前者提供了在 nodeJs 环境中渲染 dom 树、查询 dom、触发 event 的能力(实际上是依赖 jsdom),后者是 React 官方专门提供的用于测试 React 程序的工具方法,在这里可以看到 React 文档对于它的介绍

版本说明
^13.0.0只支持 react 18 及其以上的版本
小于 12.1.5支持 React 17 以下版本

React 17 版本建议就使用 ^12.1.5版本

如果使用的 是 Jest 28 或以上的版本,还需要安装 jest-environment-jsdom

yarn add -D jest-environment-jsdom

然后更改 jest 配置文件。

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  // ... other options ...
}

若是其他版本的 jest 可参考官网文档进行配置:点我跳转

// jest.setup.after.js
// 在全局变量上实现一个等待函数
global.sleep = function (time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, time);
  });
};
// test-utils.js
import { render as baseRender } from '@testing-library/react/pure';
import React from 'react';

export * from '@testing-library/react';
// 稍微封装一下,这样测试过程中方便实现修改组件 props 的操作
export function render(element, options) {
  const result = baseRender(element, options);

  return {
    ...result,
    setProps(props) {
      result.rerender(React.cloneElement(element, props));
    },
  };
}

// GeneralPay.test.js
import GeneralPay from '../GeneralPay';
import { render, act, fireEvent } from '@utils/test-utils';
import { mockPayConfigMockData } from './mock/index';

// mock 多语言
jest.mock('@tuya-fe/i18n', () => ({
  __esModule: true,
  useLocales: () => ({
    increase: {
      common_amount: '金额',
      common_pay_button_title: '立即支付',
    },
  }),
}));

jest.mock('@hooks', () => ({
  useAppSelector: () => ({
    common: {
      platLang: 'zh',
    },
  }),
}));

describe('GeneralPay', () => {
  it('normal', async () => {
    const fn = jest.fn();
    const { asFragment, setProps, getByText } = render(
      <GeneralPay
        visible={false}
        payConfig={mockPayConfigMockData}
        platLang="zh"
        amount="22"
        desc="测试描述"
        onClick={fn}
      />
    );
    expect(asFragment()).toMatchSnapshot();
    // 弹出支付组件
    setProps({
      visible: true,
    });
    // 组件有动画,所以这里等待 1500 秒,等到动画全部执行完毕
    await sleep(1500);
    // 这里断言弹出的支付组件的 UI
    expect(asFragment()).toMatchSnapshot();
  	// 测试组件的 onClick 是否正常
    fireEvent.click(getByText(/立即支付/));
    expect(fn).toHaveBeenCalled();
  });
});

模块 Mock

上面的例子中用到了模块 mock,单元测试都是运行在 node 的环境中的,可能有些组件依赖能力或者数据是并不具备的,比如:组件依赖接口请求返回的数据、RN 组件依赖原生的能力,这种情况下可以使用 Mock 的手段去解决问题。 用法:

// mock模块用法
jest.mock('modulePath', () => {
  return {
    __esModule: true,
    default: () => jest.fn(),
    attr1: 'test value',
  }
})
// import Test, { attr1 } from 'modulePath';

大多时候可能并不想 Mock 所有模块,只是想 Mock 其中的几个方法,可以这样实现:

// mock 部分模块
jest.mock('modulePath', () => {
  // 加载原先的模块
  const originalModule = jest.requireActual('modulePath');
  return {
    __esModule: true,
    ...originalModule,
    default: () => jest.fn(),
    attr1: 'testValue'
  }
})

有时候也会有在单测运行时重新 Mock 模块的需求:

jest.mock('modulePath', () => {
  const originalModule = jest.requireActual('modulePath');
  return {
    __esModule: true,
    ...originalModule,
    default: () => jest.fn(),
    attr1: 'testValue'
  }
})
it('xxx', () => {
  // 重新mock
  const module = require('modulePath')
  module.xxx = xxxx
})

Test React Hooks

Document 自从 React 推出 Hooks 之后,Hooks 也已经广泛的被开发者运用了,所以学会如何测试自定义 Hooks 也是很重要的。 @testing-library提供了测试 React Hooks 相关的方法。

// hooks.usePayConfig.test.js
import { act, renderHook } from '@testing-library/react-hooks';
import { usePayConfig } from '../hooks/usePayConfig';
import { mockPayConfigMockData } from './mock/index';
// mock 接口方法
jest.mock('@services/pay', () => {
  return {
    getPayConfigList: () => Promise.resolve(mockPayConfigMockData),
  };
});
describe('Hooks - usePayConfig', () => {
  const commodityInfo = {
    currency: 'USD',
    isSubType: false,
  };
  it('normal', async () => {
    const { result } = renderHook(() => usePayConfig());
    await act(async () => {
      result.current.setCommodityInfo(commodityInfo);
      // 因为 useEffect 是异步的,且获取支付方式也是异步的,无法准确获取到执行时机
      // 所以这里等待 150ms
      await sleep(150);
    });
    expect(result.current.commodityInfo).toEqual(commodityInfo);
    expect(result.current.payConfig).toEqual(mockPayConfigMockData);
  });
  it('customGetPayConfigMethod', async () => {
    const customPromise = Promise.resolve([1, 2]);
    const custom = jest.fn(() => customPromise);
    const { result } = renderHook(() => usePayConfig(commodityInfo, custom));
    await act(async () => {
      await sleep(150);
    });
    // 这里测试 customGetPayConfigMethod 的功能是否正常
    // 使用 jest.fn mock 了方法
    // 然后通过断言该方法是否被调用,且传入的参数是否是 commodityInfo 来判断功能是否正常执行
    expect(custom).toBeCalledWith(commodityInfo);
    expect(result.current.payConfig).toEqual([1, 2]);
  });
});

这是我们业务实际开发中的一个组件,这里粘贴一下这个 Hooks 的源码。

// usePayConfig.ts
/**
 * 支付配置 hooks
 */
import React from 'react';
import { useNextContext } from '@tuya-fe/next/context';
import getConfig from '@tuya-fe/next/config';
import { useAsync } from '@tuya-fe/react-hooks';
import { PaidType, PayConfigDto } from '@models/pay';
import { getPayConfigList } from '@services/pay';

export interface HookPayConfigInstance {
  commodityInfo: CommodityInfo;
  setCommodityInfo: React.Dispatch<React.SetStateAction<CommodityInfo>>;
  payConfig: PayConfigDto[];
  loading: boolean;
  paidType: PaidType;
  isInit: boolean;
}

export interface CommodityInfo {
  currency: string; // 商品的币种
  isSubType: boolean; // 是否订阅商品
}

export function usePayConfig(
  initCommodityInfo: CommodityInfo = { currency: '', isSubType: undefined },
  customGetPayConfigMethod?: (commodity: CommodityInfo) => Promise<PayConfigDto[]>,
): HookPayConfigInstance {
  const context = useNextContext();
  const {
    publicRuntimeConfig: { isCn },
  } = getConfig(context);

  const [commodityInfo, setCommodityInfo] = React.useState<CommodityInfo>(initCommodityInfo);
  const [paidType, setPaidType] = React.useState<PaidType>();
  const [isInit, setIsInit] = React.useState(false);

  const { data: payConfig, loading } = useAsync<PayConfigDto[]>(async () => {
    if (typeof customGetPayConfigMethod === 'function') return customGetPayConfigMethod(commodityInfo);

    const { isSubType, currency } = commodityInfo;
    if (isSubType === undefined || !currency) {
      if (typeof isSubType !== 'undefined') {
        setIsInit(true);
      }
      return [];
    }
    let realPaidType: PaidType;
    if (isSubType) {
      realPaidType = 'subscribe_pay';
    } else {
      realPaidType = (isCn === 'true' || isCn === true) ? 'mobile_web_pay' : 'app_pay';
    }
    setPaidType(realPaidType);
    const res = await getPayConfigList({ input: { paidType: realPaidType, currency } });
    if (res) {
      const recordMap = new Map();
      // 过滤重复项
      const configList = res.reduce((ret, item) => {
        if (recordMap.has(item.paidProvider)) return ret;
        recordMap.set(item.paidProvider, item.paidProvider);
        return [...ret, item];
      }, []);
      setIsInit(true);
      return configList;
    }
  }, [commodityInfo.currency, commodityInfo.isSubType]);
  return {
    commodityInfo,
    setCommodityInfo,
    payConfig: payConfig || [],
    loading,
    paidType,
    isInit,
  };
}

这个组件的作用是获取支付配置,当 commodityInfo 中的 currency 字段或者 isSubType 字段改变时,会请求接口获取支付配置。所以这个 Hooks 返回的结果中也提供了 setCommodityInfo方法去改变支付的配置,当然也提供了 payConfig提供从接口中获取到的支付配置。然后也提供了一个 customGetPayConfigMethod用来实现自定义获取支付配置的接口方法。

小技巧:在单元测试的时候如何进行调试

这个技巧将非常实用,学会它不仅可以在单元测试中进行调试,js 的应用它都可以调试。 使用 VScode Debugger 进行调试。 image.png

{
  // 使用 IntelliSense 了解相关属性。 
  // 悬停以查看现有属性的描述。
  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "unit test",
      "skipFiles": [
        "<node_internals>/**"
      ],
      "runtimeExecutable": "yarn",
      "args": ["test"],
      "outFiles": [
        "!**/node_modules/**"
      ]
    }
  ]
}

填写以上配置后,点击启动 Debug,相当于在项目根目录位置运行了 yarn test

我们的项目现在基本都是使用 typescript 进行开发的,所以在使用 Vscode Debugger 进行单元测试的调试时,需要注意配置 sourceMap, outFiles字段可以用来配置 sourceMap文件的地址,不知道具体在哪的话,直接配置忽略 node_modules文件夹就行了,这样可以减少 Debugger 工具搜索 sourceMap 文件花费的时间。 如果还是没有 sourceMap,基于 jest 来讲的话,默认采用的是 babel-jest编译代码,所以我们只需要检查 babel的 sourceMaps 配置是否开启。 一般情况下,babel 会读取根目录处的 .babelrc或者 babel.config.js文件 (具体优先级看一下 babel 的文档)。如果需要 jest 和项目分别使用两份配置的话,可以使用 babel.config.js根据环境变量(jest 会将 NODE_ENV设置为 test)去返回不同的配置。或者像以下这样做: jest.config.js 配置:

transform: {
    '\\.[jt]sx?$': path.resolve(__dirname, 'transform.js'),
},
const babelJestMd = require('babel-jest');


const babelJest = babelJestMd.__esModule ? babelJestMd.default : babelJestMd;
module.exports = babelJest.createTransformer({
  // babel options
  presets: ['@babel/preset-react', '@babel/preset-env'],
  plugins: [
    '@babel/plugin-proposal-export-namespace-from',
    ['@babel/plugin-proposal-decorators', { legacy: true }],
  ],
  sourceMaps: true,
});

balbel 的 sourceMaps 配置参见: babeljs.io/docs/en/opt…

在脚本中使用 jest-cli

有时候我们在封装一些项目的脚手架或者是模板的时候,会有把 jest 的单元测试也集成进去的需求,这时候在我们自己的脚本中去调用 jest-cli 就会很灵活。

const { runCLI } = require('jest');
const getJestConfig = require('./jestConfig/getJestConfig');

async function test(options) {
  const jestConfig = getJestConfig();
  const res = await runCLI(
    {
      ...jestConfig,
      silent: true,
      cache: options.cache,
      updateSnapshot: options.u,
      coverage: options.coverage,
      clearCache: options.clearCache,
      ci: options.ci,
    },
    [process.env.ROOT_PATH]
  );
  // console.log(res);
  if (res.results.success) {
    console.log(`Tests completed`);
  } else {
    console.error(`Tests failed`);
    process.exit(1);
  }
}

module.exports = {
  test,
};

有一点需要注意的是,在脚本中使用 jest-cli 时,jest 的 transform 配置需要转换成 json 格式,否则 jest 运行时会报错。 image.png

参考文章: How to test React Hooks 使用 Vscode Debugger 进行调试 RN 源码中有关于 jest 的内容