我们是袋鼠云数栈 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;

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

后记

目前queryfireXXX之间的配合使用,需要通过入参传递的形式使用,如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. 软件界旷世之架:测试驱动开发(TDD)之争

    摘要:在软件行业中,神仙打架的名场面,那就不得不提的是2014年的那场--测试驱动开发(TDD)之争. 在历史上有很多精彩绝伦的神仙打架,比如数学界的牛顿和莱布尼茨关于微积分的旷世之争:比如量子物理中 ...

  2. 教你如何在Python中读,写和解析CSV文

    摘要:在这篇文章中关于"在Python如何阅读CSV文件"中,我们将学习如何读,写和解析的CSV文件的Python. 您知道将表格数据存储到纯文本文件背后的机制是什么吗?答案是CS ...

  3. 华为AppCube入选Forrester《中国低代码平台市场分析报告》

    摘要:知名研究与分析机构Forrester于11月11日发布<中国低代码平台市场分析报告(The State Of Low-Code Platforms In China)>,AppCub ...

  4. vue2升级vue3: h、createVNode、render、createApp使用

    h.createVNode 杂乱笔记,凑合着看,不喜勿喷! h 函数是什么 h 函数本质就是 createElement() 的简写,作用是根据配置创建对应的虚拟节点,在vue 中占有极其重要的地位! ...

  5. 新能源物流车行业如何服务升级?地上铁联合火山引擎VeDI“破题”

    今年以来,克服种种不利因素影响,我国工业经济实现企稳回升,一些行业逆势而上,表现亮眼.尤其是新能源车行业,得益于技术创新与系列重大政策利好推动,在国内和国外市场均实现了快速增长,中国汽车工业协会最新统 ...

  6. Mac 向日葵设置

  7. Spring Boot Admin 离线实例

    一直处于离线状态 spring.boot.admin.client.instance.prefer-ip Use the ip-address rather then the hostname in ...

  8. 【主流技术】聊一聊 Redis 的基本结构和简单应用(一)

    目录 前言 一.String 类型 二.List 类型 三.Hash 类型 四.Set 结构 五.Sort Set (Zset)结构 六.文章小结 前言 Redis 是目前互联网后端的热门中间件之一, ...

  9. Go--命名规则

    在Go语言中,项目名和文件名的命名规则有一些建议和惯例.以下是一些常见的规则和最佳实践: 项目名: 项目名应该简短.有意义,并能够清晰地表达项目的目的或功能. 项目名通常使用小写字母,使用连字符或下划 ...

  10. @Constraint注解,做特殊的入参校验

    // @Constraint 是 Java 中的注解之一,用于标记自定义的约束注解.约束注解通常用于数据验证,用来限制字段的取值或格式,确保数据的合法性. @Constraint(validatedB ...