单元测试在项目中的应用

1,572 阅读15分钟

一、先简单介绍目前前端测试的主要类型

  • 单元测试
    • 单元测试是对开发人员所编写的代码进行测试,主要是逻辑功能测试。
    • 单元测试覆盖了代码块,确保它们在运行时没有问题。被测试的单元可以是函数、模块和类等。单元测试应该相互隔离并且彼此独立。对于给定的输入,用单元测试检查结果,通过尽早发现问题并避免退化,可以帮助你确保程序的每个部分都能按预期工作。
  • 集成测试
    • 即使你的所有单元测试都通过了,也只能代表每个部分可以正常工作。尽管如此,该程序仍可能失败。集成测试涵盖跨模块流程,其中各个模块在一起工作时进行组合和测试。多亏了它,你可以用一种方法来确保你的代码在整体上能够正常运行。
  • 端到端测试(E2E)
    • 与其他类型的测试相反,端到端测试始终在浏览器(或类似浏览器)环境中运行。它可能是打开的真正浏览器,并且在其中运行测试。它也可能是无头浏览器环境,即没有用户界面运行的浏览器。E2E 测试的重点是在我们正在运行的程序中模拟实际用户。它将模拟滚动,单击和键入之类的行为,并从实际用户的角度检查我们的程序是否运行良好。

二、三种测试类型里为什么首选单元测试?

  • 单元测试保障独立业务逻辑的正确性。只有一个个零件正确,组合起来的页面逻辑才会正确。
  • 我们可以对复杂的组合逻辑(如页面,多个组件关联逻辑)做单元测试(即可看作使用简单版的集成测试)。E2E测试目前由测试人员编写,故前端开发主要以单元测试为主。

三、我们为什么在业务项目里重度使用单元测试呢?

业务需求迭代频繁,提测前都要人工自测的话,其耗费的时间可想而知。 能用工具解决的问题,为啥不用工具处理呢?尽管工具不能100%覆盖(如机型样式,需求文档问题),但也能节省很多人工时间,保证代码质量。 前期投入时,很多都要摸索,但后面轻车熟路后,对开发迭代增加的工作量(比修复bug耗费的时间划算)和代码质量相比还是值得的。

四、落地过程

1、解决诉求

  • 产品业务逻辑复杂,开关配置项多,关联逻辑多,如果需求变更考虑不全,将会引发其它逻辑问题,尤其是多个开关逻辑条件下的特殊场景问题。
  • 未使用单元测试时,上线前做三遍详细代码review,耗时长;且即使是对业务代码很熟的review人员,都可能发现不了隐藏的细节问题。
  • 产品要求上线不能有功能性问题、用户使用时间短的特性,容不下我们在客户使用过程中解决线上问题,等分析出问题后,基本上使用过程就结束了。
  • 重构、升级给测试人员带来不少压力,很多业务逻辑都要测试,测试中也会遗漏个别场景,出现问题(之前真实碰到过)。

2、过程(带来价值的前提条件)

  • 基于jq迁移react的项目背景,因没有用独立迭代重构,考虑测试人员的测试压力,在项目过程中引入单元测试的。单元测试完善目的是为后期规划的代码重构、升级等工作做铺垫。
  • 确保业务逻辑都按照独立功能逻辑抽离独立组件。
  • 项目所有组件和公共方法逻辑都编写测试用例。
  • 业务逻辑和业务场景需要理解正确,错误的理解或遗漏场景,即使单元测试做全,也是徒劳的。
  • 项目所有组件和公共方法逻辑覆盖率整体强制控制在90%以上,实际测试文件的覆盖率达到95%以上。
    • 2020.10开始接入单元测试,2021年覆盖率达到80%,2022年2月覆盖率达到90%
  • 提交代码和CI流水做覆盖检测,当测试用例错误或覆盖率小于90%时不能编译。
  • 新功能逻辑和原逻辑变更都要求全面编写或修改单元测试,保证业务逻辑场景全覆盖。

3、价值

  • 在项目初期开发时,单个组件可以通过编写单元测试即时调试,不必等页面框架代码完成才能调试。
  • 在调试过程中,对于一些极限值,不必要求服务来 Mock 或者增加侵入式调试代码,这也在一定程度上提高了开发效率。
  • 第三方库跨版本升级,减轻测试人员的压力。
    • 如:react16升级18,antd-mobile 2升级5,umi3升级4,测试人员只要简单做部分页面点击测试即可
  • 业务代码逻辑重构可以在需求迭代里进行,解决开发人员不敢对老代码进行重构的担忧。在单元测试保证业务逻辑的正确性下,不再要求测试人员详细测试了,测试人员只需要对部分页面点击测试。
  • 版本升级和重构逻辑都在普通迭代里进行,不影响测试进度(点击测试最多花几个小时)
  • 测试环境基本没有业务逻辑bug问题,主要是样式、需求细节问题以及后台接口问题的bug;测试人员测试重点向无法或没有做单元测试的界面逻辑倾斜。
  • 原先代码逻辑review耗时问题解决,功能开发完即可提测,不需要花费自测和review代码的时间。
  • 上线环境稳定,不必担心代码逻辑问题,目前线上还没有发现前端业务逻辑问题。
  • 线上问题排查不再困难,大部分都可以推断出是接口数据问题、样式机型问题等,基本不存在已知的前端业务逻辑问题。

4、新旧对比,效率提升

  • 单元测试加入后,弱化代码review,大量节省review时间又保证产品质量。
  • 业务逻辑复杂的需求逻辑,迭代开发更能保证之前的逻辑不受影响。
  • 重构、升级大大减轻测试人员的测试工作量,提高效率。
  • 需求开发后即可提测,不再浪费耗时的开发自测。
  • 目前产品的H5端、复杂页面端做了单元测试覆盖,每次提交代码和构建都跑单元测试,不通过的一律不能构建代码,不能发布。
    • xxH5端,测试套件93个,测试分组259个,业务逻辑覆盖率95%以上
    • xx复杂页面重构时,加入单元测试;电视墙预览页面里,测试套件9个,测试分组38个,组件覆盖率100%,上线至今还没有反馈线上问题
    • 预约管理微信端今年也开始加入单元测试
  • 之前无单元测试时,review代码3遍,对于1个标准5人天以内的需求,自测时间0.5-1天;且花了时间,还不一定能发现逻辑问题,测试定位bug还需要另外花时间确定前端还是后端问题。
    加入单元测试后,review以及自测代码时间可忽略不计(极少迭代特殊逻辑做代码reivew),节省时间做优化事项;因此迭代大部分都是数据接口问题,测试bug定位也比之前快很多,且后面迭代需求,如果有改动影响原逻辑,单元测试就可以提早发现问题。
  • 在开发阶段,可以通过单元测试发现逻辑分支细节问题。尤其是xx复杂页面重构时,因多人开发,测试用例编写发现不少问题,不过后面提测阶段基本没有逻辑问题了。提测时出现了网络慢导致的逻辑问题,这个是因为单元测试没做网络模拟测试导致的。

5、待优化点

  • 机型样式测试无法实现,需要测试人员手动测试。
  • 样式布局和PSD设计稿无法使用单元测试对比,目前也是依靠人工测试。
  • 需求文档逻辑的遗漏问题,目前没有好的解决方案,还是要靠集体的力量,但还是会存在团队成员都没发现的问题。

五、业务团队使用上的未来规划

  • 产品后台核心重要逻辑功能加入单元测试。
  • 配合测试人员,完善E2E自动化测试。
  • 其它项目根据项目价值规划加入单元测试(如:公共H5业务逻辑项目)。

六、单元测试搭建过程及使用

以下内容根据react18项目,jest29举例

1、react项目里jest相关开发依赖安装

  • 使用@babel包的原因是,我们可以使用ES6的语法特性进行单元测试,ES6提供的 import 来导入模块的方式,Jest本身是不支持的
"devDependencies": {
  "@babel/preset-env": "^7.19.0",
  "@babel/preset-react": "^7.18.6",
  "@babel/preset-typescript": "^7.18.6",
  "@testing-library/jest-dom": "^5.16.5",
  "@testing-library/react": "^13.4.0",
  "@testing-library/user-event": "^14.4.3",
  "@types/jest": "^29.0.2",
  "babel-jest": "^29.0.3",
  "identity-obj-proxy": "^3.0.0",
  "jest": "^29.0.3",
  "jest-environment-jsdom": "^29.0.3",
  "typescript": "^4.8.3"
},

2、jest配置文件配置jest.config.ts

module.exports = {
  testEnvironmentOptions: {
    url: 'http://localhost:8001/demo/',
  },
  collectCoverage: true,
  testEnvironment: 'jsdom',
  snapshotFormat: {
    escapeString: true,
    printBasicPrototype: true,
  },
  moduleNameMapper: {
    '\\.(css|less|sass|scss|stylus)$': require.resolve('identity-obj-proxy'),
    '^@/(.*)$': '<rootDir>/src/$1',
    '^@@/(.*)$': '<rootDir>/src/.umi/$1',
  },
  moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json'],
  testMatch: ['**/?*.(spec|test|e2e).(j|t)s?(x)'],
  testPathIgnorePatterns: ['/node_modules/'],
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': require.resolve(
      './script/jest/transformers/javascript',
    ),
    '^.+\\.(css|less|sass|scss|stylus)$': require.resolve(
      './script/jest/transformers/css',
    ),
    '^(?!.*\\.(js|jsx|ts|tsx|css|less|sass|scss|stylus|json)$)':
      require.resolve('./script/jest/transformers/file'),
  },
  verbose: true,
  transformIgnorePatterns: ['/node_modules/(?!(antd-mobile)/)'],
  setupFilesAfterEnv: [
    '@testing-library/jest-dom/extend-expect',
    './__test__/jest.setup.tsx',
  ],
  collectCoverageFrom: [
    'src/service/request/*.{js,jsx,ts,tsx}',
    'src/components/**/*.{js,jsx,ts,tsx}',
    'src/pages/**/components/**/*.{js,jsx,ts,tsx}',
    'src/hooks/**/*.{js,jsx,ts,tsx}',
    // 页面文件
    'src/pages/(favorite)/**/index.{js,jsx,ts,tsx}',
  ],
  coverageThreshold: {
    global: {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90,
    },
  },
};
  • 里面需要主要关注的几个字段
    • moduleNameMapper:模块映射里css等文件需要Mock掉,单元测试需要忽略才走得通
    • testMatch:测试文件匹配规则,如a.test.tsx ,以.test.tsx结尾的作为测试文件
    • transform:CSS,JS文件需要转换处理
    • setupFilesAfterEnv: 这里是处理全局测试文件需要的资源,如对第三方库做通用Mock处理
    • collectCoverageFrom:文件覆盖率目录设置,可以对react组件、工具函数、页面等做代码覆盖率配置。
    • coverageThreshold:覆盖率强制要求配置,可以在提交代码或ci构建时跑单元测试,覆盖率没达到要求不能提交或构建代码
  • transform 配置里相关文件如下(处理转化css,ts,svg等文件)
// css.js
module.exports = {
  process() {
    return { code: 'module.exports = {};' };
  },
  getCacheKey() {
    // The output is always the same.
    return 'css';
  },
};

//javascript.js
const babelJest = require('babel-jest');

module.exports = babelJest.default.createTransformer({
  presets: [
    [
      '@babel/preset-env',
      {
        targets: { node: 'current' },
        modules: 'commonjs',
      },
    ],
    [
      '@babel/preset-react',
      {
        runtime: 'automatic',
      },
    ],
    [
      '@babel/preset-typescript',
      {
        allowNamespaces: true,
      },
    ],
  ],
  babelrc: false,
  configFile: false,
});

//file.js
const path = require('path');

module.exports = {
  process(src, filename) {
    const assetFilename = JSON.stringify(path.basename(filename));

    if (filename.match(/\.svg$/)) {
      return {
        code: `module.exports = {
        __esModule: true,
        default: ${assetFilename},
        ReactComponent: ({ svgRef, ...props }) => ({
          $$typeof: Symbol.for('react.element'),
          type: 'svg',
          ref: svgRef || null,
          key: null,
          props: Object.assign({}, props, {
            children: ${assetFilename}
          })
        }),
      };`,
      };
    }

    return { code: `module.exports = ${assetFilename};` };
  },
};

3、配置好后,控制台运行测试用例即可

// 执行全部测试用例
yarn jest
// 执行一个测试用例
yarn jest xxx/xxx.test.ts

4、测试文件的编写(主要困难在于Mock和异步逻辑,需要多写多积累经验)

  • Mock逻辑的编写,如动画,时间,第三方库等的处理
  • 异步逻辑的处理
  • 业务逻辑分支都要全面测试
  • 单元测试全面相当于开发自己做了一遍完整的自测过程
  • 参考相关文档

5、测试用例使用简单介绍

  • 常用的判断类型:toBe (值类型)、toEqual (引用类型)、toBeNull、toBeDefined、toBeTruthy (true)、toBeFalsy (false)、toBeCloseTo (约等于)、toMatch (匹配包含)、toThrow、not 修饰符( not.toBe )、toBeGreaterThan(大于)、toBeGreaterThanOrEqual(大于或者等于)、toBeLessThan(小于)、toBeLessThanOrEqual(小于或等于)、toContain(包含)、toThrow(异常)
// 先编写一个需要测试的函数
function sum(a, b){ 
    return a + b;
}
module.exports = sum

//创建一个sum.test.js文件,并写入我们的测试用例。
const sum = require('./sum');
test('adds 1 + 2 to equal 3',()=>{ 
    // 精确匹配等于3 
    expect(sum(1,2)).toBe(3);
});
  • 多次测试前后准备的钩子函数(beforeEach 和 afterEach)
  • 测试用例多而复杂时,可以用describe进行分组
import { isCity, otherMethod } from '@/utils/isCity';

describe('测试 isCity', () => {
    beforeEach(() => {  
        // 每个测试前初始化城市数据
        initializeCityData();
    });
    afterEach(() => {  
        // 每个测试后清除城市数据
        clearCityData();
    });
    test('city has BeiJing', () => {  
        expect(isCity('BeiJing')).toBeTruthy();
    });
    test('city has ShenZhen', () => {  
        expect(isCity('ShenZhen')).toBeTruthy();
    });
});

describe('测试 otherMethod', () => {
    。。。。。。其它方法测试,如测试otherMethod
});  
  • 异步测试:回调函数
// 需要测试的方法
const fetchUser = (cb) => {  
    setTimeout(() => {     
        cb('hello');
    }, 100);
};

// 测试用例
// 使用done,done表示执行done函数后,测试结束。如果没有done,同步代码执行完后,测试就执行完了,测试不会等待异步代码。
test('test callback', (done) => {  
    fetchUser((data) => {      
        expect(data).toBe('hello');  
        done();
    });
});
  • 异步测试:promise(async/await)
// 需要测试的方法
const userPromise = () => Promise.resolve('hello')

// 测试用例
// promise方式
test('test promise', () => {  
    // 必须要用return返回出去,否则测试会提早结束,也不会进入到异步代码里面进行测试  
    return userPromise().then(data => {    
        expect(data).toBe('hello');
    });
});

// async方式
test('test async', async () => {  
    const data = await userPromise();
    expect(data).toBe('hello');
});

// Jest 框架提供了一种简化的写法,即 expect 的resolves和rejects表示返回的结果
test('test with resolve', () => {  
    return expect(userPromise()).resolves.toBe('hello');
});
  • Mock Timer(定时器,基础场景)
// 需要测试的函数(文件timer-fun.ts)
const timerFun = (callback?:()=>void) => {
  console.log('timerFun 调用');
  setTimeout(() => {
    console.log('1s 后调用');
    callback && callback();
  }, 1000);
};
export { timerFun };
// 测试用例(例子使用定义时间来控制程序运行)
import { timerFun } from './timer-fun';
import { act } from '@testing-library/react';

describe('timerFun 测试', () => {
  beforeEach(() => {
    jest.clearAllTimers();
  });

  test('timerFun', () => {
    jest.useFakeTimers();
    const callback = jest.fn();
    timerFun(callback);
    expect(callback).not.toHaveBeenCalled();
    // mock 1s时间
    act(() => {
      jest.advanceTimersByTime(1000);
    });
    // 验证callback被调用1次
    expect(callback).toHaveBeenCalledTimes(1);
    // 清除调用次数
    callback.mockClear();
    // 再mock 1s时间
    act(() => {
      jest.advanceTimersByTime(1000);
    });
    // 验证这1s实际上没有调用callback,即,调用方法timerFun,只在1s后执行一次回调
    expect(callback).not.toHaveBeenCalled();
  });
});
  • Mock 第三方库
// 如react组件里使用了qrcode.react库,我们可以对qrcode.react的mock,可以对入参prop断言是否传对

jest.mock('qrcode.react', () => {
  return (prop: any) => {
    return <div>qrcode.react components,value:{prop.value}</div>;
  };
});
// 后面再测试我们组件自己的业务逻辑
  • 对于dom的一些断言,可以添加testing-library库提供的jest-dom/extend-expect来更好地对dom进行断言
import {render, fireEvent, screen} from '@testing-library/react'
import '@testing-library/jest-dom'

... 省略部分代码
// 如:判断按钮是否禁用
expect(screen.getByRole('button')).toBeDisabled();
  • 事件:FireEvent,实际的用户交互可以通过 react-testing-library 的 fireEvent 函数去模拟。
    • 常用 fireEvent:键盘(keyDown,keyPress,keyUp),聚焦(focus,blur),表单(change,input,invalid,submit,reset),鼠标(click,dblClick,drag)
    • 具体使用可看react-testing-library例子
import {render, fireEvent, screen} from '@testing-library/react'

... 省略部分代码
// 触发事件
fireEvent.click(screen.getByText('按钮'))
  • 快照测试
    • 快照测试是用于确保某个组件的UI不会有意外的改变。与UI测试不同,快照测试不会对比样式文件,仅对比dom结构和节点参数
    • 不要在组件测试里过度使用。因为组件更改后,需要人工对比差异是否正确,人为因素比较重要,不然测试就无意义了
    • 建议对常量配置项进行快照测试,毕竟它改动不频繁,人工对比差异误判率也很低
    • 在代码更改后,确保人工对比差异无误后,使用jest xxx/xxx.test.ts -u 来更新样本
// 如:测试config对象
test('测试 config', () => {
  const config = {
    a: 'a',
    b: 'b',
  };

  expect(config).toMatchSnapshot();
});
// 生成的快照文件,迁入版本管理
// 快照文件会放在测试文件同级目录下的 snapshots 文件夹中
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`测试 config 1`] = `
Object {
  "a": "a",
  "b": "b",
}
`;

七、总结

  • 测试在整个需求开发的流程中起着重要作用,它对于需求的质量提供了强而有力的保障。
  • 但是在实际的工作中,由于产品的迭代、需求的变更以及各种不确定的因素,我们经常会陷入“bug的轮回” —— 解决上一个bug,点亮另一个bug。单元测试用例的编写过程,也是开发人员自己发现bug、解决bug的过程。让提测代码更健壮,避免后期修复业务逻辑bug的返工浪费。
  • 随着业务复杂度的提升,测试的人力成本也会越来越高,尤其是重构代码给测试人员带来的压力可想而知。面对这些痛点,作为前端开发,我也常常在思考有什么方法可以在解放双手的同时,又能保证产品的质量,不必每次在需求上线时紧张兮兮地盯着,生怕发的版本影响了其他的功能。借助单元测试的力量,这些痛点将会逐个击破,让测试人员的测试重点向机型样式兼容方面倾斜,重构逻辑或升级库都不再需要测试人员详细测试,只要简单少量的点击测试即可。