本節將涵蓋Angular經常使用的組件單元測試方法,例如:Router、Component、Directive、Pipe 以及Service,本來是打算分紅兩節,但後來一想放在一塊兒會更適合閱讀,雖然看起來比較長。css
但,在此以前,我建議先閱讀系列的前兩節,可能先更系統性的瞭解Angular單元測試以及一些框架說明。html
注:本節略長,所以,我將遵循如下規則。typescript
TestComponent
。beforeEach
由第一節統一講解,後續將再也不說明。將 beforeEach
作爲標題是由於在Angular單元測試裏面,它的做用很是關鍵,由於全部的一切都須要它的引導。json
beforeEach
目的爲了簡化咱們的測試代碼,將一些重複的工做放在這。app
當咱們須要寫一段Angular單元測試時,是須要先提供一個測試模塊,即:使用 TestBed.configureTestingModule
來構建,Angular工具集提供的用於快速建立一個測試 NgModule,而且他接受的參數,跟 NgModule 並沒有任何差別。框架
beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpModule], declarations: [TestComponent] }); });
當你須要對任何東西進行測試時,若是有依賴,好比:Http,那麼你須要 imports: [HttpModule]
,這一切都跟 NgModule 寫法徹底一置。異步
模塊有了,還須要建立組件,這是由於當你須要測試某個組件時,組件自己帶有一些HTML模板的渲染,例如:頁面中顯示用戶信息,因此咱們須要一個測試組件作爲該待測試組件的容器,以便咱們能夠對渲染的結果進行測試。async
但是,等等,若是像 Pipe
自己無任何HTML模板而言也須要嗎?因此,咱們將這兩種狀況分開。ide
所謂無模板,指的是組件、指令、服務等等可能他們無須任何 HTML 模板的渲染,所以,也沒必要說非要構建一個測試組件容器出來,這種狀況咱們只須要利用強大的DI系統,將咱們組件以注入的形式,或者說咱們本意只須要測試組件(或Service)類而已。函數
let directive: LogDirective; beforeEach(() => TestBed.configureTestingModule({ providers: [ LogDirective ] })); beforeEach(inject([ LogDirective ], c => { directive = c; }));
固然模塊確定是須要的,只不過咱們採用 inject
將咱們的 LogDirective
指令注入之後,咱們就能夠獲得該指令的實例對象。
如同前面說,當咱們須要一個組件容器來確保咱們渲染的結果的時候,就須要構建一個容器測試組件。
@Component({ template: `<trade-view [id]="id" (close)="_close()"></trade-view>` }) class TestComponent { id: number = 0; _close() { } } beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpModule], declarations: [TestComponent] }); fixture = TestBed.createComponent(TestComponent); context = fixture.componentInstance; el = fixture.nativeElement; dl = fixture.debugElement; });
首先,TestComponent
爲測試容器組件,注意:這裏並無 export
,由於對於容器咱們壓根就不必被其餘測試使用。
另,咱們也不須要對 close
業務進行操做,由於對於測試而言咱們只須要確保該事件被調用就行。
其次,經過 TestBed.createComponent(TestComponent)
來建立組件,而且將返回值存儲起來。
fixture
包括組件實例、變動監測以及DOM相關的屬性,它是咱們後面用來寫單元測試的核心,這裏我將經常使用的幾個屬性存在外部,這樣全部 it
均可以方便調用。
nativeElement與debugElement的區別
前者原生DOM元素,後者是由Angular進行包裝並提供諸如:
query
查詢組件、指令等等。triggerEventHandler
觸發DOM事件。以及一些便於咱們寫測試代碼比較通用的操做,還有更多細節,會在每一小節遇到時再作解釋。
訂單列表 ngOnInit
組件初始化時會遠程請求交易訂單數據。
// trade-list.component.ts @Component({ selector: 'trade-list', templateUrl: './trade-list.component.html', styleUrls: [ './trade-list.component.scss' ] }) export class TradeListComponent { constructor(private srv: TradeService) {} ngOnInit() { this.query(); } ls: any[] = []; query() { this.srv.query().subscribe(res => { this.ls = res; }); } }
訂單詳情頁指定個 id
交易編號,並根據該編號從遠程獲取數據並渲染。同時提供 close
用於關閉詳情頁時回調通知。
// trade-view.component.ts @Component({ selector: 'trade-view', template: ` <h1>trade {{id}}</h1> <dl *ngIf="item"> <dt>sku_id</dt><dd>{{item.sku_id}}</dd> <dt>title</dt><dd>{{item.title}}</dd> </dl> <button (click)="_close()">Close</button> `, host: { '[class.trade-view]': 'true' }, styles: [ `.trade-view { display: block; }` ], encapsulation: ViewEncapsulation.None }) export class TradeViewComponent { @Input() id: number; @Output() close = new EventEmitter(); constructor(private srv: TradeService) {} ngOnInit() { this.get(); } item: any; get() { this.srv.get(this.id).then(res => { this.item = res; }); } _close() { this.close.emit(); } }
以上兩個待測試組件,我儘量把咱們平常可能遇到的(@Input、@Output、依賴、HTTP)狀況考慮進來。
下面的測試並不是按示例的順序來,而是根據單元測試步驟。
@NgModule
若是根據 beforeEach
節咱們採用有模板的來構建測試模塊,大體應該是這樣:
@Component({ template: `<trade-view [id]="id" (close)="_close()"></trade-view>` }) class TestComponent { @ViewChild(TradeViewComponent) comp: TradeViewComponent; id: number = 0; _close() { } } beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpModule], declarations: [TradeViewComponent, TestComponent], providers: [TradeService, UserService] }); fixture = TestBed.createComponent(TestComponent); context = fixture.componentInstance; el = fixture.nativeElement; de = fixture.debugElement; });
因爲 TradeViewComponent
的構造函數依賴 TradeService
(其又依賴 UserService
),所以須要注入全部相關的服務,以及 HttpModule
模塊。
但是,咱們的服務大都數是依賴於遠程數據請求,並且咱們不能由於遠程數據的不當心變動倒置咱們單元測試失敗,這樣的數據在單元測試裏面壓根就沒法獲得有效保證、並不靠譜。
所以,咱們須要使用 spyOn
(jasmine)全局函數來監視,當 TradeService
的 get
被調用時返回一些咱們模擬的數據。
let spy: jasmine.Spy; const testTrade = { id: 10000 }; beforeEach(() => { // ... spy = spyOn(tradeService, 'get').and.returnValue(Promise.resolve(testTrade)); // ... });
很是簡單,咱們只不過在原有的基礎上增長 spyOn
的處理而已。
異步beforeEach
trade-list
組件與 trade-view
有一個明顯的區別,那即是HTML模板與樣式文件是由引用外部URL地址的,而獲取這些數據的這一過程是一個異步行爲。所以,咱們在構建 @NgModule
測試模塊時,所以須要使用異步的方式構建 NgModule
。
beforeEach(async(() => { TestBed.configureTestingModule({ // 同上 }) .compileComponents() .then(() => { // 同上建立組件代碼 }); }));
除了 async()
異步方法,以及 compileComponents()
兩處之外,無任何其餘差異。其實能夠很容易理解這一點,當異步去請求數據時,總歸須要等待它加載完的,纔能有後面的行爲吧。
而這裏 compileComponents()
就是如此,他會一直等待直接 templateUrl
及 styleUrls
都請求完成後纔會繼續。
首先,從測試角度而言,咱們第一個測試用例,應該要確保組件被初始化成功,那麼咱們如何去檢驗這一點呢?
對於 TradeViewComponent
而言 ngOnInit
生命週期觸發時就執行獲取數據運行,然後模板進行渲染。
it('should be component initialized', () => { context.id = 1; fixture.detectChanges(); expect(el.querySelector('h1').innerText).toBe('trade 1'); });
context.id = 1;
中的 context
指的是測試容器組件實例,咱們對其變量 id
賦值於 1,但對Angular而言並不知道數據的變化;因此須要手動的調用 fixture.detectChanges();
來強制執行Angular變化檢測,這樣能確保數據綁定到DOM上。所以,咱們才能斷言 h1
DOM標籤的內容是 trade 1
字符串。
自動變化檢測
除了手動,咱們也能夠引入 ComponentFixtureAutoDetect
讓這種變化由 Angular 自動幫咱們。
TestBed.configureTestingModule({ { provide: ComponentFixtureAutoDetect, useValue: true } });
可是也並不是萬能了,對於一些自己是由異步或計時器賦值的數據同樣是沒法被自動檢測的。
固然這樣遠遠不夠,核心是數據請求而後再DOM渲染,以前已經利用 spyOn
監視服務方法的調用,因此咱們只須要利用 spy
變量來檢測是否被調用,以及DOM的渲染是否是跟數據同樣,就好了。
it('should be component initialized (done)', (done: DoneFn) => { context.id = 1; fixture.detectChanges(); expect(spy.calls.any()).toBe(true, 'get called'); spy.calls.mostRecent().returnValue.then(res => { fixture.detectChanges(); expect(context.comp.item.id).toBe(testTrade.id); expect(el.querySelector('dl')).not.toBeNull(); expect(el.querySelector('.sku-id').textContent).toBe('' + testTrade.sku_id); expect(el.querySelector('.ware-title').textContent).toBe(testTrade.title); done(); }); });
首先,spy.calls.any()
表示有當有任何監視函數被調用時視爲 true
,固然這是必然,由於組件 ngOnInit
的時候會調用一次。
其次,spy.calls.mostRecent().returnValue
等同於 TradeServie
的 get()
方法的調用,只不過這裏的返回的數據再也不是調用遠程,而是返回由咱們模擬的數據而已。
上面示例中,最後還使用了jasmine的異步方式,由於對於 get()
而言它自己是一個異步請求,咱們只可以等待 then()
執行之後,才能標記這個測試已經完成。
固然另外兩種異步的寫法:
async
it('should be component initialized (async)', async(() => { fixture.detectChanges(); fixture.whenStable().then(() => { fixture.detectChanges(); expect(context.comp.item.id).toBe(testTrade.id); expect(el.querySelector('dl')).not.toBeNull(); expect(el.querySelector('.sku-id').textContent).toBe('' + testTrade.sku_id); expect(el.querySelector('.ware-title').textContent).toBe(testTrade.title); }); }));
將 spy.calls.mostRecent().returnValue
替換成 fixture.whenStable()
,其返回一個Promise對象,Angular會在全部異步結束後觸發Promise.resolve。
fakeAsync
it('should be component initialized (fakeAsync)', fakeAsync(() => { fixture.detectChanges(); tick(); fixture.detectChanges(); expect(context.comp.item.id).toBe(testTrade.id); expect(el.querySelector('dl')).not.toBeNull(); expect(el.querySelector('.sku-id').textContent).toBe('' + testTrade.sku_id); expect(el.querySelector('.ware-title').textContent).toBe(testTrade.title); }));
相比較 async 代碼更加同步化,這樣代碼看起來也很是爽。
fakeAsync與async區別
兩者自己並沒有區別,只不過是後者把前者的 fixture.whenStable()
換成 tick()
而已。
@Input()
輸入參數上面示例其實咱們已經在作了 context.id = 1;
就是表示咱們向 trade-view
組件傳遞一個交易編號爲 1 值,而且也能從DOM找到 trade 1
。
@Output()
自定義事件trade-view
組件還有一個 (close)
事件,是如何確保它能被調用呢?由於當咱們須要這個事件時,並沒有法經過一個相似訂閱的方式知道說事件已經被調用了。那怎麼辦?
依然還須要 spyOn
,如前確認初始化測試用例中同樣。
首先,先監視容器組件的 _close()
。
beforeEach(() => { // ... spyOn(context, '_close'); // ... });
最後。
it('should be call `close`', () => { el.querySelector('button').click(); fixture.detectChanges(); expect(context._close).toHaveBeenCalled(); });
由於 close
自己利用一個 Close 按鈕來觸發,所以,只須要查找到該按鈕並觸發其 click
事件;利用 toHaveBeenCalled
斷言容器組件中的 _close()
事件是否被觸發。咱們無須關心 close 具體業務,由於這是另外一個組件事情了。
至此,我總感受有些不對。
那即是 TradeService
,咱們太過於依賴它了。假如 TradeService
的依賴變更了,那是否是還得再修改測試組件依賴,並且 TradeService
可能會在很我多測試文件中出現,因此這樣作很蛋疼。
怎麼辦?
回想,Angular最強大的功能DI,它解決了各類依賴關係的複雜問題。可是,從測試角度出發,若是說組件與組件之間的依賴關係也在單元測試中出現,這樣的事情很讓人受不了。
正如 TradeService
內部還依賴 UserService
,以致於,還須要注入 UserService
,這都算什麼事嘛。
固然,最好的組件測試代碼應該是純潔的、乾淨的。
Stub
trade-list
組件依賴 TradeService
,而 TradeService
又依賴 UserService
,那麼何不咱們直接人爲捏造一個 TradeService
呢?而後讓這種依賴見鬼去。
Angular最強大DI系統,能夠簡單幫助咱們解決這個問題。如下使用 trade-list
組件爲例:
const tradeServiceStub = { query(): Observable<any[]> { return Observable.of(tradeData); } }; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [TradeListComponent, TradePipe], providers: [ { provide: TradeService, useValue: tradeServiceStub } ] }).compileComponents(); // 等同上面 }));
@NgModule
測試模塊的寫法和上面大概同樣,只不過這裏咱們捏造了一個 tradeServiceStub
,而且在注入 TradeService
時採用捏造的數據而已,就這麼簡單……
所以,這裏就看不到 HttpModule
、UserService
了,乾淨了!
若是按觸類旁通來說的話,上面大概能夠完成全部包括:Directive、Pipe、Service這些測試編碼了。
故,爲了完整度,後續可能會出現一些和Component相同的內容。
一個點擊次點指令。
@Directive({ selector: '[log]' }) export class LogDirective { tick: number = 0; @Output() change = new EventEmitter(); @HostListener('click', [ '$event' ]) click(event: any) { ++this.tick; this.change.emit(this.tick); } }
@NgModule
// 測試容器組件 @Component({ template: `<div log (change)="change($event)"></div>` }) class TestComponent { @Output() changeNotify = new EventEmitter(); change(value) { this.changeNotify.emit(value); } } beforeEach(() => { TestBed.configureTestingModule({ declarations: [TestComponent, LogDirective] }); fixture = TestBed.createComponent(TestComponent); context = fixture.componentInstance; el = fixture.nativeElement; let directives = fixture.debugElement.queryAll(By.directive(LogDirective)); directive = directives.map((d: DebugElement) => d.injector.get(LogDirective) as LogDirective)[0]; });
這裏沒有涉及外部模板或樣式,因此無須採用 beforeEach
異步。
但和上面又略有不一樣的是,這裏利用 By.directive
來查找測試容器組件的 LogDirective
指令。
By
By
是Angular提供的一個快速查找工具,容許傳遞一個 Type
類型的指令對象,這樣給咱們不少便利。它還包括:css
、all
。
只須要確保 beforeEach
獲取的指令對象存在,均可以視爲正確初始化,固然你也能夠作更多。
it('should be defined on the test component', () => { expect(directive).not.toBeUndefined(); });
[log]
會監聽父宿主元素的 click
事件,而且觸發時會通知 change
自定義事件。所以,須要給測試容器組件添加一個 (change)
事件,而咱們要測試的是,當咱們觸發測試容器組件中的按鈕後,是否會觸發該事件。
it('should increment tick (fakeAsync)', fakeAsync(() => { context.changeNotify.subscribe(val => { expect(val).toBe(1); }); el.click(); tick(); }));
這裏採用 fakeAsync 異步測試方法,由於 changeNotify
的執行是須要事件觸發之後纔會接收到的。
首先,訂閱測試容器組件的 changeNotify
,並以是否接收到數值 1 來表示結果(由於功能自己就是點擊一個+1,開始默認爲:0)。
其次,觸發DOM元素的 click()
事件。
而 tick()
會中斷執行,直到訂閱結果返回。
一個根據交易狀態返回中文文本的Pipe。
@Pipe({ name: 'trade' }) export class TradePipe implements PipeTransform { transform(value: any, ...args: any[]) { switch (value) { case 'new': return '新訂單'; case 'wait_pay': return '待支付'; case 'cancel': return `<a title="${args && args.length > 0 ? args[0] : ''}">已取消</a>`; default: throw new Error(`無效狀態碼${value}`); } } }
Pipe至關於一個類,而對類的測試最簡單的,只須要主動建立一個實例就好了。
固然,你也就沒法使用 async
、fakeAsync
之類的Angular工具集提供便利了。
let pipe = new TradePipe(); it('should be defined', () => { expect(pipe).not.toBeUndefined(); });
it(`should be return '新訂單' with 'new' string`, () => { expect(pipe.transform('new')).toEqual('新訂單'); });
以上測試只可以測試Pipe是否能運行,而沒法保證DOM渲染功能是否可用。所以,須要構建一個測試容器組件。
@Component({ template: `<h1>{{ value | trade }}</h1>` }) class TestComponent { value: string = 'new'; } let fixture: ComponentFixture<TestComponent>, context: TestComponent, el: HTMLElement, de: DebugElement; beforeEach(() => { TestBed.configureTestingModule({ declarations: [TestComponent, TradePipe] }); fixture = TestBed.createComponent(TestComponent); context = fixture.componentInstance; el = fixture.nativeElement; de = fixture.debugElement; });
與以前看到的 NgModule
並沒有什麼不同。
測試用例
檢驗 h1
DOM標籤的內容是否是如咱們指望的便可。
it('should display `待支付`', () => { context.value = 'wait_pay'; fixture.detectChanges(); expect(el.querySelector('h1').textContent).toBe('待支付'); });
交易類:
@Injectable() export class TradeService { constructor(private http: Http, private userSrv: UserService) { } query(): Observable<any[]> { return this.http .get('./assets/trade-list.json?token' + this.userSrv.token) .map(response => response.json()); } private getTrade() { return { "id": 10000, "user_id": 1, "user_name": "asdf", "sku_id": 10000, "title": "商品名稱" } } get(tid: number): Promise<any> { return new Promise(resolve => { setTimeout(() => { resolve(this.getTrade()); }, 500); }) } }
用戶類:
@Injectable() export class UserService { token: string = 'wx'; get() { return { "id": 1, "name": "asdf" }; } type() { return ['普通會員', 'VIP會員']; } }
當Service無任何依賴時,咱們能夠像 Pipe 同樣,直接構建一個實例對象便可。
固然,你也就沒法使用 async
、fakeAsync
之類的Angular工具集提供便利了。
let srv: UserService = new UserService(); it(`#token should return 'wx'`, () => { expect(srv.token).toBe('wx'); });
固然,絕大部分狀況下不如所願,由於真實的業務老是依賴於 Http
模塊,所以,咱們還須要 Angular工具集的支持,先構建一個 NgModule
。
let srv: TradeService; beforeEach(() => TestBed.configureTestingModule({ imports: [HttpModule], providers: [TradeService, UserService] })); beforeEach(inject([TradeService], s => { srv = s; }));
固然,它比咱們上面任何一個示例簡單得多了。
it('#query should return trade array', (done: DoneFn) => { srv.query().subscribe(res => { expect(Array.isArray(res)).toBe(true); expect(res.length).toBe(2); done(); }); });
async
、fakeAsync
雖然Service基本上都是異步方法,可是這裏並無使用任何 async
、fakeAsync
,哪怕咱們的異步方法是 Observable 或 Promise。
細心的話,前面我提到兩次。
固然,你也就沒法使用
async
、fakeAsync
之類的Angular工具集提供便利了。
這是其中一方面。
而另外一方面本示例也採用 Angular 工具集建立了 NgModule
,可爲何如下測試沒法經過呢?
it('#get should return trade (fakeAsync)', fakeAsync((done: DoneFn) => { srv.get(1).then(res => { expect(res).not.toBeNull(); expect(res.id).toBe(10000); }); tick(); }));
這是由於 tick()
自己只能等待諸如DOM事件、定時器、Observable以及Promise之類的,可是對於咱們示例中 get()
是使用 setTimeout
來模擬一次請求要 500ms 呀,這對於 tick()
而言並不知道須要等待多長時間的呀。
因此,這裏須要改爲:
tick(600);
讓等待的時長比咱們 seTimeout
略長一點就行啦。
當前以上看似咱們已經懂得如何去測試各類組件及服務,但……有一項很重要的事實,那就是真正的項目老是衝刺着各類 Router 路由。
本來我打算獨立另外一篇解釋,可是一想,假若分開,會讓人更加糊塗。
那麼,若是咱們在上面的交易列表HTML模板增長路由跳轉代碼,例如點擊交易編碼跳轉到交易詳情頁中。
<p><a [routerLink]="[ i.id ]">{{i.id}}</a></p>
那麼,應該如何去檢驗這一過程呢?我認爲應該從兩種狀況出發:
一是:是否有必要檢驗導航過程,若是說當前測試的組件導航至目前組件,而目前組件可能包含一些更爲複雜的依賴,好比:權限。
二是:反之。
那麼根據以上兩種狀況,我拆分紅兩種,一是Stubs,二是 RouterTestingModule
路由測試模塊。
根據上面分類,只須要測試當前組件 routerLink
的正確性,而不對路由導航進行驗證。
可能這個會更簡單一點,由於只須要 Spy 監視Angular 的 routerLink
的調用不就能夠了嗎?
可怎麼作?
再回過頭說Angular最強大的DI系統,利用它,本身 Stub 一個 RouterLinkStubDirective
,而後利用DI替換 Angular 的 routerLink
不就能夠了嗎?
@Directive({ selector: '[routerLink]', host: { '(click)': 'onClick()' } }) export class RouterLinkStubDirective { @Input('routerLink') linkParams: any; navigatedTo: any = null; onClick() { this.navigatedTo = this.linkParams; } }
替換 routerLink
。
beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [TradeListComponent, RouterLinkStubDirective] }) }));
最後,能夠寫一個測試用例,來驗證生成狀況。
it('can get RouterLinks from template', () => { let allLinks = fixture.debugElement.queryAll(By.directive(RouterLinkStubDirective)); let links = allLinks.map(linkDe => linkDe.injector.get(RouterLinkStubDirective) as RouterLinkStubDirective); expect(links.length).toBe(tradeData.length); expect(links[0].linkParams.toString()).toBe('/' + tradeData[0].id); });
這類代碼上面也有寫過,這裏使用 By.directive
查找頁面全部 RouterLinkStubDirective
,最後只是根據值進行斷言。
當須要進行路由導航勢必須要與 location
打交道,得須要獲取 URL 信息來驗證導航的結果,是吧!
固然,沒必要想太多,由於Angular工具集幫咱們作了一個很經常使用的 Spy Module,以便寫測試代碼,名也:RouterTestingModule
。
beforeEach(async(() => { TestBed.configureTestingModule({ imports: [AppModule, RouterTestingModule], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents() }));
這是Angular工具集提供的一個很重要路由測試模塊,它幫助咱們 Spy 幾個平常模塊:
Location
相似於 Javascript 的 location
,咱們能夠利用它來驗證URL。LocationStrategy
方便操做路由策略,無論 Hash仍是Path。NgModuleFactoryLoader
延遲模塊加載。location
導航驗證最核心是URL的變化,那麼能夠獲取已經注入的 Location
。
const injector = fixture.debugElement.injector; location = injector.get(Location) as SpyLocation;
NO_ERRORS_SCHEMA
若是說路由至某個頁面時,而該頁面可能會有其它組件 app-header
等之類的時候,而測試模塊又沒有導入這些組件時,可又會找不到,利用 NO_ERRORS_SCHEMA
能夠忽略這部分組件錯誤。
導航路徑自己還須要手動初始化。
router = injector.get(Router); router.initialNavigation();
URL導航測試
it('should navigate to "home" immediately', fakeAsync(() => { expect(location.path()).toEqual('/home'); }));
點擊導航測試
it('should navigate to "home" immediately', fakeAsync(() => { // 獲取頁面全部連接 let allLinks = fixture.debugElement.queryAll(By.directive(RouterLinkWithHref)); // 點擊第一個連接 allLinks[0].nativeElement.click(); tick(); fixture.detectChanges(); expect(location.path()).toEqual('/10001'); }));
Angular單元測試其實很是簡單,不少代碼都是重複的。固然了,這裏頭的概念也沒有不少,總歸只是以 TestBed
爲入口、以DI系統爲簡化依賴、以 spy
監聽事件,僅此而已。
固然,這裏是有一些技巧,請見下一篇。
本節一共有27個用例,全部的你均可以在plnkr中找獲得。
下一節,解釋一些單元測試技巧。
happy coding!