DevUI是一支兼具設計視角和工程視角的團隊,服務於華爲雲 DevCloud平臺和華爲內部數箇中後臺系統,服務於設計師和前端工程師。
官方網站: devui.design
Ng組件庫: ng-devui(歡迎Star)
官方交流:添加DevUI小助手(devui-official)
DevUIHelper插件:DevUIHelper-LSP(歡迎Star)
在上一篇文章中,咱們以Angular官方自帶的測試用例爲引,介紹瞭如何在20分鐘內將單元測試集成到已有項目中;並提出從公共方法、管道等邏輯相對簡單又比較穩定的代碼塊入手,開啓書寫單元測試之路。這一篇文章,主要來回答如下兩個問題:css
當咱們在code-coverage 的報告中查看本身項目中的測試覆蓋報告時,能夠看到相似下圖中的信息,因此文章的第一部分,咱們對報告中的各類覆蓋度作一個簡短的介紹。html
語句覆蓋度 = 測試覆蓋到的語句數 / 代碼文件的總語句數前端
分支覆蓋度 = 測試覆蓋到的分支數 / 代碼文件的總分支數node
函數覆蓋度 = 測試覆蓋到的函數數量 / 代碼文件的總函數數量,哪怕被覆蓋的函數只覆蓋到一行代碼也算git
行覆蓋度 = 測試覆蓋到的行數 / 代碼文件的總行數
這裏所謂的「行」的概念,跟咱們在代碼編輯器中看到的是不同的。在代碼編輯器中一個四五百行的文件,在行覆蓋度的計算中,分母可能只有兩三百。好比說,下面咱們認爲是不少行的代碼實際上只是一行。github
看到這裏,知識彷佛並無增長,由於上面只是把幾種覆蓋度計算的公式簡單羅列了一下。
因此咱們不妨來看一個例子。假設有一個簡單的顏色計算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行。segmentfault
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瀏覽器
另外,團隊中若是有多個成員一同開發,能夠經過配置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 只能模擬函數,沒法模擬變量,因此若是你正在考慮如何模擬依賴對象中的變量,那麼最好先停一下,看看這篇文章:https://stackoverflow.com/questions/47029151/how-to-mock-variable-with-sinon-mocha-in-node-js
假設我如今要爲以下函數編寫單元測試
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:
上面三個思路只有第三個看起來靠譜一點,並且有一篇看起來很靠譜的文章專門講這個:https://jasminexie.github.io/...
細品幾遍,上述思路沒有問題,可是着手實施了半天,一直拋出服務未注入的錯誤。再加上文章裏提到的代碼改動有點多,理解起來也有一丟丟費勁,因此我就依據文章的思路對改動作了簡化。
最終用於測試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()
?往這裏看:https://angular.cn/guide/testing-components-basics#bycss
@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();
來驗證方法調用行爲是否符合預期。
那若是我想讓我代碼中的這個方法也執行,怎麼辦?這裏有你想要的答案:https://scriptverse.academy/t...
再來一個業務場景中的真實案例,看完這個,你可能就更清楚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%的單元測試覆蓋,那麼下一步或許就能夠考慮,如何用更少的測試用例來完成更高的測試覆蓋度。
若是你以爲意猶未盡,或者說感受文章裏還存在沒有講清楚的狀況,那麼去這裏尋找你想要的答案吧:https://angular.cn/guide/testing
想要系統地學習同樣東西,文檔和書籍永遠是最好的選擇。
本文從測試覆蓋度入手,講解了各類覆蓋度的計算方式,並給出一個簡單的案例,你算對了嗎?
接着,針對一個真實的業務組件,討論多種場景下如何完成代碼的測試覆蓋,最終將該組件的測試覆蓋度從0% 提高到90%+,但願你看了本篇有所收穫。
咱們是DevUI團隊,歡迎來這裏和咱們一塊兒打造優雅高效的人機設計/研發體系。招聘郵箱:muyang2@huawei.com。
文/DevUI 少東
往期文章推薦
《html2canvas實現瀏覽器截圖的原理(包含源碼分析的通用方法)》