Semi 组件库如何做测试
石嘉林艳2022/07/13TL;DR
通过本文你可以了解
- React 组件测试的基本方法
- 如何使用 Jest/ Cypress.io/ Chromatic 测试 Semi Design React 组件。
- 如何合并多个测试工具的测试报告
预计阅读时间:20 分钟
为什么测以及测什么?
Why
Semi 组件库提供的是通用的、可自定义的 React 组件,我们需要保证组件的基本交互可以正常工作,例如点击按钮可以触发按钮的点击事件,点击选择器可以触发选项的选择事件等。另外 Semi 的组件默认具有一套规范的设计语言,包括文字、颜色、尺寸、间距等,我们需要保证组件显示符合 Semi 的设计规范。
Where
Semi 组件库是一个基于 foundation + adapter 架构的 React 组件库,其中 foundation 层包括与前端框架如 React、Vue 等无关的 TypeScript 代码,adapter 层是基于 foundation 的 React 实现,我们的测试范围就是这两个层级的相关代码。
- foundation 层:@douyinfe/semi-foundatin,包括框架无关的 TS 代码,TS 代码会在组件发布时转为 JavaScript 代码
- adapter 层:@douyinfe/semi-ui,包括 React UI 代码
除此之外,Semi 组件的动画依赖 semi-animation 相关包,由于动效变更较少和测试的复杂度较高,它并不在我们的测试范围中。
如何对测试进行评价?
测试评价包含两个方面:测试通过率、测试覆盖率。测试通过率是底线,保证了被测试到的功能不会受到版本迭代的影响,测试覆盖率衡量测试代码是否全面。
在组件的开发过程中,我们会使用手动测试的方法查看组件功能是否可以正常运行,在版本的迭代中我们则需要借助自动化测试帮我们进行测试。
手动测试
在 Semi 的组件开发流程中,我们会先启动一个 Storybook 项目,基于 Storybook 对组件的功能进行开发,通过 story 编写我们组件 API 对应的用例。通过这些用例我们可以浏览组件样式和测试组件交互。
例如下图,我们为 Button 组件的 primary type 创建了一个 story,通过人工方式检查背景色和字体颜色等样式是否正确。

自动测试
手动测试只适用于开发阶段,无法保证组件在迭代过程中是否保持 UI 和交互的一致性。因此需要我们引入测试工具帮助测试。Semi 一般是在组件开发完成后编写测试用例。我们通过编写测试用例对组件功能性进行测试,随后根据测试用例的通过率和代码覆盖率来查看组件的 UI 展示和交互行为是否符合预期。
代码覆盖率
除了手动测试和自动测试的通过率外,代码测试覆盖率也是测试评价的一个重要标准。根据维基百科的定义,“代码覆盖率是软件测试中的一种度量,描述程序中源代码被测试的比例和程度,所得的比例称为代码覆盖率”。代码覆盖率包括函数覆盖率、语句覆盖率、条件覆盖率、判断覆盖率和行覆盖率等。
- 函数覆盖率:有调用到程序中的每一个函数吗?函数是否被调用过。
- 语句覆盖率:有调用到每个语句吗?在 JS 中,值、操作符、表达式、关键字和注释都是语句。
- 条件覆盖率:每个逻辑表达式中的每一个条件(无法再分解的逻辑表达式)是否有执行?
- 判断覆盖率:有调用到逻辑表达式中的每个分支吗?if 指令成立或者不成立。
- 行覆盖率:有执行这一行吗?一行可能包含多个语句和分支。
Semi 组件库测试方法
Semi 组件库的测试方法有三种,分别是单元测试、E2E 测试和 UI 测试。以下介绍这三者的测试场景和如何使用它们对组件进行测试。
单元测试 by Jest
什么是单元测试呢?根据维基百科定义,“在计算机编程中,单元测试又称为模块测试 ,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作”。 站在 Semi 组件库的角度来看,单元测试就是对组件做的测试。
技术方案
Semi 单元测试的技术方案:
- 测试框架:Jest,提供可运行环境、测试结构、测试报告、断言、mocking 等功能
- 辅助测试库:Enzyme,主要用于 React 组件 render
- 辅助测试库:JSDOM,在 Node 环境提供 DOM 操作环境,配合 Enzyme 的 Full DOM Rendering 场景使用
- 辅助测试库:Sinon,提供 spy、stub、mock 来做事件测试和回调函数测试
测试内容
Semi 单元测试的内容主要包括:
- 组件应被渲染的 DOM 树是否正确
- 组件的属性传递是否正确(方法是否被正确调用等)
- 组件内的各个行为响应是否正确
常用 API 示例
🌰 比如我们要测试 Button 组件的 className 或者 style 有没有被正确渲染:
🌰 或者测试带有 icon 的 Button 是否正确渲染了 icon:
🌰 测试组件的属性传递是否正确:
🌰 修改 state 与 props,测试组件 UI 状态是否进行了正确的变更:
🌰 测试组件的事件回调是否被调用:
易掉坑的点
-
mount 有弹出层的组件,找不到弹出层对应的 DOM? Enzyme 默认的 mount,只会将组件本身挂载到一个 div 容器中,并不是将其挂载到 document 上。而弹出层是通过 appendChild 的方式插入到 document.body 中的,所以无法找到 portal 的容器,没有进行插入,自然就不会有弹出层。
解决方法:使用 mount(component, { attachTo: container }) 使用 attachTo,将容器挂载到body里一个特定div上,注意还需要在 beforeEach、afterEach 中作创建、销毁的操作。
-
JSDOM 不包含 Layout 引擎,因此调用 getBoundingClientRect 函数、获取 offsetWidth 时总是返回 0? 解决办法:使用 getComputedStyle 来获取 CSS 属性 #135
-
import { get } from lodash-es 报错? 默认 node_modules 中的模块不会走 babel-jest,而像 lodash-es 导出的是 ES module,在 Node 环境中需要的是 CommonJS。
解决方法:将 node_modules下所有需要走 babel-jest 编译的包,在 transformIgnorePatterns 中配置相应的 module 路径。
更多避坑内容请查看:Semi接入Jest测试 问题汇总
优点 & 缺点
✅单元测试是站在程序员角度的测试,在编写时更多的是测试组件中各个函数的返回结果是否和期望值一样,适合测试React组件的状态、回调函数的调用、参数和属性的传递、组件的挂载和渲染等。
❗️但是这种测试方式存在一些缺陷,它更多的依赖于对组件状态的信任,而不是测试用户真实的交互。对于一些和用户强相关的交互操作,比如滚动、延迟或者页面跳转,因此,我们还需要一种可以模拟用户行为的测试方式——E2E测试。
E2E 测试 by Cypress
E2E,是“End to End”的缩写,E2E 测试即为端到端测试。该测试通过模拟用户在浏览器的操作来测试组件的行为是否符合预期。
方案选择
Semi 对业内的各类测试平台工具(如 TestCafe、Testim.io、Cypress、CrossBrowserTesting、Webdriver.io、Nightwatch……)进行过调研后,基于生态完善度、功能丰富度、GUI易用性、插件二次开发可行性等多个维度综合对比后,我们最终采用了 Cypress 作为我们的 E2E 测试工具,Chromatic 作为我们的 UI 测试工具。
测试内容
在 Semi 组件库前期我们使用的是单元测试,它对大部分测试场景都能满足,但是随着组件的迭代,我们发现单元测试存在一些测试不到的场景,例如 Tooltip 弹出位置的计算,Slider 拖动一段距离等。我们迫切需要一种手段对这些测试不到、测试起来较复杂的场景进行补充。调研后,我们引入了 Cypress 进行 E2E 测试。Cypress 与现有的单元测试互为补充,它在以下两种场景下,实现成本会远小于单元测试:
- 第一种是对于操作路径较长,使用单元测试编写会很繁琐的测试用例;
- 第二种是某些不好通过单元测试实现的操作。 主要包括(但不限于)以下几类操作场景:
- 滚动行为:Anchor、BackTop、Table、ScrollList、DatePicker、TimePicker
- DOM 位置:Tooltip(弹出的位置目前 Enzyme + JSDOM 无法测试)
- 表单提交:Form
- 延时:Notification、Toast
- 链接跳转:Navigation
- 复杂用例:Tree、TreeSelect、Table
常见 API 示例
🌰 测试 Anchor 的滚动行为:
🌰 测试 Tooltip 的弹出行为:
🌰 测试轮播图的自动播放是否符合预期:
优点 & 缺点
Semi 使用的 Cypress 弥补了 Jest 单测的不足之处,适合对浏览器的真实 API 进行测试,我们可以使用浏览器的 getBoundingClientRects 获取 DOM 的位置信息,也可以在 mouseover 事件中传入 pageX 和 pageY 以实现拖拽到指定位置。但也正是因为测试的是真实的浏览器,它的测试用例执行时间会明显大于 Jest + Enzyme 的执行时间。
UI 测试 by Chromatic
Chromatic 是一个静态组件视觉对比测试工具,通过对比 snapshot (组件渲染出来的图像,或称快照)来检测 story 的视觉差异。快照测试是并行运行,可以在 1 分钟内运行 2000+ 测试。 Chromatic 可以为我们提供以下服务来保证组件库 UI 的一致性:
- 共享工作区。每次推送代码时,Chromatic 将代码的 Storybook 发布到其 CDN 上,同时为团队提供一个共享工作区,以共同评论和 review UI 变更。它可以与 Semi 使用的 Github action 配合工作。
- 提供测试组件中的视觉回归。 Chromatic 将 stories 变成测试基准。 每个 story 都在 Chrome、Firefox 和 Internet Explorer 11 中同时呈现,然后与“最后一次已知的良好状态”的快照进行比较以检测错误(Semi 使用 Chromatic 的 OSS 收费方案,快照测试浏览器仅包含 Chrome)。
- 查看受每个 PR 影响的组件的可视化变更集。 Chromatic 将给定功能分支上的新组件和更新组件与目标分支进行比较,以生成 UI 变更集。 由于 Chromatic 不涉及到测试代码的编写,每个 story 就是它的快照用例,下面简单介绍一下 Chromatic 的使用流程。
UI 对比流程
- 第一步:构建 Storybook,发布到 Chromatic 云端
Chromatic 测试中,每一次 PR(update 后会重新 build) 是一次 build 流程。目前 Semi 为 63 组件创建了 807 个 story,每个 story 中包含一个或者多个组件的用例,每次构建都会对这 807 个 story 创建的快照进行对比。

- 第二步:UI 回归测试,对比变更,更新基准

我们在 Chromatic 平台上,点击 build 的详情查看变更是否符合预期。对于符合的变更 accept,对于不符合的变更 deny。
- Accept:更新 story 的 baseline。当一个快照被 accept 之后,除非它发生变更,否则不需要重新 accept 一次(即使是通过 git branch 或者 git merge)。
- Deny:标记为“拒绝”,本次构建会立即更新为失败状态。一次 build 可以拒绝多个变更。
Accept 🌰:Input密码按钮发生了变化,左边是 baseline,右边是新的 build 变更,可以看到图片右侧的非禁用状态下的密码按钮变成了常显,之前是 hover 或 focus 输入框时显示 。不过此次更新是我们预期的,所以选择接受此次变更。

Deny 🌰:Breadcrumb 提供了当级别名字超出设定宽度时显示缩略文本 ...。下面右边新的 build 没有显示缩略文本,这里我们预期没有变化,因此选择拒绝此次变更。

在我们发现样式变更错误时,首先需要拒绝此次变更,然后修改我们的样式代码,push 代码到 Github 仓库后会 Chromatic 会重新 build,最后我们再次对这个变更进行 review。
优点 & 缺点
Chromatic 适合对静态组件进行 UI 回归测试,它可以在不同的 build 之间对比组件的快照,减少了人工对组件样式进行回归测试的成本。它提供了事件触发的钩子,可以在测试的不同阶段触发,通过这个钩子可以实现测试完成后对开发人员发消息,告知组件测试的状态。
当然 Chromatic 也有一些使用上的限制,目前它仅可以测试静态的组件,快照测试的数量根据服务的不同所有区别,开源免费计划仅提供了每月 35000 次快照测试。
代码覆盖率的统计
我们针对组件 API 编写了相关的单元测试和 E2E 测试代码。但是源码中具体有哪一行或者哪个函数没有被测试到,需要通过代码覆盖率查看。
Semi 使用了 Jest 和 Cypress 编写我们的测试代码,这两个测试工具都可以获取到对应的代码覆盖率。例如在 Jest 中我们写了组件回调函数等测试代码,在 Cypress 里我们写了滚动的测试代码,它们生成的测试报告只包含其测试代码对应的代码覆盖率。然而对于组件库来说,我们需要的是获取仓库整体的代码覆盖率,无论用哪种方式测试到都可以,因此统计代码覆盖率时我们需要将这两个部分的测试报告合并到一起。
覆盖率统计原理
代码覆盖率的统计包含两个核心步骤,第一步是在源码中的每一行插入计数器,第二步是运行测试代码,并在运行过程中统计源码执行情况,对计数器们做累加。这两步都有相应的工具处理,下面简单展示这个过程。
以一个简单的 count 函数为例:
第一步,在源码中插入计数器,我们使用 nyc 对 count 函数进行处理,经过 nyc 处理后函数会变成:
nyc 是一个统计代码覆盖率的包。
我们的测试代码:
运行后会 nyc 会根据计数器的统计情况,生成 count 函数的代码覆盖率报告。
对应到 Semi 代码覆盖率的统计,
第一步我们需要对 semi-ui 和 semi-foundation源码进行转化,插入计数器;
第二步运行我们的 Enzyme 和 Cypress 测试代码,生成源码的测试覆盖率报告。因为 Enzyme 和 Cypress 测试框架不同,我们需要生成两份测试报告并把测试报告进行合并。
测试报告
Jest + Enzyme
Jest 提供了 --coverage 参数,运行单元测试时,我们在命令行传入该参数即可生成单元测试的测试报告。我们通过设置 Jest 的配置把 Jest 的代码覆盖率报告放在根目录下的 test/coverage 目录。
Cypress
生成 Cypress 的代码覆盖率稍微麻烦一点。需要我们自定义插入计数器和生成覆盖率报告。 Cypress 提供了获取代码覆盖率的文档。
- 阶段一:插入计数器 我们需要首先对 Semi 源码进行转化,插入生成代码覆盖率所需要的计数器。使用 nyc 或者 babel 插件都可以对源码进行转换。 Semi 选择的是 babel plugin 😉 。原因是 babel 插件可以无缝与 Storybook 的 Webpack 配置连接。如果使用 nyc 需要产生一个临时目录,我们还需要改 Storybook 引用的源码目录,因此没有采用此方案。 我们在 Storybook Webpack 配置里插入 babel-plugin-istanbul 插件,相应配置如下。
babel-plugin-istanbul 设置 exclude 过滤掉无需测试的 Semi 源码,如 story 文件和打包相关的文件等。我们在根目录下的新建一个 nyc.config.js,对代码覆盖率统计相关的变量进行配置,在上面的 Storybook 中引用相关的配置。
- 阶段二:收集代码覆盖率报告
我们按照 Cypress 文档进行配置,在运行 Cypress 测试用例时统计 Semi 源码覆盖率。 第一步,安装 @cypress/code-coverage 作为项目的 dev 依赖,在plugin/index.js 引入依赖。
第二步,在 support/index.js 添加引用。
@cypress/code-coverage 会合并 Cypress 的单个测试并生成合并后的测试结果。它也是调用的 nyc 去生成对应的测试报告。
合并测试报告
生成完两个的代码覆盖率之后,我们借用 instanbul-combine 这个包把 Enzyme 和 Cypress 的代码覆盖率报告进行合并,并生成合并后的报告。它们的位置分别保存在:
- Enzyme:test/coverage/coverage-final.json
- Cypress:cypress/coverage/coverage-final.json
- 合并后:test/merged 运行命令合并代码覆盖率报告并生成合并后的报告:
可以看到合并后的代码覆盖率分别是:
- 语句覆盖率:86.5%
- 分支覆盖率:74.9%
- 函数覆盖率:84%
- 行覆盖率:86.7%

持续集成(CI)
手动运行测试命令,获取测试报告太枯燥了,我们现在结合 CI(Continuous Integration,持续集成) 工具自动化这个过程。
Github action
Github action 提供了持续集成的功能,我们希望在向仓库 push 代码或者仓库有 pull request 时,自动运行测试过程并合并测试报告。我们在仓库的 workflows 下添加 test.yml 文件。
这个 workflow 首先安装了项目的依赖,然后运行了测试用例、合并测试报告,最后将测试结果上传到 Codecov。
Codecov
在上面的 workflow 中,我们最后将代码覆盖率报告上传到了 Codecov 平台。Codecov 提供了覆盖率在线查看、PR 评论测试覆盖率报告和 badge 生成功能。 在 Codecov 平台我们可以查看文件的代码覆盖率。

在 Github PR 页面,运行完 test workflow 后,Codecov 会评论当前 PR 的代码覆盖率变化。评论内会显示哪个文件的覆盖率变化了多少。

Codecov 还可以生成一个 badge,展示仓库当前的代码覆盖率。我们打开 Codecov 的设置,复制 badge 的链接到仓库 README.md 内。

最后我们得到一个这样的 badge。

最后的话
Semi 组件库的测试方案前期使用 Jest + Enzyme,随着项目的迭代我们发现它无法满足我们的测试需求。经过对比业界主流的 E2E 测试框架,我们选择了 Cypress,它可以对 Jest 测试不到的场景进行补充,进一步提升我们的测试范围和代码覆盖率。
两个工具有各自的使用场景,在实际的项目中可以综合使用它们对组件库进行测试。最终,Semi 组件通过 Jest 和 Cypress 达到了 90% 的行覆盖率。除此之外,我们还通过 Chromatic 对 UI 进行回归测试,避免出现组件出现意料之外的 UI 变更。
展望未来,除了对组件的交互和 UI 进行测试外,组件的性能也是我们需要关注的问题。未来 Semi 还会增加组件性能相关的测试,使得研发可以感知到组件变更带来的性能损耗,避免组件在迭代时出现较大的性能问题。