DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师。
官方网站:devui.design
Ng组件库:ng-devui(欢迎Star)
官方交流:添加DevUI小助手(devui-official)
DevUIHelper插件:DevUIHelper-LSP(欢迎Star)css
在上一篇文章中,咱们以Angular官方自带的测试用例为引,介绍了如何在20分钟内将单元测试集成到已有项目中;并提出从公共方法、管道等逻辑相对简单又比较稳定的代码块入手,开启书写单元测试之路。这一篇文章,主要来回答如下两个问题:html
当咱们在code-coverage 的报告中查看本身项目中的测试覆盖报告时,能够看到相似下图中的信息,因此文章的第一部分,咱们对报告中的各类覆盖度作一个简短的介绍。前端
语句覆盖度 = 测试覆盖到的语句数 / 代码文件的总语句数node
分支覆盖度 = 测试覆盖到的分支数 / 代码文件的总分支数git
函数覆盖度 = 测试覆盖到的函数数量 / 代码文件的总函数数量,哪怕被覆盖的函数只覆盖到一行代码也算github
行覆盖度 = 测试覆盖到的行数 / 代码文件的总行数 这里所谓的“行”的概念,跟咱们在代码编辑器中看到的是不同的。在代码编辑器中一个四五百行的文件,在行覆盖度的计算中,分母可能只有两三百。好比说,下面咱们认为是不少行的代码实际上只是一行。web
看到这里,知识彷佛并无增长,由于上面只是把几种覆盖度计算的公式简单罗列了一下。 因此咱们不妨来看一个例子。假设有一个简单的颜色计算pipe,代码以下。 先花30s,本身算一算,上述代码的语句、分支、函数、行数分别为多少。canvas
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({name: 'scoreColor'})
export class ScoreColorPipe implements PipeTransform {
transform(value: number): string {
if(value >= 80) return 'score-green';
if(value >= 60) return 'score-yellow';
return 'score-red';
}
}
复制代码
算完了,公布一下答案。按照下方代码的注释部分进行计算,能够得知该代码文件包含7条语句,4个分支,1个函数,4行。浏览器
import { Pipe, PipeTransform } from '@angular/core';
// 行 + 1,语句 + 2
@Pipe({name: 'scoreColor'})
export class ScoreColorPipe implements PipeTransform {
// 函数 + 1
transform(value: number): string {
// 行 + 1,语句 + 2,分支 + 2
if(value >= 80) return 'score-green';
// 行 + 1,语句 + 2,分支 + 2
if(value >= 60) return 'score-yellow';
// 行 + 1,语句 + 1
return 'score-red';
}
}
复制代码
除了这四种覆盖以外,还有条件覆盖、路径覆盖、断定条件覆盖、组合覆盖等其余四种覆盖,如何用测试用例完成上述维度的覆盖,这里不深刻展开,想了解的话,能够参考:www.jianshu.com/p/8814362ea…markdown
另外,团队中若是有多个成员一同开发,能够经过配置karma.conf.js 的方式来强制最低单元测试覆盖率。
coverageIstanbulReporter: {
reports: [ 'html', 'lcovonly' ],
fixWebpackSourcePaths: true,
thresholds: {
statements: 80,
lines: 80,
branches: 80,
functions: 80
}
}
复制代码
配置之后,运行测试的时候若是没有达到目标覆盖率,就会有相关提醒。
讲完测试覆盖度及其计算方式,咱们来看一个公共业务组件(header.component.ts),经过介绍各类场景下如何编写测试用例将上述四种测试覆盖度提高到100%。
假设咱们要为一个名为header.component.ts的公共组件添加100%的测试覆盖,该组件中包含了常规的业务逻辑,代码的具体内容咱们先不看,会在下面的场景分析中逐渐给出。
首先咱们来建立一个测试文件header.component.spec.ts。
注意:TestBed.configureTestingModule方法就是在声明一个Module,须要添加该组件对应的Module中全部imports和provide中的依赖。
import { TestBed, async } from '@angular/core/testing';
import { HeaderComponent } from './header.component';
import { DatePipe } from '@angular/common';
import { DevUIModule } from '@avenueui/ng-devui';
describe('Header Component', () => {
let fixture: any;
let theComp: HeaderComponent;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
HeaderComponent
],
imports: [
DevUIModule
],
providers: [
DatePipe
]
}).compileComponents().then(() => {
fixture = TestBed.createComponent(HeaderComponent);
theComp = fixture.debugElement.componentInstance;
});
}));
it('should create header', () => {
expect(theComp).toBeDefined();
});
});
复制代码
运行ng test
,若是能看到如下页面,说明组件初始化成功,接下来咱们就分场景来讨论在书写单元测试过程当中可能要处理的各类状况
header.component.ts中ngOnInit 的代码以下所示:
ngOnInit() {
this.subscribeCloseAlert();
}
复制代码
要对这一行代码或者说这个函数进行测试覆盖,其实很简单,这样写就完事了:
describe('ngOnInit', () => {
it(`ngOnInit should be called`, () => {
theComp.ngOnInit();
})
})
复制代码
可是这里的测试覆盖只至关于在测试用例中执行了一遍ngOnInit,顺便调用了里面的全部函数,却不能保证里面全部函数的行为是没有问题的。咱们内心可能也会犯嘀咕,好像什么都没作,这就算完成测试了吗?
一样是对ngOnInit 实现行覆盖,咱们还能够这样写。
describe('ngOnInit', () => {
it(`functions should be called on init`, () => {
spyOn(theComp, 'subscribeCloseAlert');
theComp.ngOnInit();
expect(theComp.subscribeCloseAlert).toHaveBeenCalled();
})
})
复制代码
稍加对比,咱们能够看到第二种写法虽然写完以后,覆盖度的结果跟第一种是同样的,可是多了一层更为精准的逻辑,即验证ngOnInit 被调用时,其中调用的函数确实也被调用了,这也是咱们推荐的写法。至于spyOn,若是看着比较陌生的话,能够暂时跳过,在第8点里面会比较详细地介绍其用法。
从这里咱们也能够看出,行覆盖和函数覆盖对于代码质量的保障实际上是很低的,只能证实测试用例有执行到这些代码,没办法保障执行的结果没问题,为了测试函数的行为,咱们须要用大量的expect 对函数执行先后的变量和结果进行检查,建议每个测试函数都配置对应的expect。若是你的测试函数中没有expect,你也会在这里收到反馈。
组件中常常会依赖服务中的变量或者方法,以下所示的代码,在组件中大概比比皆是。
this.commonService.getCloseAlertSub().subscribe((res) => {
...
});
复制代码
针对组件的单元测试只须要保障组件自身的行为可靠,不须要也不该该覆盖到所依赖的服务,所以咱们须要对依赖的服务进行mock 或者stub。
做为前端开发者,对mock 数据应该不陌生。单元测试中的mock 与之相似,即制造一个假的外部依赖对象,并假设当前组件对该对象的依赖都是可靠的(至于被依赖对象是否可靠,会靠它本身的单元测试代码进行保障),在此基础之上,完成当前组件的单元测试书写,代码写出来就像下面这样:
class CommonServiceMock {
getCloseAlertSub() {
return {
observable: function() {}
}
};
}
providers: [
{ provide: CommonService, useClass: CommonServiceMock }
...
}
复制代码
要实现一样的目的,咱们也可使用sinon的stub来作,代码以下:
import sinon from 'sinon/pkg/sinon-esm';
...
class CommonServiceMock {
getCloseAlertSub() { };
}
let observable = { subscribe: function () { } };
beforeEach(
...
.compileComponents().then(() => {
...
sandbox = sinon.createSandbox();
sandbox.stub(commonService, 'getCloseAlertSub')
.withArgs().returns(observable);
});
)
复制代码
在使用stub 的时候咱们一样也mock 了组件所依赖的服务,与上述只使用mock相比,这种方法的特色在于,mock 类中只须要声明函数,函数的具体表现能够经过stub来定义。
另外注意一点,stub 只能模拟函数,没法模拟变量,因此若是你正在考虑如何模拟依赖对象中的变量,那么最好先停一下,看看这篇文章:stackoverflow.com/questions/4…
假设我如今要为以下函数编写单元测试
subscribeCloseAlert() {
this.closeAlertSub = this.commonService.getCloseAlertSub().subscribe((res) => {
if (res) {
this.timeChangeTopVal = '58px';
}
});
}
复制代码
在编写出的测试代码中既要保证全部代码都被执行,还要保证执行的结果是符合预期的,所以须要检查如下几点:
基于这几点假设,咱们不妨对以上函数进行重构,获得两个更小、逻辑独立性更强、更容易测试的函数:
subscribeCloseAlert() {
// subscribe 回调用必须添加bind(this),不然this的指向会丢失
this.closeAlertSub = this.commonService.getCloseAlertSub().subscribe(this.processCloseAlert.bind(this));
}
processCloseAlert(res) {
if (res) {
this.timeChangeTopVal = '58px';
}
}
复制代码
若是你在“测试ngOnInit”中是使用的方法一覆盖ngOnInit,你会发现,subscribeCloseAlert 函数已经被覆盖了。由于方法一对ngOnInit的调用,已经对这个函数实施了行覆盖及函数覆盖。(不推荐这样作)
若是你用的是方法二,那么可能还要加一个测试函数给subscribeCloseAlert 作下行覆盖。
// TODO: 先凑合这这么写吧,有点懒,还没看这个应该怎么写比较好
// 比较明确的一点是,这不是好的写法,由于就像咱们上文提到的,这个测试函数没有expect
describe('subscribeCloseAlert', () => {
it(`should be called`, () => {
theComp.subscribeCloseAlert();
})
})
复制代码
接着,咱们来覆盖第二个函数。仍是那个经典的语句,describe-it-should,写出来的测试代码就像下面这样:
describe('processCloseAlert', () => {
it(`should change timeChangeTopVal to 58 if input is not null or false`, () => {
theComp.processCloseAlert(true);
expect(theComp.timeChangeTopVal).toBe('58px');
})
})
复制代码
这个时候,你打开coverage 中的index.html 会发现这两个函数附近的代码覆盖率检测以下所示。能够看到,第五行代码前面标记了一个黑底黄色的E,这个E是在告诉咱们“else path not taken”,再看看上面咱们给出的测试代码,确实没有覆盖的,可是实际上else 里面也不须要采起什么行为,因此这里你们能够根据须要看是否要添加else 的测试代码。
我花了两个小时的时间去尝试,而后扔下几天,又花了一个小时的时间去尝试,才写出能够测试window.location 的代码。 假设组件中有一个涉及到获取当前url参数的函数,内容以下:
// 公共函数声明(被测函数)
getUrlQueryParam(key: string): string {
if (!decodeURIComponent(location.href).split('?')[1]) return '';
const queryParams = decodeURIComponent(location.href).split('?')[1].split('&');
const res = queryParams.filter((item) => item.split('=')[0] === key);
return res.length ? res[0].split('=')[1] : '';
}
// 调用方
hasTimeInUrl(): boolean {
const sTime = this.getUrlQueryParam('sTime');
const eTime = this.getUrlQueryParam('eTime');
return !!(sTime && eTime);
}
复制代码
这个函数比较难测,缘由在于咱们没办法经过改变location.href 的值来覆盖函数中的全部分支。在Google 上搜寻了良久,大概有如下三个思路来测试location.href:
上面三个思路只有第三个看起来靠谱一点,并且有一篇看起来很靠谱的文章专门讲这个:jasminexie.github.io/injecting-w…
细品几遍,上述思路没有问题,可是着手实施了半天,一直抛出服务未注入的错误。再加上文章里提到的代码改动有点多,理解起来也有一丢丢费劲,因此我就依据文章的思路对改动作了简化。
最终用于测试window 系列的的方法及代码以下:
// 声明变量window,默认复制为window 对象,并把组件中全部用到window 的地方改成调用this.window
window = window;
...
// 公共函数声明
getUrlQueryParam(key: string): string {
// 注意这里的变化,用this.window.location 取代了原来的location.href
if (!decodeURIComponent(this.window.location.href).split('?')[1]) return '';
const queryParams = decodeURIComponent(this.window.location.href).split('?')[1].split('&');
const res = queryParams.filter((item) => item.split('=')[0] === key);
return res.length ? res[0].split('=')[1] : '';
}
复制代码
// 在代码顶部添加一个window 的mock 对象
const mockWindow = {
location: {
href: 'http://localhost:9876/?id=66290461&appId=12345'
}
}
beforeEach((() => {
...
theComp.window = mockWindow;
}))
// 而后测试的部分这样写
describe('getUrlQueryParam', () => {
it(`shold get the value of appId if the url has a param named appId`, () => {
theComp.window.location.href = 'http://localhost:9876/?id=66290461&appId=12345'
const res = theComp.getUrlQueryParam('appId');
expect(res).toBe('12345');
})
it(`shold get '' if the url does not have a param named appId`, () => {
theComp.window.location.href = 'http://localhost:9876/?id=66290461'
const res = theComp.getUrlQueryParam('appId');
expect(res).toBe('');
})
})
复制代码
好比对于一个Button 组件,我须要保证他携带了应有的class。
import { By } from '@angular/platform-browser';
..
beforeEach((() => {
...
buttonDebugElement = fixture.debugElement.query(By.directive(ButtonComponent));
buttonInsideNativeElement = buttonDebugElement.query(By.css('button')).nativeElement;
}))
describe('button default behavior', () => {
it('Button should apply css classes', () => {
expect(buttonInsideNativeElement.classList.contains('devui-btn')).toBe(true);
expect(buttonInsideNativeElement.classList.contains('devui-btn-primary')).toBe(true);
});
});
复制代码
想了解By.css()
?往这里看:angular.cn/guide/testi…
@Input() 和@Output 咱们是再熟悉不过了,直接来看代码:
@Output() timeDimChange = new EventEmitter<any>();
...
dimensionChange(evt) {
this.timeDimChange.emit(evt);
}
复制代码
测试代码以下:
describe('dimensionChange', () => {
it(`should output the value`, () => {
theComp.timeDimChange.subscribe((evt) => expect(evt).toBe('output test'))
theComp.dimensionChange('output test');
})
})
复制代码
假如咱们要为如下函数编写测试,在这个函数中咱们对组件中某个变量的值作了修改
stopPropagation() {
this.hasBeenCalled = true;
}
复制代码
测试思路很简单,调用这个函数,而后检查变量的值是否被修改,就像下面这样
describe('stopPropagation', () => {
it(`should change the value of hasBeenCalled`, () => {
theComp.stopPropagation();
expect(theComp.hasBeenCalled).toBe(true);
})
})
复制代码
可是,若是咱们是面临这样一个函数呢?函数中没有对变量进行修改,只是调用了入参的一个方法
stopPropagation(event) {
event.stopPropagation();
}
复制代码
先看答案,再来解释
describe('stopPropagation', () => {
it(`should call event stopPropagation`, () => {
const event = {
stopPropagation() {}
}
spyOn(event, 'stopPropagation');
theComp.stopPropagation(event);
expect(event.stopPropagation).toHaveBeenCalled();
})
})
复制代码
spyOn有两个参数,第一个参数是被监视对象,第二个参数是要监视的方法。
一旦执行了spyOn,那么当代码中有地方调用了被监视的方法时,实际上你代码里的这个方法并无真正执行。在这种状况下,你可使用expect(event.stopPropagation).toHaveBeenCalled();
来验证方法调用行为是否符合预期。
那若是我想让我代码中的这个方法也执行,怎么办?这里有你想要的答案:scriptverse.academy/tutorials/j…
再来一个业务场景中的真实案例,看完这个,你可能就更清楚spyOn的威力了。
假设有这样一个函数:
setAndChangeTimeRange() {
if (this.hasTimeInUrl()) {
this.processTimeInUrl();
} else {
this.setValOfChosenTimeRange();
this.setTimeRange();
}
}
复制代码
这个函数总共只有八行代码,可是这行代码中又调用了四个函数,若是把这四个函数一一展开,所涉及的代码量可能会超过一百行。那么,当咱们在对这个函数进行单元测试的时候是测什么呢?要测试展开后的全部100行代码吗?
实际上,咱们只须要测试这八行代码,也就是个人if-else 逻辑是否正确,至于每一个函数的行为,咱们会用该函数的单元测试代码保障。怎么写呢?就用咱们刚刚提到的spyOn。那么,该函数的测试用例写出来就应该是下面这样:
describe('setAndChangeTimeRange', () => {
it(`should exec processTimeInUrl if has time in url`, () => {
spyOn(theComp, 'hasTimeInUrl').and.returnValue(true);
spyOn(theComp, 'processTimeInUrl');
theComp.setAndChangeTimeRange();
expect(theComp.processTimeInUrl).toHaveBeenCalled();
});
it(`should set time range if does not have time in url`, () => {
spyOn(theComp, 'setValOfChosenTimeRange');
spyOn(theComp, 'setTimeRange');
spyOn(theComp, 'hasTimeInUrl').and.returnValue(false);
theComp.setAndChangeTimeRange();
expect(theComp.setValOfChosenTimeRange).toHaveBeenCalled();
expect(theComp.setTimeRange).toHaveBeenCalled();
});
})
复制代码
写完这部分,停一会,再品一品“单元测试”这四个字,是否是以为更有意境了?
上述场景和案例都来自业务代码中一个532 行的公共组件,从0% 开始,到如今测试覆盖度达到90%+(离标题的100% 还差一点点哈哈,剩的大约10%就交给正在读文章的你了),目前测试代码量是597行(真的翻倍了,哈哈)
上面提到的内容已是全部我认为值得一提的东西。场景写的差很少了,那就讨论一个相对本节内容稍微“题外”一点的话题。 从下图中能够看到,为了对该函数作到较高的测试覆盖,不少代码被执行了4-5次。
因此若是你已经完成了对当前组件100%的单元测试覆盖,那么下一步或许就能够考虑,如何用更少的测试用例来完成更高的测试覆盖度。
若是你以为意犹未尽,或者说感受文章里还存在没有讲清楚的状况,那么去这里寻找你想要的答案吧:angular.cn/guide/testi…
想要系统地学习同样东西,文档和书籍永远是最好的选择。
本文从测试覆盖度入手,讲解了各类覆盖度的计算方式,并给出一个简单的案例,你算对了吗?
接着,针对一个真实的业务组件,讨论多种场景下如何完成代码的测试覆盖,最终将该组件的测试覆盖度从0% 提高到90%+,但愿你看了本篇有所收获。
咱们是DevUI团队,欢迎来这里和咱们一块儿打造优雅高效的人机设计/研发体系。招聘邮箱:muyang2@huawei.com。
文/DevUI 少东
往期文章推荐
《html2canvas实现浏览器截图的原理(包含源码分析的通用方法)》