Ant Design Typography.Paragraph Jest单元测试分析

废话开头

我这个b又来了啊,写这篇文章兴趣呢还是由于上次的Typography.Paragraph的bug,在写测试用例覆盖它的时候,发现antd的单元测试也特别有意思,所以来给同志们分享一下

什么是单元测试

首先捏,单元测试简单来讲就是小区域的测试,官方定义可就要百度了喔~~。

在前端,单元测试对于超大型的软件来说十分重要,放在现实中我们来讲,开发团队除了主心骨往往是不稳定的,但流动的开发人员对超大型的软件项目熟悉度肯定有限,往往改了一个东西就引起来多个bug,再去改这些bug又触发更多,那么解决这种情况的方案之一就是单元测试,对于高频性的bug,使用单元测试对其覆盖,这样每次代码改动的时候,都去跑一遍单元测试,确保其不会影响其它因素,可以提高系统的稳定性和一定方面的开发效率

而antd这样的开源项目,大多数人对其源码的熟悉度更是有限,因此单元测试的重要性也不言而喻

但是antd的单元测试框架有很多种,除了jest还有vitest,尽管antd是一个基于react框架的开源项目,当然对于主仓库 ant design(其实还有react component下面的多个仓库)来说,其使用的单元测试框架是jest,同志们可以先跳转过去学习一下,juejin.cn/post/684490...

Typography.Paragraph Jest 源码分析

那么言归正传,我这个b今天要讲的到底是个什么玩意

首先捏,我们知道在jest中渲染Dom并不像浏览器当中那么容易控制,jest中渲染Dom也并不是在浏览器中渲染,因此想要按我们心意随心随意的为Dom定制环境就显得很重要了(宽高行高等等),antd是怎么解决这个问题的呢,有请源码,我们打开typography目录下的_test_,打开ellipsis.test.tsx文件

js 复制代码
mockRectSpy = spyElementPrototypes(HTMLElement, {
  offsetHeight: {
    get() {
      let html = this.innerHTML;
      html = html.replace(/<[^>]*>/g, '');
      const lines = Math.ceil(html.length / LINE_STR_COUNT);
      return lines * 16;
    },
  },
  offsetWidth: {
    get: () => {
      getWidthTimes += 1;
      return 100;
    },
  },
  getBoundingClientRect() {
    let html = this.innerHTML;
    html = html.replace(/<[^>]*>/g, '');
    const lines = Math.ceil(html.length / LINE_STR_COUNT);
    return { height: lines * 16 };
  },
});

我们可以看到啊,这里使用了一个函数spyElementPrototypes,传了两个参数,其中一个是一个对象,含有offsetHeight, offsetWidth, getBoundingClientRect的字样,熟悉Dom的同志就知道了,这是htmlElment上的属性,那么它这个方法是要干嘛呢,这就要溯源找找这个方法了

js 复制代码
import { spyElementPrototypes } from 'rc-util/lib/test/domHook';

这个方法的具体内容是

typescript 复制代码
export function spyElementPrototypes<T extends ElementClass>(
  elementClass: T,
  properties: Record<string, Property>,
) {
  const propNames = Object.keys(properties);
  const originDescriptors = {};

  propNames.forEach(propName => {
    const originDescriptor = Object.getOwnPropertyDescriptor(elementClass.prototype, propName);
    originDescriptors[propName] = originDescriptor || NO_EXIST;

    const spyProp = properties[propName];

    if (typeof spyProp === 'function') {
      // If is a function
      elementClass.prototype[propName] = function spyFunc(...args) {
        return spyProp.call(this, originDescriptor, ...args);
      };
    } else {
      // Otherwise tread as a property
      Object.defineProperty(elementClass.prototype, propName, {
        ...spyProp,
        set(value) {
          if (spyProp.set) {
            return spyProp.set.call(this, originDescriptor, value);
          }
          return originDescriptor.set(value);
        },
        get() {
          if (spyProp.get) {
            return spyProp.get.call(this, originDescriptor);
          }
          return originDescriptor.get();
        },
        configurable: true,
      });
    }
  });

  return {
    mockRestore() {
      propNames.forEach(propName => {
        const originDescriptor = originDescriptors[propName];
        if (originDescriptor === NO_EXIST) {
          delete elementClass.prototype[propName];
        } else if (typeof originDescriptor === 'function') {
          elementClass.prototype[propName] = originDescriptor;
        } else {
          Object.defineProperty(elementClass.prototype, propName, originDescriptor);
        }
      });
    },
  };
}0

可以看到,这个方法接受了两个参数,elementClass: T, properties: Record<string, Property>,结合上面mockRectSpy = spyElementPrototypes(HTMLElement, { offsetHeight: { ... }, ... });的使用我们就可以知道,这第一个参数是一个Dom元素类,而第二个参数则是要访问的参数的属性,接下来我们来看看它做了什么

js 复制代码
const propNames = Object.keys(properties); 
propNames.forEach(propName => { ...});

首先遍历了第二个参数的key值,此时propNames也就是['offsetHeight', 'offsetWidth', 'getBoundingClientRect'],然后对其进行遍历

js 复制代码
const originDescriptors = {};
propNames.forEach(propName => { 
    const originDescriptor = Object.getOwnPropertyDescriptor(elementClass.prototype, propName); 
    originDescriptors[propName] = originDescriptor || NO_EXIST; 
});

在每一次遍历中,通过调用Object.getOwnPropertyDescriptor(elementClass.prototype, propName),拿到传入的Dom元素类的对应属性的原始描述符,并将它设置到原始描述符的map(originDescriptors)

js 复制代码
    const spyProp = properties[propName];

    if (typeof spyProp === 'function') {
      // If is a function
      elementClass.prototype[propName] = function spyFunc(...args) {
        return spyProp.call(this, originDescriptor, ...args);
      };
    }

接下来用spyProp引用key值对应的value,如offsetHeight定义的 { get() { ... },然后来到第一个判断,typeof spyProp === 'function',如果value定义的是一个方法,那么将会修改对应的原型上原来的属性,并将它替换为用户所定义方法,也就是elementClass.prototype[propName] = function spyFunc(...args),并将参数一并传给用户定义的方法中

下面来走第二个条件,如果用户定义的value不是一个方法

js 复制代码
     else {
      // Otherwise tread as a property
      Object.defineProperty(elementClass.prototype, propName, {
        ...spyProp,
        set(value) {
          if (spyProp.set) {
            return spyProp.set.call(this, originDescriptor, value);
          }
          return originDescriptor.set(value);
        },
        get() {
          if (spyProp.get) {
            return spyProp.get.call(this, originDescriptor);
          }
          return originDescriptor.get();
        },
        configurable: true,
      });
    }

这里通过调用Object.defineProperty,劫持了elementClass原型上对应的属性(propName)

首先通过...spyProp将用户定义的属性value对应关系一并覆盖到对应的属性上,在对setget进行单独处理,这里set是属性被赋值时会触发的函数,并返回一个值,这个值将会修改属性,get是属性被访问时会触发的函数,并返回一个值,这个值会被当做属性的值

js 复制代码
set(value) { 
    if (spyProp.set) { 
        return spyProp.set.call(this, originDescriptor, value); 
    } 
    return originDescriptor.set(value); 
},

在修改set函数的时候进行判断,如果用户定义的value上有set函数if (spyProp.set),那么将它替换成用户定义的,否则用原来原型上定义的,get同理

最后整个函数return了一个方法mockRestore(),用来还原用户修改的原型,所以整个spyElementPrototypes乍一看内容很复杂,实际还是很简单的

现在我们再回到它使用的场景

js 复制代码
const LINE_STR_COUNT = 20;

mockRectSpy = spyElementPrototypes(HTMLElement, {
  offsetHeight: {
    get() {
      let html = this.innerHTML;
      html = html.replace(/<[^>]*>/g, '');
      const lines = Math.ceil(html.length / LINE_STR_COUNT);
      return lines * 16;
    },
  },
  offsetWidth: {
    get: () => {
      getWidthTimes += 1;
      return 100;
    },
  },
  getBoundingClientRect() {
    let html = this.innerHTML;
    html = html.replace(/<[^>]*>/g, '');
    const lines = Math.ceil(html.length / LINE_STR_COUNT);
    return { height: lines * 16 };
  },
});

我们现在就知道它是要干嘛的,它其实就是劫持了每一个htmlelment上的offsetHeight、offsetWidth、getBoundingClientRect,将它修改为自己所定义,用来模拟在jest环境中元素的高度、宽度等内容,我们先来看看offsetHeight修改了什么东西,它是怎么计算高度的呢

js 复制代码
  const LINE_STR_COUNT = 20;
  offsetHeight: {
    get() {
      let html = this.innerHTML;
      html = html.replace(/<[^>]*>/g, '');
      const lines = Math.ceil(html.length / LINE_STR_COUNT);
      return lines * 16;
    },
  },

首先get()被调用时,this指向它被调用的环境,也就是属性对应的Dom元素,这个函数访问了它的html内容,let html = this.innerHTML,然后将它的标签全部清除,html.replace(/<[^>]*>/g, ''),这是一个正则表达式,/<匹配以<开头的字符串,[^>]*匹配任意个不是>的字符串,>/g匹配以>结尾的字符串,并且全局匹配多次,也就是说这个正则表达式匹配了所有类似于<span>或者</span>的内容,并将它清空

清空下来的内容其实就是实际展示给用户的字符串了,接下来将它除于每行允许的字符串数,也就是LINE_STR_COUNT = 20,再乘以每行设定的高度lines * 16,就得出了元素的高度

而其他的修改其实也就同样的原理,通过强制设定行高和每行允许的字符串,来保证元素高宽可控,就不多介绍啦~~

以上就是Ant Design Typography.Paragraph Jest单元测试分析的全部内容啦,单元测试的内容不止这些,但是作者在当初写这部分单元测试的时候发现这款内容十分有趣,于是就选挑了这些内容,涉及的知识也是很多的,如原型、代理和正则表达式,也可以为我们平时解决问题带来一些思路

那么同志们下次再见!如有纰漏请多指教!

相关推荐
虾球xz9 分钟前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇14 分钟前
HTML常用表格与标签
前端·html
疯狂的沙粒18 分钟前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员34 分钟前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐36 分钟前
前端图像处理(一)
前端
程序猿阿伟43 分钟前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒1 小时前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪1 小时前
AJAX的基本使用
前端·javascript·ajax
力透键背1 小时前
display: none和visibility: hidden的区别
开发语言·前端·javascript
程楠楠&M1 小时前
node.js第三方Express 框架
前端·javascript·node.js·express