废话开头
我这个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对应关系一并覆盖到对应的属性上,在对set
和get
进行单独处理,这里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单元测试分析的全部内容啦,单元测试的内容不止这些,但是作者在当初写这部分单元测试的时候发现这款内容十分有趣,于是就选挑了这些内容,涉及的知识也是很多的,如原型、代理和正则表达式
,也可以为我们平时解决问题带来一些思路
那么同志们下次再见!如有纰漏请多指教!