前言

當我們把程式寫完之後,通常會需要做測試,可能就是跑跑看看東西有沒有出來、符合預期,或著印出內容或是 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&quot;&gt;&lt;/script&gt;
<script src="https://unpkg.com/zone.js/dist/long-stack-trace-zone.js&quot;&gt;&lt;/script&gt;
<script src="https://unpkg.com/reflect-metadata@0.1.3/Reflect.js&quot;&gt;&lt;/script&gt;
<script src="https://unpkg.com/systemjs@0.19.31/dist/system.js&quot;&gt;&lt;/script&gt;

<!-- Angular 2 testing dependencies –>
<script src="https://unpkg.com/zone.js/dist/proxy.js?main=browser&quot;&gt;&lt;/script&gt;
<script src="https://unpkg.com/zone.js/dist/sync-test.js?main=browser&quot;&gt;&lt;/script&gt;
<script src="https://unpkg.com/zone.js/dist/jasmine-patch.js?main=browser&quot;&gt;&lt;/script&gt;
<script src="https://unpkg.com/zone.js@0.6.25/dist/async-test.js&quot;&gt;&lt;/script&gt;
<script src="https://unpkg.com/zone.js/dist/fake-async-test.js?main=browser&quot;&gt;&lt;/script&gt;

<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.getinject

首先我們用 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: &lt;h1&gt;Home&lt;/h1&gt;
})
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);
  }));
})