Angular 2 單元測試 Unit Test
- 2017-01-14
- Liu, An-Chi 劉安齊
前言
當我們把程式寫完之後,通常會需要做測試,可能就是跑跑看看東西有沒有出來、符合預期,或著印出內容或是 log 下來。每次都要手動測試很麻煩,有時候也會有死角,甚至當流程繁雜時,手動測試絕對不是個好選擇,這時候就會用到單元測試 (Unit Test)。
藉由建立及執行單元測試,檢查您的程式碼是否如預期般執行。 這之所以稱為單元測試,是因為您將程式功能分解成離散的可測試行為,這些行為能做為個別的「單位」(unit) 加以測試。
想更了解單元測試可以看這篇
測試環境
概念
這邊用 Jasmine 測試,但其實也可以用其他的測試框架像是 Mocha.
測試觀念:
- Suites —
describe(string, function)
函數,下標題然後包含多個Specs
。 - Specs —
it(string, function)
函數,下標題然後包含多個expectations
. - Expectations — 預期會發生的結果,語法像是
expect(actual).toBe(expected)
。 - Matchers — 判斷是否符合預期。像是:
toBe(expected)
、toEqual(expected)
。
Setup
你可以用 Jasmine 的 SpecRunner.html,或是採用測試運行框架像是 Karma。
通常開發 Angular 用的大型套件 (Angular CLI, Angular Seed) 就會包含單元測試的檔案了。
Plunker 用的測試環境:
<!-- Jasmine dependencies -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.4.1/jasmine.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.4.1/jasmine.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.4.1/jasmine-html.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.4.1/boot.js"></script>
<!-- Angular 2 dependencies –>
<script src="https://unpkg.com/zone.js/dist/zone.js"></script>
<script src="https://unpkg.com/zone.js/dist/long-stack-trace-zone.js"></script>
<script src="https://unpkg.com/reflect-metadata@0.1.3/Reflect.js"></script>
<script src="https://unpkg.com/systemjs@0.19.31/dist/system.js"></script>
<!-- Angular 2 testing dependencies –>
<script src="https://unpkg.com/zone.js/dist/proxy.js?main=browser"></script>
<script src="https://unpkg.com/zone.js/dist/sync-test.js?main=browser"></script>
<script src="https://unpkg.com/zone.js/dist/jasmine-patch.js?main=browser"></script>
<script src="https://unpkg.com/zone.js@0.6.25/dist/async-test.js"></script>
<script src="https://unpkg.com/zone.js/dist/fake-async-test.js?main=browser"></script>
<script src="config.js"></script>
<script>
//load all dependencies at the same time
Promise.all([
//required to test on browser
System.import('src/setup.spec'),
//specs
System.import('src/languagesService.spec'),
…
]).then(function(modules) {
//manually trigger Jasmine test-runner
window.onload();
}).catch(console.error.bind(console));
</script>
這邊可以看 Plunker 實際執行測試的樣子。
範例
測試範例來自這邊
測試 DI
TestBed
如同 @NgModule
幫助我們建立注入依賴 (DI) 的單元測試. 呼叫 TestBed.configureTestingModule
來執行。
@NgModule({
declarations: [ ComponentToTest ]
providers: [ MyService ]
})
class AppModule { }
TestBed.configureTestingModule({
declarations: [ ComponentToTest ],
providers: [ MyService ]
});
//從 TestBed 取得實例 (root injector)
let service = TestBed.get(MyService);
inject
讓我們在 TestBed 層級取得依賴
it('should return ...', inject([MyService], service => {
service.foo();
}));
Component injector
讓我們在 Component 層級取得依賴。
@Component({
providers: [ MyService ]
})
class ComponentToTest { }
let fixture = TestBed.createComponent(ComponentToTest);
let service = fixture.debugElement.injector.get(MyService);
甚麼層級的依賴取決於當初定義。Component 層級就無法使用 TestBed.get
或 inject
。
首先我們用 TestBed 的 TestBed.configureTestingModule
載入依賴的來源。接著用 inject
去自動實體化依賴。
describe('Service: LanguagesService', () => {
let service;
beforeEach(() => TestBed.configureTestingModule({
providers: [ LanguagesService ]
}));
beforeEach(inject([LanguagesService], s => {
service = s;
}));
it('should return available languages', () => {
expect(service.get()).toContain('en');
});
});
同步異步
同步
// synchronous
beforeEach(() => {
fixture = TestBed.createComponent(MyTestComponent);
});
異步
// asynchronous
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ MyTestComponent ],
}).compileComponents(); // compile external templates and css
}));
測試組件
// Usage: <greeter name="Joe"></greeter>
// Renders: <h1>Hello Joe!</h1>
@Component({
selector: 'greeter',
template: `<h1>Hello {{name}}!</h1>`
})
export class Greeter {
@Input() name;
}
describe('Component: Greeter', () => {
let fixture, greeter, element, de;
//setup
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ Greeter ]
});
fixture = TestBed.createComponent(Greeter);
greeter = fixture.componentInstance; // to access properties and methods
element = fixture.nativeElement; // to access DOM element
de = fixture.debugElement; // test helper
});
//specs
it('should render Hello World!
', async(() => {
greeter.name = 'World';
//trigger change detection
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(element.querySelector('h1').innerText).toBe('Hello World!');
expect(de.query(By.css('h1')).nativeElement.innerText).toBe('Hello World!');
});
}));
})
測試服務 (Service)
//a simple service
export class LanguagesService {
get() {
return ['en', 'es', 'fr'];
}
}
describe('Service: LanguagesService', () => {
let service;
beforeEach(() => TestBed.configureTestingModule({
providers: [ LanguagesService ]
}));
beforeEach(inject([LanguagesService], s => {
service = s;
}));
it('should return available languages', () => {
let languages = service.get();
expect(languages).toContain('en');
expect(languages).toContain('es');
expect(languages).toContain('fr');
expect(languages.length).toEqual(3);
});
});
測試 HTTP
export class LanguagesServiceHttp {
constructor(private http:Http) { }
get(){
return this.http.get('api/languages.json')
.map(response => response.json());
}
}
describe('Service: LanguagesServiceHttp', () => {
let service;
//setup
beforeEach(() => TestBed.configureTestingModule({
imports: [ HttpModule ],
providers: [ LanguagesServiceHttp ]
}));
beforeEach(inject([LanguagesServiceHttp], s => {
service = s;
}));
//specs
it('should return available languages', async(() => {
service.get().subscribe(x => {
expect(x).toContain('en');
expect(x).toContain('es');
expect(x).toContain('fr');
expect(x.length).toEqual(3);
});
}));
})
MockBackend
更接近 HTTP 邏輯的測試
describe('MockBackend: LanguagesServiceHttp', () => {
let mockbackend, service;
//setup
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ HttpModule ],
providers: [
LanguagesServiceHttp,
{ provide: XHRBackend, useClass: MockBackend }
]
})
});
beforeEach(inject([LanguagesServiceHttp, XHRBackend], (_service, _mockbackend) => {
service = _service;
mockbackend = _mockbackend;
}));
//specs
it('should return mocked response (async)', async(() => {
let response = ["ru", "es"];
mockbackend.connections.subscribe(connection => {
connection.mockRespond(new Response({body: JSON.stringify(response)}));
});
service.get().subscribe(languages => {
expect(languages).toContain('ru');
expect(languages).toContain('es');
expect(languages.length).toBe(2);
});
}));
})
測試指令(Directive)
// Example: <div log-clicks></div>
@Directive({
selector: "[log-clicks]"
})
export class logClicks {
counter = 0;
@Output() changes = new EventEmitter();
@HostListener('click', ['$event.target'])
clicked(target) {
console.log(Click on [${target}]: ${++this.counter}
);
//we use emit as next is marked as deprecated
this.changes.emit(this.counter);
}
}
我們建構一個跟要測試的組件很像的組件 Container ,拿來做測試
@Component({
selector: 'container',
template: `<div log-clicks (changes)="changed($event)"></div>`,
directives: [logClicks]
})
export class Container {
@Output() changes = new EventEmitter();
changed(value){
this.changes.emit(value);
}
}
describe('Directive: logClicks', () => {
let fixture;
let container;
let element;
//setup
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ Container, logClicks ]
});
fixture = TestBed.createComponent(Container);
container = fixture.componentInstance; // to access properties and methods
element = fixture.nativeElement; // to access DOM element
});
//specs
it('should increment counter', fakeAsync(() => {
let div = element.querySelector('div');
//set up subscriber
container.changes.subscribe(x => {
expect(x).toBe(1);
});
//trigger click on container
div.click();
//execute all pending asynchronous calls
tick();
}));
})
測試 Pipe
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({
name: 'capitalise'
})
export class CapitalisePipe implements PipeTransform {
transform(value: string): string {
if (typeof value !== 'string') {
throw new Error('Requires a String as input');
}
return value.toUpperCase();
}
}
describe('Pipe: CapitalisePipe', () => {
let pipe;
//setup
beforeEach(() => TestBed.configureTestingModule({
providers: [ CapitalisePipe ]
}));
beforeEach(inject([CapitalisePipe], p => {
pipe = p;
}));
//specs
it('should work with empty string', () => {
expect(pipe.transform('')).toEqual('');
});
it('should capitalise', () => {
expect(pipe.transform('wow')).toEqual('WOW');
});
it('should throw with invalid values', () => {
//must use arrow function for expect to capture exception
expect(()=>pipe.transform(undefined)).toThrow();
expect(()=>pipe.transform()).toThrow();
expect(()=>pipe.transform()).toThrowError('Requires a String as input');
});
})
測試 Route
@Component({
selector: 'my-app',
template: `<router-outlet></router-outlet>`
})
class TestComponent { }
@Component({
selector: 'home',
template: <h1>Home</h1>
})
export class Home { }
export const routes: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', component: Home },
{ path: '**', redirectTo: 'home' }
];
@NgModule({
imports: [
BrowserModule, RouterModule.forRoot(routes),
],
declarations: [TestComponent, Home],
bootstrap: [TestComponent],
exports: [TestComponent]
})
export class AppModule {}
describe('Router tests', () => {
//setup
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes(routes),
AppModule
]
});
});
//specs
it('can navigate to home (async)', async(() => {
let fixture = TestBed.createComponent(TestComponent);
TestBed.get(Router)
.navigate(['/home'])
.then(() => {
expect(location.pathname.endsWith('/home')).toBe(true);
}).catch(e => console.log(e));
}));
it('can navigate to home (fakeAsync/tick)', fakeAsync(() => {
let fixture = TestBed.createComponent(TestComponent);
TestBed.get(Router).navigate(['/home']);
fixture.detectChanges();
//execute all pending asynchronous calls
tick();
expect(location.pathname.endsWith('/home')).toBe(true);
}));
it('can navigate to home (done)', done => {
let fixture = TestBed.createComponent(TestComponent);
TestBed.get(Router)
.navigate(['/home'])
.then(() => {
expect(location.pathname.endsWith('/home')).toBe(true);
done();
}).catch(e => console.log(e));
});
});
測試 EventEmitters
@Component({
selector: 'counter',
template: `
<div>
<h1>{{counter}}</h1>
<button (click)="change(1)">+1</button>
<button (click)="change(-1)">-1</button>
</div>`
})
export class Counter {
@Output() changes = new EventEmitter();
constructor(){
this.counter = 0;
}
change(increment) {
this.counter += increment;
//we use emit as next is marked as deprecated
this.changes.emit(this.counter);
}
}
describe('EventEmitter: Counter', () => {
let counter;
//setup
beforeEach(() => TestBed.configureTestingModule({
providers: [ Counter ]
}));
beforeEach(inject([Counter], c => {
counter = c;
}))
//specs
it('should increment +1 (async)', async(() => {
counter.changes.subscribe(x => {
expect(x).toBe(1);
});
counter.change(1);
}));
it('should decrement -1 (async)', async(() => {
counter.changes.subscribe(x => {
expect(x).toBe(-1);
});
counter.change(-1);
}));
})