iT邦幫忙

2017 iT 邦幫忙鐵人賽
DAY 30
0
Modern Web

Angular 2 之 30 天邁向神乎其技之路系列 第 30

[Day 30] Angular 2 單元測試 Unit Test

前言

當我們把程式寫完之後,通常會需要做測試,可能就是跑跑看看東西有沒有出來、符合預期,或著印出內容或是 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.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: `<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);
  }));
}) 

上一篇
[Day 29] Angular 2 @Directive
下一篇
[Day 31] Angular 2 給初學者的學習指南
系列文
Angular 2 之 30 天邁向神乎其技之路31

尚未有邦友留言

立即登入留言