美文网首页
我们开源了一个 Ant Design 的单元测试工具库

我们开源了一个 Ant Design 的单元测试工具库

作者: 袋鼠云数栈前端 | 来源:发表于2023-11-27 11:09 被阅读0次

    背景

    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 标识调用该方法前的预备条件,比如

    file

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

    file

    那么如何自动添加@example代码?
    首先根据源代码的结构化目录,我们可以很方便根据文件夹名获取到对应的组件名,并访问到这个组件对应的所有单测。

    file

    每个单测文件内包含了对应组件下所有的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

    file

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

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

    file
    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🌟。

    相关文章

      网友评论

          本文标题:我们开源了一个 Ant Design 的单元测试工具库

          本文链接:https://www.haomeiwen.com/subject/ogmzwdtx.html