关于前端:现在开始为你的Angular应用编写测试二

6次阅读

共计 12071 个字符,预计需要花费 31 分钟才能阅读完成。

DevUI 是一支兼具设计视角和工程视角的团队,服务于华为云 DevCloud 平台和华为外部数个中后盾零碎,服务于设计师和前端工程师。

官方网站:devui.design

Ng 组件库:ng-devui(欢送 Star)

官网交换:增加 DevUI 小助手(devui-official)

DevUIHelper 插件:DevUIHelper-LSP(欢送 Star)

引言

在上一篇文章中,咱们以 Angular 官网自带的测试用例为引,介绍了如何在 20 分钟内将单元测试集成到已有我的项目中;并提出从公共办法、管道等逻辑绝对简略又比较稳定的代码块动手,开启书写单元测试之路。这一篇文章,次要来答复以下两个问题:

  1. 运行测试代码时看到的四种测试覆盖度别离是怎么计算的
  2. 如何从零开始,为一个组件实现 100% 的测试代码笼罩

01 测试覆盖度是怎么算的?

当咱们在 code-coverage 的报告中查看本人我的项目中的测试笼罩报告时,能够看到相似下图中的信息,所以文章的第一局部,咱们对报告中的各种覆盖度做一个简短的介绍。

1、语句覆盖度(Statements)

语句覆盖度 = 测试笼罩到的语句数 / 代码文件的总语句数

2、分支覆盖度(Branches)

分支覆盖度 = 测试笼罩到的分支数 / 代码文件的总分支数

3、函数覆盖度(Functions)

函数覆盖度 = 测试笼罩到的函数数量 / 代码文件的总函数数量,哪怕被笼罩的函数只笼罩到一行代码也算

4、行覆盖度(Lines)

行覆盖度 = 测试笼罩到的行数 / 代码文件的总行数
这里所谓的“行”的概念,跟咱们在代码编辑器中看到的是不一样的。在代码编辑器中一个四五百行的文件,在行覆盖度的计算中,分母可能只有两三百。比如说,上面咱们认为是很多行的代码实际上只是一行。

5、看个例子

看到这里,常识仿佛并没有减少,因为下面只是把几种覆盖度计算的公式简略列举了一下。
所以咱们无妨来看一个例子。假如有一个简略的色彩计算 pipe,代码如下。
先花 30s,本人算一算,上述代码的语句、分支、函数、行数别离为多少。

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';
    }
}

除了这四种笼罩之外,还有条件笼罩、门路笼罩、断定条件笼罩、组合笼罩等其余四种笼罩,如何用测试用例实现上述维度的笼罩,这里不深刻开展,想理解的话,能够参考:https://www.jianshu.com/p/8814362ea125

6、其余

另外,团队中如果有多个成员一起开发,能够通过配置 karma.conf.js 的形式来强制最低单元测试覆盖率。

coverageIstanbulReporter: {reports: [ 'html', 'lcovonly'],
    fixWebpackSourcePaths: true,
    thresholds: {
        statements: 80,
        lines: 80,
        branches: 80,
        functions: 80
    }
}

配置当前,运行测试的时候如果没有达到目标覆盖率,就会有相干揭示。

02 实现一个组件的 100% 测试笼罩

讲完测试覆盖度及其计算形式,咱们来看一个公共业务组件(header.component.ts),通过介绍各种场景下如何编写测试用例将上述四种测试覆盖度晋升到 100%。

1、新建测试文件 *.spec.ts

假如咱们要为一个名为 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,如果能看到以下页面,阐明组件初始化胜利,接下来咱们就分场景来探讨在书写单元测试过程中可能要解决的各种状况

2、测试 ngOnInit

header.component.ts 中 ngOnInit 的代码如下所示:

ngOnInit() {this.subscribeCloseAlert();
}

(1)写法一:行笼罩

要对这一行代码或者说这个函数进行测试笼罩,其实很简略,这样写就完事了:

describe('ngOnInit', () => {it(`ngOnInit should be called`, () => {theComp.ngOnInit();
    })
})

然而这里的测试笼罩只相当于在测试用例中执行了一遍 ngOnInit,顺便调用了外面的所有函数,却不能保障外面所有函数的行为是没有问题的。咱们心里可能也会犯嘀咕,如同什么都没做,这就算实现测试了吗?

(2)写法二:看起来还是行笼罩

同样是对 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,你也会在这里收到反馈。

3、应用 Mock 模仿组件依赖的服务(service)

组件中常常会依赖服务中的变量或者办法,如下所示的代码,在组件中大略亘古未有。

this.commonService.getCloseAlertSub().subscribe((res) => {...});

针对组件的单元测试只须要保障组件本身的行为牢靠,不须要也不应该笼罩到所依赖的服务,因而咱们须要对依赖的服务进行 mock 或者 stub。

(1)应用 mock

作为前端开发者,对 mock 数据应该不生疏。单元测试中的 mock 与之相似,即制作一个假的内部依赖对象,并假如以后组件对该对象的依赖都是牢靠的(至于被依赖对象是否牢靠,会靠它本人的单元测试代码进行保障),在此基础之上,实现以后组件的单元测试书写,代码写进去就像上面这样:

class CommonServiceMock {getCloseAlertSub() {
        return {observable: function() {}}
    };
}
providers: [{ provide: CommonService, useClass: CommonServiceMock}
    ...
}

(2)应用 mock + stub

要实现同样的目标,咱们也能够应用 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 只能模仿函数,无奈模仿变量,所以如果你正在思考如何模仿依赖对象中的变量,那么最好先停一下,看看这篇文章:https://stackoverflow.com/questions/47029151/how-to-mock-variable-with-sinon-mocha-in-node-js

4、测试一个函数

假如我当初要为如下函数编写单元测试

subscribeCloseAlert() {this.closeAlertSub = this.commonService.getCloseAlertSub().subscribe((res) => {if (res) {this.timeChangeTopVal = '58px';}
    });
}

在编写出的测试代码中既要保障所有代码都被执行,还要保障执行的后果是合乎预期的,因而须要查看以下几点:

  1. commonService 中的 getCloseAlertSub 办法被调用
  2. getCloseAlertSub 办法的回调中调用 processCloseAlert(特地细的逻辑了,我也还没写……)
  3. processCloseAlert 中的逻辑合乎预期

基于这几点假如,咱们无妨对以上函数进行重构,失去两个更小、逻辑独立性更强、更容易测试的函数:

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 的测试代码。

5、测试 window.location

我花了两个小时的工夫去尝试,而后扔下几天,又花了一个小时的工夫去尝试,才写出能够测试 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:

  • 用 Object.defineProperty 来扭转 location.href 的值(不可行)
  • 通过 with 扭转运行上下文模仿自定义 location 对象(不可行 & 不举荐)
  • 把 window 作为一个 InjectionToken 注入到调用方,在组件中注入的是实在 window,在测试代码中注入的则是 mock window(可行 but 操作不进去)

下面三个思路只有第三个看起来靠谱一点,而且有一篇看起来很靠谱的文章专门讲这个:https://jasminexie.github.io/…

细品几遍,上述思路没有问题,然而着手施行了半天,始终抛出服务未注入的谬误。再加上文章里提到的代码改变有点多,了解起来也有一丢丢吃力,所以我就根据文章的思路对改变做了简化。

最终用于测试 window 系列的的办法及代码如下:

(1)header.component.ts

// 申明变量 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] : '';
}

(2)header.component.spec.ts

// 在代码顶部增加一个 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('');
    })
})

6、款式查看

比方对于一个 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()?往这里看:https://angular.cn/guide/testing-components-basics#bycss

7、测试 @Output

@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');
    })
})

8、应用 Spy 确认函数被执行

(1)spyOn 的根本用法

如果咱们要为以下函数编写测试,在这个函数中咱们对组件中某个变量的值做了批改

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(); 来验证办法调用行为是否合乎预期。

那如果我想让我代码中的这个办法也执行,怎么办?这里有你想要的答案:https://scriptverse.academy/t…

(2)一个更靠近业务场景的例子

再来一个业务场景中的实在案例,看完这个,你可能就更分明 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();});
})

写完这部分,停一会,再品一品“单元测试”这四个字,是不是感觉更有意境了?

9、用更少的用例实现更高的覆盖度

上述场景和案例都来自业务代码中一个 532 行的公共组件,从 0% 开始,到当初测试覆盖度达到 90%+(离题目的 100% 还差一点点哈哈,剩的大概 10% 就交给正在读文章的你了),目前测试代码量是 597 行(真的翻倍了,哈哈)

下面提到的内容曾经是所有我认为值得一提的货色。场景写的差不多了,那就探讨一个绝对本节内容略微“题外”一点的话题。
从下图中能够看到,为了对该函数做到较高的测试笼罩,很多代码被执行了 4 - 5 次。

所以如果你曾经实现了对以后组件 100% 的单元测试笼罩,那么下一步或者就能够思考,如何用更少的测试用例来实现更高的测试覆盖度。

10、还有更多

如果你感觉意犹未尽,或者说感觉文章里还存在没有讲清楚的状况,那么去这里寻找你想要的答案吧:https://angular.cn/guide/testing

想要系统地学习一样货色,文档和书籍永远是最好的抉择。

总结

本文从测试覆盖度动手,解说了各种覆盖度的计算形式,并给出一个简略的案例,你算对了吗?

接着,针对一个实在的业务组件,探讨多种场景下如何实现代码的测试笼罩,最终将该组件的测试覆盖度从 0% 晋升到 90%+,心愿你看了本篇有所播种。

退出咱们

咱们是 DevUI 团队,欢送来这里和咱们一起打造优雅高效的人机设计 / 研发体系。招聘邮箱:muyang2@huawei.com。

文 /DevUI 少东

往期文章举荐

《当初开始为你的 Angular 利用编写测试(一)》

《html2canvas 实现浏览器截图的原理(蕴含源码剖析的通用办法)》

《手把手教你搭建一个灰度公布环境》

参考链接汇总

  • 白盒测试中的几种笼罩办法:https://www.jianshu.com/p/8814362ea125
  • How to mock variable with Sinon/Mocha in node.js:https://stackoverflow.com/questions/47029151/how-to-mock-variable-with-sinon-mocha-in-node-js
  • Injecting Window in an Angular Application:https://jasminexie.github.io/injecting-window-in-an-angular-application/
  • 组件测试根底之 By.css:https://angular.cn/guide/testing-components-basics#bycss
  • jasmine-spyon:https://scriptverse.academy/tutorials/jasmine-spyon.html
  • Angular 官网测试文档:https://angular.cn/guide/testing
  • Angular8 多场景下单元测试实际指南:https://juejin.cn/post/6844903988324909069#heading-6
  • JavaScript 测试驱动开发(图书):https://www.ituring.com.cn/book/1920
正文完
 0