我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:佳岚

欢迎大家点一个小小的 Star ant-design-testing

背景

antd-design 是国内最受欢迎的 React 组件库,不少公司会基于 antd 封装自己的业务组件,社区中也有大量基于 antd 进行二次封装的组件库,对于这些情况,势必需要写单元测试去保证组件的可靠性。 但带来的问题是,涉及到对 antd 原生组件的一些事件触发,节点查询,往往需要去查看 antd 源码或页面审查元素才能知道如何书写正确的单测。

你往往会遇到以下问题而去查看源码

  • 我想要触发某个事件回调,需要fireEvent哪个元素?
  • fire的事件到底是mousedown触发还是click触发的?
  • 查询antd元素时的选择器是啥?
  • 如何正确的书写这个组件的单测,同步还是异步?
  • etc…

discussions

它实现了什么?

  1. 为每个组件提供事件回调的触发方法,如通过简单的通过input.fireChange(”xxx”) 即可触发Input组件的onChange方法。
  2. 提供查询功能,快捷查询具体的组件,如一个Form里有两个Input,这时我就可以用input.query(container, 1)快速拿到第二个Input。
  3. 对于提供的方法,以 JsDoc 的形式提供单测模板,简化翻阅文档的过程,提高上手容易度。

使用

进行全局配置

如果你的项目中对 antd 指定了前缀,如

ConfigProvider.config({
prefixCls: 'myant',
});

则你需要在你的jest setupTests文件中加入以下代码

import { provider } from 'ant-design-testing';

provider({ prefixCls: 'myant' });

在jest.config.js中引入setupTests文件

module.exports = {
setupFilesAfterEnv: ['./tests/setupTests.ts'],
};

基本使用

在单测文件中,引入对应的组件,每个组件会以小驼峰命名,如下

import { select } from 'ant-design-testing';

it('test select a option', () => {
const fn = jest.fn();
const { container } = render(
<Select onChange={fn} getPopupContainer={(node) => node.parentNode} options={[{ label: 1, value: 1 }]} />
);
select.fireOpen(container);
select.fireSelect(container, 0);
expect(fn).toBeCalled();
});

在上述的简单案例中,我们使用简单的两行代码,就完成了两步交互操作

  1. 打开下拉菜单
  2. 选择第1个下拉菜单项

我们再来个稍微复杂一点点点的案例:输入用户名,密码,然后选择角色,之后点击按钮提交

封装的MyForm组件

const MyForm = ({ onSubmit }: any) => {
const [form] = Form.useForm();
return (
<Form form={form}>
<Form.Item name="username">
<Input />
</Form.Item>
<Form.Item name="password">
<Input type="password" />
</Form.Item>
<Form.Item name="role">
<Select>
<Select.Option value="admin">管理员</Select.Option>
</Select>
</Form.Item>
<Button
htmlType="submit"
onClick={() => {
onSubmit(form.getFieldsValue());
}}
>
提交
</Button>
</Form>
);
};

则单测可以这样写,myForm.test.tsx

import { select, input, button } from 'ant-design-testing';

it('test MyForm', () => {
const fn = jest.fn();
const { container } = render(
<MyForm onSubmit={fn}/>
);
const userName = input.query(container)!;
const password = input.query(container, 1)!;
input.fireChange(userName, 'zhangsan')
input.fireChange(password, '123456') select.fireOpen(container);
select.fireSelect(document.body, 0) button.fireClick(container); expect(fn).toBeCalledWith({username: 'zhangsan', password: '123456', role: 'admin'});
});

通过xxx.query我们能够快速定位到对应的输入框, 并和fireXXX方法配合使用,同于不同的组件,提供的query也不尽相同,如Select组件就提供了

  • query - 查询Select的根容器
  • queryInput - 查询Select中的实际Input表单
  • querySelector - 查询实际触发下拉的容器
  • queryDropdown - 查询下拉菜单
  • queryOption - 查询下拉菜单中的选项
  • queryClear - 查询清除按钮

需要额外注意的点是:

  • query如果查询不到,不会抛出异常;fireXXX方法如果查询不到元素,会直接抛出异常
  • 如果是想查询像Select的下拉框、Dropdown的下拉菜单、Popconfirm等具有getPopupContainer属性的组件,默认是挂载在document.body下。因此,查询时请务必加上

    getPopupContainer={(node) => node.parentNode} 或者传入的container使用document.body, 如select.queryDropdown(document.body)

代码文档生成

对于每个组件的query or fireXXX,在实际书写单测时还有很多细节点需要留意,如某些异步组件,需要使用 useFakeTimers 才能跑通测试,所以大部分内容我们都通过JsDoc的形式提醒开发者如何去正确使用。

如果你正在测试一个组件,但你并不知道是否要添加 fakeTimers,我们会添加@prerequisite 标识调用该方法前的预备条件,比如

对于每个暴露的方法,我们通过@example提供一个基础测试案例,来帮助你书写, 这个工作量可能会很大且频繁,所以我们需要一个脚本自动帮产物代码添加案例code。

那么如何自动添加@example代码?

首先根据源代码的结构化目录,我们可以很方便根据文件夹名获取到对应的组件名,并访问到这个组件对应的所有单测。

每个单测文件内包含了对应组件下所有的examle中的代码,类似于这样

describe("Test breadcrumb's fire functions", () => {
test('test fireClick', () => {
// 略
breadCrumb.fireClick(container, 1);
expect(fn).toBeCalled();
}); test('test query', () => {
// 略
expect(breadCrumb.query(container)).toBe(getByTestId('test1'));
expect(breadCrumb.query(container, 1)).toBe(getByTestId('test2'));
}); test('test queryBreadcrumbItem', () => {
// 略
expect(breadCrumb.queryBreadcrumbItem(container)).toBe(queryByText('Foo'));
expect(breadCrumb.queryBreadcrumbItem(container, 1)).toBe(queryByText('Bar'));
});
});

那么我们的实现思路就很清晰了,遍历所有组件,访问这个组件的每个单测,并记录代码,最后在 build 时再把单测代码以JsDoc形式添加到每个 build 产物中。

那么下面,我们就需要知道每个test块 或 it块 代码它是在测试组件的哪个功能?

我们同样要利用JsDoc来实现这点,为每个单测块添加一个@link类型的注释来链接对应所测试的功能,像下面这样

describe("Test breadcrumb's fire functions", () => {
/**
* @link fireClick
*/
test('test fireClick', () => {
// 略
breadCrumb.fireClick(container, 1);
expect(fn).toBeCalled();
}); /**
* @link query
*/
test('test query', () => {
// 略
expect(breadCrumb.query(container)).toBe(getByTestId('test1'));
expect(breadCrumb.query(container, 1)).toBe(getByTestId('test2'));
});
});

那么我们只需要获取@link后的属性值就能知道这块单测在测哪个功能了。

获取单测文件中每个 test 中的代码并不难,通过babel解析成ast我们可以很容易做到,但很遗憾的是, babel 解析不了JsDoc

那么我们只能转向去使用Typescript解析器了,typescript提供了Typescript Complier API ,不过文档内容相对少且乱https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API。

所以我们采用[ts-morph](https://ts-morph.com/)去实现, typescript 能额外解析出每个JsDocTag  ASTViewer

  1. 首先我们需要加载解析源代码中的单测文件,每个SourceFile文件包含了解析的AST节点信息

    const { SyntaxKind, Project } = require('ts-morph');
    
    const project = new Project();
    project.addSourceFilesAtPaths('src/**/__tests__/*.tsx');
    const testFiles = project.getSourceFiles();
  2. 遍历所有SourceFile ,每个文件对应一个组件,  访问单测块中的代码,并保存到结果集中results中

    // 根据文件路径名解析出对应的组件名
    const fileName = sourceFile.getBaseName();
    const componentName = fileName.match(/^(\w+)\.test\.tsx/)[1] || ''; let describeCallExpression = null; // 先拿到jest的describe方法调用代码
    const expressionStatements = sourceFile.getStatements().filter((s) => s.isKind(SyntaxKind.ExpressionStatement));
    expressionStatements.forEach((s) => {
    const callExpressions = s.getChildrenOfKind(SyntaxKind.CallExpression);
    callExpressions.forEach((callExpression) => {
    const identifier = callExpression.getFirstChildByKind(SyntaxKind.Identifier);
    if (identifier?.getText() === 'describe') {
    describeCallExpression = callExpression;
    }
    });
    }); if (!describeCallExpression) return; // 第二个调用参数就是我们要的代码节点
    const [_, describeFunc] = describeCallExpression.getArguments();
  3. 定位到 describe 后,访问其中的 test 块或者 it 块,并根据 @link注释解析出每个 test 块对应测试的功能

    // 声明一个map来保存每个方法对应的代码
    const exampleCodeMap = new Map(); describeFunc
    .asKind(SyntaxKind.ArrowFunction)
    .getStatements()
    .forEach((s) => {
    if (s.isKind(SyntaxKind.ExpressionStatement)) {
    const jsDoc = s.getFirstChildByKind(SyntaxKind.JSDoc);
    // Only record test example code with @link jsdoc tag
    const jsDocLinkTag = jsDoc
    ?.getChildrenOfKind(SyntaxKind.JSDocTag)
    .find((tag) => tag.getFirstChildByKind(SyntaxKind.Identifier)?.getText() === 'link');
    const linkMethodName = jsDocLinkTag?.getCommentText();
    if (!linkMethodName) return; s.getChildrenOfKind(SyntaxKind.CallExpression).forEach((callExpression) => {
    const identifier = callExpression.getFirstChildByKind(SyntaxKind.Identifier);
    if (['test', 'it'].includes(identifier?.getText())) {
    const [_, testFn] = callExpression.getArguments();
    const exampleCode = testFn.asKind(SyntaxKind.ArrowFunction).getBodyText();
    exampleCodeMap.set(linkMethodName, exampleCode);
    }
    });
    }
    }); results.push({ componentName, exampleCodeMap });

    到这里,我们就拿到了所有解析结果

    results = [
    { componentName: “button”, exampleCodeMap: map{”fireClick”: “xxxcode”} }
    ]
  4. 后面就是再解析 build 后的每个 d.ts 文件,原先已有的注释我们会全部保留,再拼上我们之前从源代码中拿到的代码

    dist/cjs/alert/index.d.ts

    /**
    * Fires onClose function
    */
    export declare function fireClose(container: IContainer): void;
    /**
    * Returns the `index` container of Alert
    * @param index default is `0`
    */
    export declare function query(container: IContainer, index?: number): HTMLElement | null;
    const outputProject = new Project();
    // 运行完build命令后,读取类型文件
    outputProject.addSourceFilesAtPaths('dist/(esm|cjs)/*/index.d.ts'); results.forEach(({ componentName, exampleCodeMap }) => {
    const outputFile = outputProject.getSourceFile((file) => {
    const dirPath = file.getDirectoryPath();
    const dirName = path.basename(dirPath);
    return componentName === dirName;
    });
    if (!outputFile || !exampleCodeMap.size) return; outputFile.getStatements().forEach((s) => {
    // 处理产出的declare可能为箭头函数声明或普通函数声明
    if (![SyntaxKind.VariableStatement, SyntaxKind.FunctionDeclaration].includes(s.getKind())) return;
    const declaration = s.isKind(SyntaxKind.VariableStatement) ? s.getDeclarations()?.[0] : s;
    if (!declaration) return; // Find method declaration and if it has example test code, add jsdoc
    const methodName = declaration.getFirstChildByKind(SyntaxKind.Identifier)?.getText();
    if (!exampleCodeMap.has(methodName)) return;
    const testExampleCode = exampleCodeMap.get(methodName); // If method has jsdoc already, append it, or add a new jsdoc with example code
    const jsDoc = s.getJsDocs().at(0) || s.addJsDoc({ tags: [] });
    jsDoc.addTag({ tagName: 'example', text: '\n' + testExampleCode });
    }); outputFile.saveSync();
    });
  5. 运行该脚本,则会自动覆盖掉产生新的 d.ts 文件

    import type { IContainer } from '../interface';
    /**
    * Fires onClose function
    * @example
    * const fn = jest.fn();
    * const { container } = render(<Alert message="Warning Text" type="warning" closable onClose={fn} />);
    * alert.fireClose(container);
    * expect(fn).toBeCalled();
    */
    export declare function fireClose(container: IContainer): void;

至此,我们完成了自动生成案例代码文档的功能,这并不复杂,我们的期望是代码即是最好的文档。

后记

目前query与fireXXX之间的配合使用,需要通过入参传递的形式使用,如fireClick(query(container, 1)) ,这其实并不方便,后续我们会考虑提供链式调用的方式去使用。

工具库提供的API可能并没有设计的非常完善,如果有相关的建议或者添加新 API 的诉求,可以给我们提 issue 或 pr,仓库地址ant-design-testing。也希望大家不要吝啬来一个 star。

最后

欢迎关注【袋鼠云数栈UED团队】~

袋鼠云数栈UED团队持续为广大开发者分享技术成果,相继参与开源了欢迎star

我们开源了一个 Ant Design 的单元测试工具库的更多相关文章

  1. ElementUI(vue UI库)、iView(vue UI库)、ant design(react UI库)中组件的区别

    ElementUI(vue UI库).iView(vue UI库).ant design(react UI库)中组件的区别: 事项 ElementUI iView ant design 全局加载进度条 ...

  2. 阿里开源项目之Ant Design Pro

    本篇文章主要包含的内容有三个方面. 第一.Ant Design Pro简介; 第二.Ant Design Pro能做什么; 第三.初步使用; 我相信通过这三个方面的讲解能让你大概知道Ant Desig ...

  3. react的ant design的UI组件库

    PC官网:https://ant.design/ 移动端网址:https://mobile.ant.design/docs/react/introduce-cn antd-mobile :是 Ant ...

  4. PyQt-Fluent-Widgets:一个 Fluent Design 风格的组件库

    简介 这是一个使用 PyQt/PySide 编写的 Fluent Design 风格的组件库,包含最常用的组件,支持亮暗主题无缝切换.实际上此项目是从 Groove Music 项目剥离出来的子项目, ...

  5. Ant Design of Vue 组件库的使用

    文档里面很清楚 安装步骤    这是全部引入的 1  有的组价涉及到汉化的问题 import moment from 'moment' import '../../../../node_modules ...

  6. MagicalRecord,一个简化CoreData操作的工具库

    简介 项目主页:https://github.com/magicalpanda/MagicalRecord 实例下载:https://github.com/ios122/MagicalRecord 在 ...

  7. apache.commons.io.IOUtils: 一个很方便的IO工具库(比如InputStream转String)

    转换InputStream到String, 比如 //引入apache的io包 import org.apache.commons.io.IOUtils; ... ...String str = IO ...

  8. 使用Ant Design写一个仿微软ToDo

    实习期的第一份活,自己看Ant Design的官网学习,然后用Ant Design写一个仿微软ToDo. 不做教学目的,只是记录一下. 1.学习 Ant Design 是个组件库,想要会用,至少要知道 ...

  9. Ant Design Pro快速入门

    在上一篇文章中,我们介绍了如何构建一个Ant Design Pro的环境. 同时讲解了如何启动服务并查看前端页面功能. 在本文中,我们将简单讲解如何在Ant Design Pro框架下实现自己的业务功 ...

  10. 前端自动分环境打包(vue和ant design)

    现实中的问题:有时候版本上线的时候,打包时忘记切换环境,将测试包推上正式服务器,那你就会被批了. 期望:在写打包的命令行的时候就觉得自己在打包正式版本,避免推包时候的,不确信自己的包是否正确. 既然有 ...

随机推荐

  1. 实时媒体AI,打破内容创作天花板,加速视频创新

    摘要:互联网视频经过几代发展,用户体验快速升级,对创新玩法.互动性要求越来越高.那如何解决这些问题? 媒体AI,是行业共识.华为云提供云原生实时媒体AI能力,联合伙伴打造AI算法开放生态市场,加速视频 ...

  2. 技术实践丨Prometheus+Grafana搭建HBase监控仪表盘

    摘要:Grafana+Prometheus是目前非常流行的监控方案,图形化展示非常强大. 简介 ● HBase是一个分布式的.面向列的开源数据库,开源版本默认提供了展示集群信息的UI,可以显示集群的实 ...

  3. 深度克隆从C#/C/Java漫谈到JavaScript真复制

    如果只想看js,直接从JavaScript标题开始. 在C#里面,深度clone有System.ICloneable.创建现有实例相同的值创建类的新实例 克隆原理 值类型变量与引用类型变量 如果我们有 ...

  4. appuploader 常规使用登录方法

      转载:登录appuploader 登录appuploader   常规使用登录方法 双击appuploader.exe 启动appuploader 点击底部的未登录,弹出登录框 在登录框内输入ap ...

  5. LAS Spark+云原生:数据分析全新解决方案

    更多技术交流.求职机会,欢迎关注字节跳动数据平台微信公众号,回复[1]进入官方交流群 随着数据规模的迅速增长和数据处理需求的不断演进,云原生架构和湖仓分析成为了现代数据处理的重要趋势.在这个数字化时代 ...

  6. 由浅入深,揭秘企业级OLAP数据引擎ByteHouse

    更多技术交流.求职机会,欢迎关注字节跳动数据平台微信公众号,回复[1]进入官方交流群 在字节跳动各产品线飞速成长的过程中,对数据分析能力也提出了更高的要求,现有的主流数据分析产品都没办法完全满足业务要 ...

  7. -source 1.5 中不支持静态接口方法调用

    -source 1.5 中不支持静态接口方法调用 (请使用 -source 8 或更高版本以启用静态接口方法调用) File -> Project Structure File -> Se ...

  8. Kubernetes(K8S) yaml 介绍

    使用空格做为缩进 缩进的空格数目不重要, 只要相同层级的元素左侧对齐即可 低版本缩进时不允许使用 Tab 键, 只允许使用空格 使用#标识注释, 从这个字符一直到行尾, 都会被解释器忽略 --- 使用 ...

  9. 台大李宏毅机器学习公开课2020版登陆B站

    课程简介: 真正大师的课程往往都是免费的,诸如吴恩达,李飞飞等.不过大家应该对李宏毅老师也不陌生吧?很多机器学习初学者,首选李宏毅老师.毕竟中文授课,而且他讲课通俗易懂.课程案例生动有趣(还记得宝可梦 ...

  10. 【django drf】 阶段练习

    目录 需求 settings.py views.py urls.py serializers.py permissions.py page.py authenticate.py model.py 权限 ...