ant-design/docs/blog/testing-migrate.zh-CN.md
2024-07-19 11:00:46 +08:00

295 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: antd 测试库迁移的那些事儿
date: 2022-12-20
author: li-jia-nan,zombieJ
zhihu_url: https://zhuanlan.zhihu.com/p/639249930
yuque_url: https://www.yuque.com/ant-design/ant-design/bunxvp7nz4y7bbhi
juejin_url: https://juejin.cn/post/7179115861176188983
---
大家好,我是 **[@li-jia-nan](https://github.com/li-jia-nan)**。也是前几个月新加入 antd 的 Collaborator有幸作为 Collaborators 之一,我开发了 **[FloatButton](/components/float-button-cn)** 组件和 **[QRCode](/components/qrcode-cn)** 组件,以及一些其它维护工作,下面分享一下 antd 测试库迁移的那些事儿~
## 引言
`antd@4.x` 中,使用 **[enzyme](https://enzymejs.github.io/enzyme)** 作为测试框架,然而由于 enzyme 缺乏维护,到了 React 18 时代已经很难⽀持。也因此不得不开始为 antd 开启漫⻓的 **[@testing-lib](https://testing-library.com/docs/react-testing-library/intro)** 迁移之路。
在迁移过程中,我承担了大概 antd 四分之一的工作量,这里主要记录一下迁移过程中遇到的问题。
> 感谢在此期间 [@zombieJ](https://github.com/zombieJ) [@MadCcc](https://github.com/MadCcc) [@miracles1919](https://github.com/miracles1919) 提供的帮助。
![image](https://user-images.githubusercontent.com/49217418/207530591-1faaf171-638b-40af-8d61-3f07cb60abe2.png)
![image](https://user-images.githubusercontent.com/49217418/207530491-4988ecc4-2da0-4a0c-ba5d-b9797edecd1a.png)
![image](https://user-images.githubusercontent.com/49217418/207530507-412f0244-3d88-4500-9eb4-054f3e112731.png)
## 起步
在迁移之前,我们需要先搞清楚迁移的目的是什么。在 `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 17 | React 18 |
| ----------------------- | -------------- |
| NodeList(2) \[text, p\] | NodeList \[p\] |
检查⼀下 `Fiber` 节点信息,可以发现 `React 17` 会把空元素也作为 `Fiber` 节点,而 `React 18` 则会忽略空元素:
> React 17:
![image](https://user-images.githubusercontent.com/49217418/207533725-fb8f9e4d-7f09-4a13-a04a-cbb2d3eb2aea.png)
> React 18:
![image](https://user-images.githubusercontent.com/49217418/207533740-328d10ea-d9bc-4469-bc00-f08e33857e6f.png)
按图索骥就能找到相关 PR
- https://github.com/facebook/react/pull/22807
![WX20230319-145539@2x](https://user-images.githubusercontent.com/49217418/226159376-497fd490-153e-4e88-92e2-29dda50b3426.png)
## ⼀个解法
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 社区,共同为开源奉献自己的力量。