单元测试在 React-Native APP 中调研及实践

1,442 阅读10分钟
我正在参加「掘金·启航计划」
  1. 背景及目标

背景

  • 最近参与的公司一个内部 APP的开发,经过团队 7 人半年多快速的迭代,已产生 65k+ 行的代码量,服务目标用户预计超过万人。项目由于迭代紧凑,会存在频繁增量发版需求(最多时一周会有3次)。
  • 伴随着功能的逐渐叠加,上线前的回归就显得越来越重要,但是上线前的回归测试严重依赖于测试人力,会导致大量重复人力的投入,使得团队效率降低,而且如果人工测试用例覆盖率不足,就可能随时导致线上问题出现。
  • 同时,频繁发版不可避免的也会导致一些回归用例不到位,影响工程师正常工作,打扰团队成员发版后的夜半美梦。长此以往只会让团队逐渐疲惫,打击团队信心,影响团队口碑。

目标

  • 为核心模块增加单元测试,避免出现线上低级错误,提升工作效率,保障项目依赖的公共资源健壮性。
  • 同时也可减少开发/测试回归侧的人力投入,避免因此类问题导致的线上事故,保障项目高效、高质量发布。

没有完备的单元测试的代码所构成的一个系统,就像组装一架飞机,各个配件没有分别经过严格检测,只在最后组装好后,再通过试飞来检验飞机是否正常一样

  1. 单元测试类型

快照测试(Snapshot)

快照测试是第一次运行测试的时候在不同情况下的渲染结果(挂载前)保存的一份快照文件,后面每次再运行快照测试时,都会和第一次的比较。

快照测试主要用于检查特定状态下,组件渲染是否匹配对应的 dom 结构,如果不符合则认为该测试用例不通过,一般应用于 UI 组件 测试。

// 如下就是一个组件的快照
exports[`<JestTest/> Snapshot 1`] = `
    <Text
      accessible={true}
      allowFontScaling={true}
      ellipsizeMode="tail"
      onClick={[Function]}
    >
      You not liked this.Click to toggle.
    </Text>
`;

功能测试

单元测试具有独立的运行环境,可以方便快速的独立 mock 某一模块的各种逻辑,在单测中,可以通过代码覆盖率快速定位到具体哪个函数、哪一行没有覆盖到,从而避免出现某些逻辑被忽视。

功能测试主要测试组件生成的 DOM 节点是否符合预期,比如响应事件(点击/输入/异步请求)之后,组件的属性与状态是否符合预期。需要注意的是,这些操作需要依赖第三方库实现。

同时也可以对一些公共 Function 进行测试用例编写,这相对简单很多,在此不做过多赘述。

代码可测性

单元测试,一般会测试某个文件、某个模块、某个函数、甚至是某一句代码,这个就对代码的模块化和独立性要求比较高。一般单元测试比较全面的代码,可维护性也会较高。

  1. 技术选型

ReactNative 主流单元测试框架

框架组合厂商特点RN 支持度RN 官方使用
Jest+Enzymeairbnb使用类似JQ的语法方式对结果进行断言(对 ReactNative 单测支持不是很友好,如使用 mount 时会编译报错等)一般
Jest+react-test-rendererfacebook将 React 组件渲染为纯 JavaScript 对象,而不依赖于 DOM 或本机移动环境。友好

二次封装库对比

以下是基于 Jest+react-test-renderer 方案,界普遍使用 的 RN 三方库对比

库选择Star / 下载量(周)优点缺点文档
@testing-library/jest-native300 / 151,525- 基于react-test-render,给组件增加自定义属性(testID),便于快速识别校对
  • 着重于属性、内容的渲染校验比对 | 没有交互类事件,无法处理强交互组件 mock 模型 | api文档完善,样例完整 | | @testing-library/react-native | 2.4k / 235,921 | - 基于react-test-render
  • 可以引入jest-native拓展
  • 通过API 获取dom及其children,用于快速识别校对
  • 增加 fileEvent 事件,增加交互校对 | jest dom 获取没有前者方便 | 有demo项目,丰富的样例素材,方便上手demo | | react-test-renderer | 5,048,372 | 官网推荐 | | |

结论

推荐使用 react-test-render + @testing-library/react-native ,基于以下四点原因:

  • 更符合我们的需求
  • 支持交互类事件的用例
  • 文档齐全,样例丰富
  • 社区活跃度高

另外,针对 React Hooks 的方法,推荐使用 @testing-library/react-hooks 库辅助测试

yarn add -D @testing-library/react-hooks
  1. 单元测试接入实践

前端组件化已经让 UI 测试变得容易很多,每个组件都可以被简化为这样一个表达式,即 UI = f(data),这个纯函数返回的只是一个描述 UI 组件应该是什么样子的虚拟 DOM,本质上就是一个树形的数据结构。给这个纯函数输入一些应用程序的状态,就会得到相应的 UI 描述的输出,这个过程不会去直接操作实际的 UI 元素,也不会产生所谓的副作用。

无法复制加载中的内容

image.png

当前业务中,APP 内部模块之间耦合度较低,大致可以分为三类,公共基础/业务组件、公共方法、公共 Hook,他们的组合覆盖了APP 70%+ 的功能,那我们只用基于这些组件单元进行编写测试用例即可覆盖 APP 70% 以上的代码。

# 公共方法 - 使用功能测试
/src/common/**

# 公共hooks - 使用功能测试/快照(涉及dom时)
/src/hooks/**

# Redux 函数
/src/store/**

# 公共基础组件 - 快照
/src/components/** 

# 公共业务组件 - 快照
/src/modules/**

下面我们以APP内部的几个组件、功能方法以及 Hook 为例,演示测试用例编写

UI 组件测试用例编写

快照测试

import React from 'react'
import renderer from 'react-test-renderer'
import { render, fireEvent } from '@testing-library/react-native'

import AsButton from '~/components/as-button/index'
import colors from '~/common/colors'

// 快照测试
it('renders as button with snapshot', () => {
  const Button = renderer.create(
    <AsButton
      key="default"
      containerStyle={{ width: 140 }}
      textStyle={{ color: colors.content }}
      onPress={() => console.log('AsButton onPress')}
    >
      按钮
    </AsButton>
  )
  const tree = Button.toJSON()
  expect(tree).toMatchSnapshot()
})

如果该组件逻辑已变更,但是没有更新快照,则检查时会产生报错(如下图);

首先需要校对这个修改是否符合预期

  • 如果需要更新快照,执行 npm t -- --testPathPattern={path}
  • 如果不符合,检查并还原代码;

参见 [jest]snapshot-testing 文档

组件 事件回调 测试用例

// component function callback test
import React from 'react'
import renderer from 'react-test-renderer'
import { render, fireEvent } from '@testing-library/react-native'

import AsButton from '../index'
import colors from '~/common/colors'

it('test as button onPress action', () => {
  const passport = '发财密码:123456'
  let submittedData = ''
  // mock回调方法
  
  const handlePress = jest.fn(() => (submittedData = passport))

  const { getByText } = render(
    <AsButton
      key="default"
      containerStyle={{ width: 140 }}
      textStyle={{ color: colors.content }}
      onPress={handlePress}
    >
      点我发财
    </AsButton>
  )

  // 通过text寻找dom,模拟点击
  fireEvent.press(getByText('点我发财'))
  // 断言结果是否一致
  expect(submittedData).toEqual(passport)
})

一般方法测试用例编写

import renderer from 'react-test-renderer'
import moment from 'moment'

import { calcLastTime, formatMsgTime } from '~/common/date'

describe('测试 date.js ', () => {
    // 该方法返回的是 ReactDom,选择快照测试
  test('测试 calcLastTime 方法计算剩余时间快照', () => {
    const RenderMinute = renderer.create(calcLastTime(50))
    const RenderHour = renderer.create(calcLastTime(20 * 60))
    const RenderDate = renderer.create(calcLastTime(50 * 60))
    const RenderOverTime = renderer.create(calcLastTime(20, true))
    // 快照
    expect(RenderMinute.toJSON()).toMatchSnapshot()
    expect(RenderHour.toJSON()).toMatchSnapshot()
    expect(RenderDate.toJSON()).toMatchSnapshot()
    expect(RenderOverTime.toJSON()).toMatchSnapshot()
  })

  // 该方法返回的是 String,选择功能测试
  const formatDate = moment().format('YYYY-MM-DD')
  test('测试 formatMsgTime 函数', () => {
    const date = moment(formatDate).subtract(-7, 'hours')
    expect(formatMsgTime(date)).toBe('07:00')
    expect(formatMsgTime(date.subtract(1, 'days'))).toBe('昨天 07:00')
    expect(formatMsgTime(date.subtract(10, 'days'))).toBe(moment(date).format('MM月DD日 HH:mm'))
  })

})

React hooks 单测

// ~common/hooks.js 
// useSetInterval 方法

export const useSetInterval = (func, interval) => {
  const timerRef = useRef(0)
  const [time, setTime] = useState(0)
  
  useEffect(() => {
    timerRef.current = setTimeout(() => {
      func()
      setTime(time + 1)
    }, interval)
    
    return () => {
      timerRef.current && clearTimeout(timerRef.current)
    }
  }, [time])
}

// __test__/hook.test.js
// useSetInterval 单测
import { renderHook, act } from '@testing-library/react-hooks'
import { useSetInterval } from '~/common/hooks'

jest.useFakeTimers()
jest.spyOn(global, 'setTimeout')

describe('测试 date.js', () => {
  let currentIndex = 0
  
  test('测试 useSetInterval hook用例', async () => {
    act(() => {
    // 执行Hooks
      renderHook(() =>
        useSetInterval(() => {
          currentIndex++
        }, 1000)
      )
    })

    jest.advanceTimersByTime(1500)
    expect(currentIndex).toBe(1)
    jest.advanceTimersByTime(1000)
    expect(currentIndex).toBe(2)
    jest.advanceTimersByTime(1000)
    expect(currentIndex).toBe(3)
  })
})

计划:可执行单测模块测试覆盖率 90% 以上,核心模块覆盖率 100%

单测接入开发流程

我们不得不承认,编写单元测试就像 eslint 这种控制代码质量的工具一样,如果不强制绑定到开发流程中,那么它必将慢慢的被大家遗忘。为了确保团队单测质量,我们需要把它们加入到 Git工作流中来。(需要团队搭建CI/CD流程)

流程如下图如图所示 image.png 暂时无法在文档外展示此内容

我们将单元测试的运行集中于两个点,git commitMerge Request

git commit 流程

在开发者开发过程中,我们会在git commit 时,检查当前提交的代码中是否包含已有单元测试相关文件,如果不存在则跳过单测部分,节省运行时间;如果存在,则会在 pre-commit-hook 中找到已有单测且被修改的文件,对它们执行的单测用例。如果单测不通过则不允许代码提交,从源头保障了代码提交的合规性。其次,我们不进行全量单测扫描,只对增量单测相关文件进行检查,检查粒度更细,有效节省了编译时间,避免开发时间浪费。

 // package.json

 "husky": {
    "hooks": {
      "pre-commit": "npm run unit-test"
    }
  }
// unit-test 脚本
const fs = require('fs')
const path = require('path')
const shell = require('shelljs')

const jestDir = path.join(__dirname, '..', '__test__')

// 获取单元测试文件列表
const getUnitJestFileNameList = (dir, list = []) => {
  const files = fs.readdirSync(dir)
  
  files.forEach((file) => {
    const fullPath = path.join(dir, file)
    const stat = fs.statSync(fullPath)
    
    if (stat.isDirectory()) {
      if (!fullPath.endsWith('__snapshots__')) {
        getUnitJestFileNameList(path.join(dir, file), list) // 递归读取文件
      }
    } else {
      const name = file.replace('.test.js', '')
      list.push({ name, fullPath })
    }
  })
}

// 获取到当前 git 修改文件列表

const getModifyGitFiles = () => {
  const gitLogs = shell.exec('git status').stdout.toString().trim().split('\n') || []
  
  return gitLogs.filter((log) => log.startsWith('\tnew file:') || log.startsWith('\tmodified:'))
}

// 检查当前待提交文件中包含测试用例的文件,并执行单元测试运行

const getChangedJestFile = () => {
  const jestFileList = []
  getUnitJestFileNameList(jestDir, jestFileList)
  const gitStatusLogs = getModifyGitFiles()
  
  jestFileList.forEach(({ name, fullPath }) => {
    const isFileChanged = gitStatusLogs.some((log) => log.includes(name))
    if (isFileChanged) {
      console.log('即将运行的单元测试文件', name)
      const result = shell.exec(`jest ${fullPath}`).stderr.toString().trim()
      
      /**
       * 退出代码(阻止 git commit 成功)
       * 返回 1 表示错误
       * 返回 0 表示通过检测
       */
      if (result.includes('failed,')) {
        console.error(
          '错误信息:模块【',
          name,
          '】, 路径:【',
          fullPath,
          '】单元测试不通过,请先检查测试用例是否是最新的'
        )
        process.exit(1)
      }
    }
  })
}

getChangedJestFile()

Merge Request CI

在开发分支往主分支合并的过程中,我们通过CI 注入 unit-test-job,执行全量单测用例扫描,确保当前MR单测用例的合规性。

# gitlab-ci.yml

unit_test:
  stage: scan
  script:
    - npm install
    - npm run test
  only:
    - merge_requests

我们希望能尽早介入单测的质量评估,以确保项目健康运行,避免把这些问题都带到上线前解决;

  1. 问题

6.1 在各个 hook 编写单元测试时,发现一些 hook 非常难以测试,大体的特征如下:

  • hook 的实现非常复杂,状态繁多,依赖繁多
  • hook 的实现不复杂,但外部依赖难以 mock
  • hook 的实现自成一体,没有入口

涉及到一些复杂的Hook实现,我建议将它的测试用例放到集成测试阶段进行实现,而不要花费过多精力在编写单元测试的 mock 逻辑上。

6.2 无法模拟 Keyboard Event、Blur Event 以及一些复杂移动端操作,会导致部分模块单测覆盖率无法达标 6.3 无法针对业务场景进行 mock

参考文档

谈一谈ReactNative单元测试-掘金

@testing-library/react-native