logoAnt Design

⌘ K
  • 设计
  • 研发
  • 组件
  • 博客
  • 资源
  • 国内镜像
6.0.0-alpha.3
  • v6 的一些 CSS 琐事
  • 👀 视觉回归测试
  • 为什么禁用日期这么难?
  • 封装 Form.Item 实现数组转对象
  • 行省略计算
  • 📢 v4 维护周期截止
  • antd 里常用的 TypeScript 工具方法
  • 一个构建的幽灵
  • 当 Ant Design 遇上 CSS 变量
  • API 的历史债务
  • 灵动的 Notification
  • 色彩模型与颜色选择器
  • 主题拓展
  • 虚拟表格来了!
  • 快乐工作主题(一)
  • 动态样式去哪儿了?
  • Suspense 引发的样式丢失问题
  • 打包体积优化
  • 你好,GitHub Actions
  • 所见即所得
  • 静态方法之痛
  • SSR 静态样式导出
  • 依赖排查
  • 贡献者开发维护指南
  • 转载-如何提交无法解答的问题
  • 新的 Tooltip 对齐方式
  • 非必要的渲染
  • 如何成长为 Collaborator
  • Modal hook 的有趣 BUG
  • antd 测试库迁移的那些事儿
  • Tree 的勾选传导
  • getContainer 的一些变化
  • 组件级别的 CSS-in-JS
引言
起步
迁移
一、渲染:
二、交互 & 事件
三、DOM 元素
四、兼容性测试
Diff 之谜
pretty-format
⼀个解法
收工

antd 测试库迁移的那些事儿

2022-12-20
@li-jia-nan
@zombieJ

文章被以下专栏收录:

antd

Ant Design

一个 UI 设计体系
我有想法,去参与讨论
antd

Ant Design

Ant Design 官方专栏
我有想法,去参与讨论
antd

Ant Design

Juejin logoAnt Design 开源专栏
Juejin logo我有想法,去参与讨论
文档贡献者
Modal hook 的有趣 BUGTree 的勾选传导

相关资源

Ant Design X
Ant Design Charts
Ant Design Pro
Pro Components
Ant Design Mobile
Ant Design Mini
Ant Design Web3
Ant Design Landing-首页模板集
Scaffolds-脚手架市场
Umi-React 应用开发框架
dumi-组件/文档研发工具
qiankun-微前端框架
Ant Motion-设计动效
国内镜像站点 🇨🇳

社区

Awesome Ant Design
Medium
X
yuque logoAnt Design 语雀专栏
Ant Design 知乎专栏
体验科技专栏
seeconf logoSEE Conf-蚂蚁体验科技大会
加入我们

帮助

GitHub
更新日志
常见问题
报告 Bug
议题
讨论区
StackOverflow
SegmentFault

Ant XTech logo更多产品

yuque logo语雀-构建你的数字花园
AntV logoAntV-数据可视化解决方案
Egg logoEgg-企业级 Node.js 框架
Kitchen logoKitchen-Sketch 工具集
Galacean logoGalacean-互动图形解决方案
WeaveFox logoWeaveFox-前端智能研发
xtech logo蚂蚁体验科技
主题编辑器
Made with ❤ by
蚂蚁集团和 Ant Design 开源社区
loading

大家好,我是 @li-jia-nan。也是前几个月新加入 antd 的 Collaborator,有幸作为 Collaborators 之一,我开发了 FloatButton 组件和 QRCode 组件,以及一些其它维护工作,下面分享一下 antd 测试库迁移的那些事儿~

引言

在 antd@4.x 中,使用 enzyme 作为测试框架,然而由于 enzyme 缺乏维护,到了 React 18 时代已经很难⽀持。也因此不得不开始为 antd 开启漫⻓的 @testing-lib 迁移之路。

在迁移过程中,我承担了大概 antd 四分之一的工作量,这里主要记录一下迁移过程中遇到的问题。

感谢在此期间 @zombieJ @MadCcc @miracles1919 提供的帮助。

image

image

image

起步

在迁移之前,我们需要先搞清楚迁移的目的是什么。在 enzyme 中,大多数场景是测试了组件中的状态是否正确,或者 class 上的静态属性是否正常被赋值,这其实是不合理的,因为我们更重要的是需要关心“功能”是否正常,而非“属性”是否正确,因为源代码对使用者来说是黑盒,用户只关心组件是否正确。

基上,测试用例应该基于“行为”来编写,而非“实现”来编写(这也是 testing-library 的目标)。在这个原则上,会发现有几个用例是多余的(因为在实际代码中不会单独触发某些函数),将其删除也并没有影响到 test coverage。

当然了,这只是放弃 enzyme 的其中一个原因。更重要的是它缺乏维护,并且不支持 React 18 了。

迁移

一、渲染:

enzyme 支持三种方式的渲染:

  • shallow: 浅渲染,是对官方的 Shallow Renderer 的封装。将组件渲染成虚拟 DOM 对象,通过 Shallow Render 得到的组件不会有断言到子组件的部分,并且可以使用 jQuery 的方式访问组件的信息。

  • render: 静态渲染,它将 React 组件渲染成静态的 HTML 字符串,然后解析这段字符串,并返回一个实例对象,可以用来分析组件的 html 结构。

  • mount: 完全渲染,它将组件渲染加载成一个真实的 DOM 节点,用来测试 DOM API 的交互和组件的生命周期,用到了 jsdom 来模拟浏览器环境。

为了贴近浏览器现实场景,antd@4.x 选用 mount 来进行渲染,而在 @testing-library 中对应的则是 render 方法:

diff
-- import { mount } from 'enzyme';
++ import { render } from '@testing-library/react';
-- const wrapper = mount(
++ const { container } = render(
<ConfigProvider getPopupContainer={getPopupContainer}>
<Slider />
</ConfigProvider>,
);

二、交互 & 事件

enzyme 提供了 simulate(event) 方法来模拟事件触发和用户交互,event 为事件名称,而在 @testing-library 中对应的则是 fireEvent 方法:

diff
++ import { fireEvent } from '@testing-library/react';
-- wrapper.find('.ant-handle').simulate('click');
++ fireEvent.click(container.querySelector('.ant-handle'));

三、DOM 元素

在 enzyme 中,提供了一些内置的 api 来操作 dom,或者查找组件:

  • instance(): 返回测试组件的实例
  • at(index): 返回一个渲染过的对象
  • text(): 返回当前组件的文本内容
  • html(): 返回当前组件的 HTML 代码形式
  • props(): 返回组件的所有属性
  • prop(key): 返回组件的指定属性
  • state(): 返回组件的状态
  • setState(nextState): 设置组件的状态
  • setProps(nextProps): 设置组件的属性
  • find(selector): 根据选择器查找节点,selector 可以是 CSS 中的选择器,也可以是组件的构造函数,以及组件的 displayName 等

在 testing-library 中,没有提供这些 api(正如上面提到过的 - testing-library 更加注重行为上的测试),所以需要换成原生的 dom 操作:

diff
expect(ref.current.getPopupDomNode()).toBe(null);
-- popover.find('span').simulate('click');
-- expect(popover.find('Trigger PopupInner').props().visible).toBeTruthy();
++ expect(container.querySelector('.ant-popover-inner-content')).toBeFalsy();
++ fireEvent.click(popover.container.querySelector('span'));
++ expect(container.querySelector('.ant-popover-inner-content')).toBeTruthy();

四、兼容性测试

在大版本升级的同时,废弃了部分组件,但是并没有在 antd 中移除,比如 BackTop 组件,需要在组件中加入 warning 以保证兼容性,所以还需要对 warning 编写专门的单元测试:

diff
describe('BackTop', () => {
++ it('should console Error', () => {
++ const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
++ render(<BackTop />);
++ expect(errSpy).toHaveBeenCalledWith(
++ 'Warning: [antd: BackTop] `BackTop` is deprecated. Please use `FloatButton.BackTop` instead.',
++ );
++ errSpy.mockRestore();
++ });
});

Diff 之谜

在转换过程中,发现了⼀个神奇的现象,有些情况下,同样的 case 生成的 DOM 快照会不一样,也因此开始探索 React 18 到底变化了什么:

过去 enzyme 的 snapshot 对⽐是通过 enzyme-to-json 插件将 enzyme object 转换成序列化对象:

js
// jest.config.js
module.exports = {
// ...
snapshotSerializers: ['enzyme-to-json/serializer'],
};

到了 @testing-library/react 则直接通过调用 render 产⽣ dom 元素,然后对 dom 进⾏对⽐:

diff
-- import { mount } from 'enzyme';
++ import { render } from '@testing-library/react';
describe('xxx', () => {
it('yyy', () => {
-- const wrapper = mount(<Demo />);
++ const { container } = render(<Demo />);
-- expect(wrapper.render()).toMatchSnapshot();
++ expect(container.firstChild).toMatchSnapshot();
});
});

有趣的是,在⼀些测试⽤例中。它会挂掉,区别在于 React 18 有时候会少⼀些空⾏:

diff
<div>
--
Hello World
</div>

通过测试 dom 的 innerHTML 后发现,17 和 18 是⼀样的。所以在遇到问题之初,我们只是将测试用例简单的改成⽐较 innerHTML :

ts
expect(container.querySelector('.className').innerHTML).toMatchSnapshot();

但是,随着迁移变多,会逐渐发现这种情况不断发⽣。比较 innerHTML 也不是长久之计。于是开始探索为什么会出现这种情况。

pretty-format

pretty-format 是⼀个很有意思的库,它可以将任意对象转换成字符串。它的⼀个⽤途就是⽤于 jest 的 snapshot 对⽐。它的⼀个特点是可以⾃定义转换规则。

jest 中对⽐ snapshot 会先做⼀步 format,对于原⽣ dom、object 等常⻅对象。它已经内置了⼀套 plugins ⽤以做格式化转换:

html
<div>
<span>Hello</span>
<p>World</p>
</div>
↓
<div>
<span> Hello </span>
<p>World</p>
</div>

出现多余空格第⼀反应就是是否是因为 17 & 18 引⼊的 @testing-lib/react 版本不同,导致影响了 jest 依赖的 pretty-format 版本,经过检查都是⼀致的:

json
{
"devDependencies": {
"pretty-format": "^29.0.0",
"@testing-library/react": "^13.0.0"
}
}

这个判断不对后,那就是另⼀种情况。dom 中存在空元素,使得 pretty-format 可以感知,但是本身却不影响 innerHTML ,于是就写了⼀个简单的 test case:

ts
const holder = document.createElement('div');
holder.append('');
holder.append(document.createElement('a'));
expect(holder).toMatchSnapshot();
console.log(holder.innerHTML);

得到以下输出:

snap
// snapshot
exports[`debug exports modules correctly 1`] = `
<div>
<a />
</div>
`;
// console.log
<a></a>

和设想的⼀致,那么就很简单了。那么⼤概率就是 React 18 的 render 会忽略空元素。我们做⼀个简单的实验:

tsx
import React, { useEffect, useRef, version } from 'react';
const App: React.FC = () => {
const holderRef = useRef<HTMLDivElement>(null);
useEffect(() => {
console.log(holderRef.current?.childNodes);
}, []);
return (
<div ref={holderRef}>
<p>{version}</p>
</div>
);
};
export default App;

果不其然:

React 17React 18
NodeList(2) [text, p]NodeList [p]

检查⼀下 Fiber 节点信息,可以发现 React 17 会把空元素也作为 Fiber 节点,而 React 18 则会忽略空元素:

React 17:

image

React 18:

image

按图索骥就能找到相关 PR:

  • https://github.com/facebook/react/pull/22807

WX20230319-145539@2x

⼀个解法

antd 需要对 React16、17、18 都进⾏测试,如果 snapshot 不可⾏会造成太⼤成本。所以我们需要对 jest 进⾏改造。enzyme-to-json 则给了我灵感,我们可以修改 snapshot ⽣成逻辑来抹平 React 不同版本之间的 diff:

ts
expect.addSnapshotSerializer({
// 判断⼀下是否是 dom 元素,如果是的就⾛我们⾃⼰的序列化逻辑
// 代码简化过,真实判断需要更多逻辑,可以参考 antd 的 setupAfterEnv.ts
test: (element) => element instanceof HTMLElement,
// ...
});

然后接⼊ pretty-format,添加⾃⼰的逻辑:

ts
const htmlContent = format(element, {
plugins: [plugins.DOMCollection, plugins.DOMElement],
});
expect.addSnapshotSerializer({
test: '//...',
print: (element) => {
const filtered = htmlContent
.split(/[\n\r]+/)
.filter((line) => line.trim())
.map((line) => line.replace(/\s+$/, ''))
.join('\n');
return filtered;
},
});

收工

以上,是 antd 测试框架迁移时遇到的一些问题,希望对于需要迁移或者尚未开始编写测试用例的同学提供帮助。也欢迎大家加入 antd 社区,共同为开源奉献自己的力量。