Angular8單元測試示例指南

前端漫談發表於2019-11-06

angular單元測試準備

  • 構建angular框架,並編寫對應的業務、業務測試用例**.spec.ts檔案
  • 執行測試
 ng test --no-watch --code-coverage //根目錄下會生成coverage目錄,其中index.html記錄元件覆蓋率
複製程式碼
  • 檢視

測試單獨service - 框架new例項測試

程式碼如下:

@Injectable() //交給angular管理,幫忙注入依賴
export class ValueService {
  value:string;
  constructor() { }
  getValue() { return this.value}
}
複製程式碼

測試用例如下:

//直接new service 例項
let service: ValueService;
  beforeEach(() => { service = new ValueService(); });

  it('#getValue should return real value', () => {
    expect(service.getValue()).toBe('real value');
  });
	
//or 

//直接獲取服務例項進行測試,通過呼叫服務,校驗邏輯
let service: ValueService;
beforeEach(() => {
  TestBed.configureTestingModule({ providers: [ValueService] }); //等效於useClass
});
it('should use ValueService', () => {
  service = TestBed.get(ValueService);
  expect(service.getValue()).toBe('real value');
});
複製程式碼

測試單獨的元件 -- 程式碼new 元件例項

程式碼如下:

@Component({
  selector: 'lightswitch-comp',
  template: `
    <button (click)="clicked()">Click me!</button>
    <span>{{message}}</span>`
})
export class LightswitchComponent {
  isOn = false;
  clicked() { this.isOn = !this.isOn; }
  get message() { return `The light is ${this.isOn ? 'On' : 'Off'}`; }
}
複製程式碼

測試程式碼如下:

//直接new 
it('#clicked() should set #message to "is on"', () => {
    const comp = new LightswitchComponent();
    expect(comp.message).toMatch(/is off/i, 'off at first');
    comp.clicked();
    expect(comp.message).toMatch(/is on/i, 'on after clicked');
 });
 
 //or 獲取元件例項,交給框架建立new
 let comp:LightswitchComponent;
 beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      LightswitchComponent,
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(LightswitchComponent);
});
 it('#clicked() should set #message to "is on"', () => {
    expect(comp.message).toMatch(/is off/i, 'off at first');
    comp.clicked();
    expect(comp.message).toMatch(/is on/i, 'on after clicked');
 });
複製程式碼

元件帶有input、output

export class DashboardHeroComponent {
  @Input() hero: Hero;
  @Output() selected = new EventEmitter<Hero>();
  click() { this.selected.emit(this.hero); }
}
複製程式碼

測試程式碼如下:

let comp:DashboardHeroComponent;
 beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      DashboardHeroComponent,
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(DashboardHeroComponent);
});
it('raises the selected event when clicked', () => {
  const hero: Hero = { id: 42, name: 'Test' };
  comp.hero = hero;

  comp.selected.subscribe((selectedHero: Hero) => expect(selectedHero).toBe(hero));
  comp.click();
});
複製程式碼

測試service帶有依賴 -- mock依賴

利用spy mock依賴

程式碼如下:

@Injectable()
export class MasterService {
  constructor(private valueService: ValueService) { }
  getValue() { return this.valueService.getValue(); }
}
複製程式碼

獲取真實的依賴服務,常因為服務中依賴原因,難以順利建立。此時spy下服務,是最簡單的方法。

測試如下:

let masterService: MasterService;
let valueServiceSpy: jasmine.SpyObj<ValueService>;

beforeEach(() => {
  const spy = jasmine.createSpyObj('ValueService', ['getValue']);//需要注意位置,在beforeEach

  TestBed.configureTestingModule({
    // Provide both the service-to-test and its (spy) dependency
    providers: [
      MasterService,
	//注入服務,mock提供依賴服務的支援,完成MasterService例項建立
      { provide: ValueService, useValue: spy } 
    ]
  });
  // Inject both the service-to-test and its (spy) dependency
  masterService = TestBed.get(MasterService);
  valueServiceSpy = TestBed.get(ValueService);
});
複製程式碼
it('#getValue should return stubbed value from a spy', () => {
  const stubValue = 'stub value';
  valueServiceSpy.getValue.and.returnValue(stubValue);
  expect(masterService.getValue())
    .toBe(stubValue, 'service returned stub value'); //利用mock依賴返回的值,進行期望判斷業務邏輯
});
複製程式碼

測試元件帶有依賴

WelcomeComponent 依賴於 UserService

export class WelcomeComponent  implements OnInit {
	welcome: string;
	constructor(private userService: UserService) { }

	ngOnInit(): void {
		this.welcome = this.userService.isLoggedIn ?
			'Welcome, ' + this.userService.user.name : 'Please log in.';
	}
}
複製程式碼

測試程式碼

class MockUserService {
	isLoggedIn = true;
	user = { name: 'Test User'};
};
複製程式碼
beforeEach(() => {
	TestBed.configureTestingModule({
		// provide the component-under-test and dependent service
		providers: [
			WelcomeComponent,
			{ provide: UserService, useClass: MockUserService } 
			// {provide: UserService, useVale: userServiceSpy}
		]
	});
	// inject both the component and the dependent service.
	comp = TestBed.get(WelcomeComponent);
	userService = TestBed.get(UserService); 
	//或者 userService = fixture.debugElement.injector.get(UserService);
	//更容易記住,也不太冗長。但是,只有當Angular在測試的根注入器中將帶有服務例項的元件注入元件時,它才起作用。
});
複製程式碼
it('should not have welcome message after construction', () => {
	expect(comp.welcome).toBeUndefined();
});
it('should welcome logged in user after Angular calls ngOnInit', () => {
	comp.ngOnInit();
	expect(comp.welcome).toContain(userService.user.name);
});
it('should ask user to log in if not logged in after ngOnInit', () => {
	userService.isLoggedIn = false;
	comp.ngOnInit();
	expect(comp.welcome).not.toContain(userService.user.name);
	expect(comp.welcome).toContain('log in');
});
複製程式碼

元件dom測試

元件建立測試

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { BannerComponent } from './banner.component';
describe('BannerComponent', () => {
  let component: BannerComponent;
  let fixture: ComponentFixture<BannerComponent>;
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ BannerComponent ]
    })
    .compileComponents();
  }));
  beforeEach(() => {
    fixture = TestBed.createComponent(BannerComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeDefined();
  });
	
});
複製程式碼

頁面元素固定

 it('should contain "banner works!"', () => {
	const bannerElement: HTMLElement = fixture.nativeElement;
	expect(bannerElement.textContent).toContain('banner works!');
  });
 it('should have <p> with "banner works!"', () => {
	const bannerElement: HTMLElement = fixture.nativeElement;
	const p = bannerElement.querySelector('p');
	expect(p.textContent).toEqual('banner works!');
 });
 it('should find the <p> with fixture.debugElement.nativeElement)', () => {
  const bannerDe: DebugElement = fixture.debugElement;
  const bannerEl: HTMLElement = bannerDe.nativeElement;
  const p = bannerEl.querySelector('p');
  expect(p.textContent).toEqual('banner works!');
});
//如果querySelector不能使用,
import { By } from '@angular/platform-browser';
it('should find the <p> with fixture.debugElement.query(By.css)', () => {
	const bannerDe: DebugElement = fixture.debugElement;
	const paragraphDe = bannerDe.query(By.css('p'));
	const p: HTMLElement = paragraphDe.nativeElement;
	expect(p.textContent).toEqual('banner works!');
});
複製程式碼

頁面元素動態修改

//頁面元素動態修改,測試
it('should display a different test title', () => {
  component.title = 'Test Title';
  fixture.detectChanges(); //顯示的進行修改檢測
  expect(h1.textContent).toContain('Test Title');
});
//or 除去顯示宣告detectChanges,使用自動檢測
import { ComponentFixtureAutoDetect } from '@angular/core/testing';
TestBed.configureTestingModule({
  declarations: [ BannerComponent ],
  providers: [
    { provide: ComponentFixtureAutoDetect, useValue: true }
  ]
});
複製程式碼

Render2修改樣式測試

import {Type ,Render2 } from 'angular/core';

let renderer2: Renderer2;
...
beforeEach(async( () => {
    TestBed.configureTestingModule({
        ...
        providers: [Renderer2]
    }).compileComponents();
}));

beforeEach(() => {
    fixture = TestBed.createComponent(BannerComponent);
    renderer2 = fixture.componentRef.injector.get<Renderer2>(Renderer2 as Type<Renderer2>);
    // and spy on it
    spyOn(renderer2, 'addClass').and.callThrough();
    // or replace
    // spyOn(renderer2, 'addClass').and.callFake(..);
    // etc
});

it('should call renderer', () => {
    expect(renderer2.addClass).toHaveBeenCalledWith(jasmine.any(Object), 'css-class');
});
複製程式碼

Observable測試

code

getQuote() {
  this.errorMessage = '';
  this.quote = this.twainService.getQuote().pipe(
    startWith('...'),
    catchError( (err: any) => {
      // Wait a turn because errorMessage already set once this turn
      () => this.errorMessage = err.message || err.toString()
      return of('...'); // reset message to placeholder
    })
  );
複製程式碼

正常返回

beforeEach(() => {
  testQuote = 'Test Quote';

  // Create a fake TwainService object with a `getQuote()` spy
  const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
  // Make the spy return a synchronous Observable with the test data
  getQuoteSpy = twainService.getQuote.and.returnValue( of(testQuote) ); //關鍵在此

  TestBed.configureTestingModule({
    declarations: [ TwainComponent ],
    providers:    [
      { provide: TwainService, useValue: twainService }
    ]
  });

  fixture = TestBed.createComponent(TwainComponent);
  component = fixture.componentInstance;
  quoteEl = fixture.nativeElement.querySelector('.twain');
});
it('should show quote after component initialized', () => {
  fixture.detectChanges(); // onInit()

  // sync spy result shows testQuote immediately after init
  expect(quoteEl.textContent).toBe(testQuote);
  expect(getQuoteSpy.calls.any()).toBe(true, 'getQuote called');
});
複製程式碼

返回異常

beforeEach(() => {
  // Create a fake TwainService object with a `getQuote()` spy
  const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
  // Make the spy return a synchronous Observable with the test data
  getQuoteSpy = twainService.getQuote.and.returnValue( throwError('ops') ); //關鍵在此

  TestBed.configureTestingModule({
    declarations: [ TwainComponent ],
    providers:    [
      { provide: TwainService, useValue: twainService }
    ]
  });

  fixture = TestBed.createComponent(TwainComponent);
  component = fixture.componentInstance;
  quoteEl = fixture.nativeElement.querySelector('.twain');
});
it('should show quote after component initialized', () => {
  fixture.detectChanges(); // onInit()

  // sync spy result shows testQuote immediately after init
  expect(errorMessage()).toMatch(/test failure/, 'should display error');
  expect(quoteEl.textContent).toBe('...', 'should show placeholder');
});
複製程式碼

返回異常,但非同步處理

//code 
getQuote() {
  this.errorMessage = '';
  this.quote = this.twainService.getQuote().pipe(
    startWith('...'),
    catchError( (err: any) => {
      // Wait a turn because errorMessage already set once this turn
      setTimeout(() => this.errorMessage = err.message || err.toString());
      return of('...'); // reset message to placeholder
    })
  );
  
 beforeEach(() => {
  // Create a fake TwainService object with a `getQuote()` spy
  const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
  // Make the spy return a synchronous Observable with the test data
  getQuoteSpy = twainService.getQuote.and.returnValue( throwError('ops') ); //關鍵在此

  TestBed.configureTestingModule({
    declarations: [ TwainComponent ],
    providers:    [
      { provide: TwainService, useValue: twainService }
    ]
  });

  fixture = TestBed.createComponent(TwainComponent);
  component = fixture.componentInstance;
  quoteEl = fixture.nativeElement.querySelector('.twain');
});

it('should display error when TwainService fails', fakeAsync(() => { //fakeAsync不適用與ajax
  // tell spy to return an error observable
  getQuoteSpy.and.returnValue(
    throwError('TwainService test failure'));

  fixture.detectChanges(); // onInit()
  // sync spy errors immediately after init

  tick(); // flush the component's setTimeout()

  fixture.detectChanges(); // update errorMessage within setTimeout()

  expect(errorMessage()).toMatch(/test failure/, 'should display error');
  expect(quoteEl.textContent).toBe('...', 'should show placeholder');
}));
複製程式碼

非同步程式碼測試

使用fakeAsync

it('should get Date diff correctly in fakeAsync', fakeAsync(() => {
    const start = Date.now();
    tick(100);
    const end = Date.now();
    expect(end - start).toBe(100);
 }));
 
複製程式碼

fakeAsync支援以下非同步任務:

  • setTimeout
  • setInterval
  • requestAnimationFrame
  • webkitRequestAnimationFrame
  • mozRequestAnimationFrame
  • rxjs - delay、interval等

ajax請求測試

it('should show quote after getQuote (async)', async(() => {
  fixture.detectChanges(); // ngOnInit()
  expect(quoteEl.textContent).toBe('...', 'should show placeholder');

  fixture.whenStable().then(() => { // wait for async getQuote
    fixture.detectChanges();        // update view with quote
    expect(quoteEl.textContent).toBe(testQuote);
    expect(errorMessage()).toBeNull('should not show error');
  });
}));
複製程式碼

jasmine done

it('should show quote after getQuote (spy done)', (done: DoneFn) => {
  fixture.detectChanges();

  // the spy's most recent call returns the observable with the test quote
  getQuoteSpy.calls.mostRecent().returnValue.subscribe(() => {
    fixture.detectChanges(); // update view with quote
    expect(quoteEl.textContent).toBe(testQuote);
    expect(errorMessage()).toBeNull('should not show error');
    done();
  });
});
複製程式碼

元件巢狀測試

服務依賴錯誤

TypeError: ctor is not a constructor
複製程式碼

問題原因:provide中錯誤的配置

//錯誤的
providers: [{provide: OrderService, useClass: new OrderServiceMock()}]
複製程式碼
//正確的
providers: [{provide: OrderService, useValue: new OrderServiceMock()}]
複製程式碼

路由測試

總結:

  • 測試用例的編寫,應該儘可能的簡化測試物件邏輯,分而測之。
  • 編寫程式碼時候,需要留意,預留測試

參考文獻

window 變數

Angular

d3 測試

本文作者:前端漫漫

聯絡郵箱:simple2012hcz@126.com

版權宣告: 本文章除特別宣告外,均採用 CC BY-NC-SA 4.0 許可協議。轉載請註明出處!

相關文章