logoAnt Design

⌘ K
  • 设计
  • 研发
  • 组件
  • 博客
  • 资源
  • 国内镜像
5.25.4
  • 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
    Twitter
    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-互动图形解决方案
    xtech logo蚂蚁体验科技
    主题编辑器
    Made with ❤ by
    蚂蚁集团和 Ant Design 开源社区

    大家好,我是 @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 社区,共同为开源奉献自己的力量。