Angular 测试速查表
Angular 单元测试速查表
异步行为
个人笔记
这是我为客户准备的内部文件的删节版。它基于最新的修订版本,与客户的版本并不完全相同。
Angular 单元测试速查表
以下是一些常见 Angular 测试场景的代码示例,以及一些改进测试实践的技巧。记住,先测试!
测试场景
隔离逻辑
使用辅助函数将逻辑与应用程序的其余部分封装在一起。避免将逻辑放在生命周期方法和其他钩子中。避免在辅助方法中引用组件的状态,即使它可用。这将使单独测试更容易。
坏的
ngOnInit() {
...
this.clientPhoneNumbers = this.allClients
.filter( ( client: Client ) => client.phone !== undefined && client.phone !== null )
.map( ( client: Client ) => ( { name: client.name, phone: client.phone } ) );
...
}
上面的代码示例很难测试。ngOnInit
为了测试仅有的三行代码,我们不得不在方法中 provide 和/或 mock 每个操作的所有依赖项。
更好的
ngOnInit() {
...
this.collectClientPhoneNumbers();
...
}
collectClientPhoneNumbers() {
this.clientPhoneNumbers = this.allClients
.filter( ( client: Client ) => client.phone !== undefined && client.phone !== null )
.map( ( client: Client ) => ( { name: client.name, phone: client.phone } ) );
}
在我们改进的示例中,我们不再需要确保所有其他操作都ngOnInit
成功,因为我们只测试了该collectClientPhoneNumbers
方法。但是,我们仍然需要模拟或为 allClients 字段提供组件的状态。
最好的
ngOnInit() {
...
this.clientPhoneNumbers = this.collectClientPhoneNumbers( this.allClients );
...
}
collectClientPhoneNumbers( clients: Client[] ): Object[] {
return clients
.filter( ( client: Client ) => client.phone !== undefined && client.phone !== null )
.map( ( client: Client ) => ( { name: client.name, phone: client.phone } ) );
}
在我们最好的实现中,逻辑完全独立于组件的状态。组件编译时我们不需要模拟任何东西,只需提供原生的 JS 输入即可。
测试示例
it( 'When collectClientPhoneNumbers receives a list of Clients, then it should return a list of phone numbers', () => {
// GIVEN - Load test data and define expected results.
const clients = loadFromMockData('valid-clients');
const firstClientPhoneNumber = { name: client[0].name, phone: client[0].number };
const clientsWithPhoneNumbers = clients.filter( c => client.phone !== undefined && client.phone !== null );
// WHEN - Perform the operation and capture results.
const filteredClients = component.collectClientPhoneNumbers( clients );
// THEN - Compare results with expected values.
expect( filteredClients.length ).toEqual( clientsWithPhoneNumbers.length );
expect( filteredClients[0] ).toEqual( firstClientPhoneNumber );
} );
异步行为
Angular Testing 模块提供了两个用于测试异步操作的实用程序。
异步测试工具说明
- async:测试将等到所有异步行为都解析完毕后才结束。最好测试简单的异步行为,这些行为不应阻塞很长时间。避免与可能挂起或持续很长时间才能解析的异步行为一起使用。
- fakeAsync:测试将拦截异步行为并以同步方式执行。最适合测试异步行为链或可能挂起或需要很长时间才能解决的不可靠异步行为。
- tick :在fakeAsync测试中模拟时间的流逝。需要一个表示已用时间(以毫秒为单位)的数字参数。
- flushMicrotasks:强制完成所有待处理的微任务,例如Promises和Observables。
- flush:强制完成所有待处理的宏任务,例如setInterval、setTimeout等。#### 要测试的代码
class SlowService {
names: BehaviorSubject<string[]> = new BehaviorSubject<>( [] );
getNames(): Observable<string[]> {
return this.names;
}
updateNames( names: string[] ) {
setTimeout( () => this.names.next( names ), 3000 );
}
}
class SlowComponent implements OnInit {
names: string[];
constructor( private slowService: SlowService ) {}
ngOnInit() {
this.slowService.getNames().subscribe( ( names: string[] ) => {
this.names = names;
} );
}
}
测试示例async()
it( 'When updatedNames is passed a list of names, Then the subscription will update with a list of names', async(
inject( [SlowService], ( slowService ) => {
// GIVEN - Create test data, initialize component and assert component's initial state
const names = [ "Bob", "Mark" ];
component.ngOnInit();
fixture.whenStable()
.then( () => {
expect( component.names ).toBeDefined();
expect( component.names.length ).toEqual( 0 );
// WHEN - Simulate an update in the service's state and wait for asynchronous operations to complete
slowService.updateNames( names );
return fixture.whenStable();
} )
.then( () => {
// THEN - Assert changes in component's state
expect( component.names.length ).toEqual( 2 );
expect( component.names ).toEqual( names );
} );
} ) ) );
测试示例fakeAsync()
, tick()
, flush()
,flushMicrotasks()
it( 'When updatedNames is passed a list of names, Then the subscription will update with a list of names', fakeAsync(
inject( [SlowService], ( slowService ) => {
// GIVEN - Create test data, initialize component and assert component's initial state
const names = [ "Bob", "Mark" ];
component.ngOnInit();
flushMicrotasks();
expect( component.names ).toBeDefined();
expect( component.names.length ).toEqual( 0 );
// WHEN - Simulate an update in the service's state and wait for asynchronous operations to complete
slowService.updateNames( names );
tick( 3001 );
// THEN - Assert changes in component's state
expect( component.names.length ).toEqual( 2 );
expect( component.names ).toEqual( names );
} ) ) );
间谍和模拟
通过监视函数,我们可以验证组件之间的交互是否在正确的条件下进行。我们使用模拟对象来减少需要测试的代码量。Jasmine 提供了spyOn()
管理间谍和模拟的功能。
情况 1:断言某个方法已被调用。
const obj = { method: () => null };
spyOn( obj, 'method' );
obj.method();
expect( obj.method ).toHaveBeenCalled();
警告:监视方法将阻止该方法的实际执行。
情况 2:断言某个方法被调用并执行方法。
const obj = { getName: () => 'Sam' };
spyOn( obj, 'getName' ).and.callThrough();
expect( obj.getName() ).toEqual( 'Sam' );
expect( obj.getName ).toHaveBeenCalled();
情况 3:断言某个方法被调用并执行某个函数。
const obj = { getName: () => 'Sam' };
spyOn( obj, 'getName' ).and.callFake((args) => console.log(args));
expect( obj.getName() ).toEqual( 'Sam' );
expect( obj.getName ).toHaveBeenCalled();
案例 4:模拟现有方法的响应。
const obj = { mustBeTrue: () => false };
spyOn( obj, 'mustBeTrue' ).and.returnValue( true );
expect( obj.mustBeTrue() ).toBe( true );
案例 5:模拟现有方法的几种响应。
const iterator = { next: () => null };
spyOn( iterator, 'next' ).and.returnValues( 1, 2 );
expect( iterator.next ).toEqual( 1 );
expect( iterator.next ).toEqual( 2 );
情况 6:断言某个方法被调用了多次。
const obj = { method: () => null };
spyOn( obj, 'method' );
for ( let i = 0; i < 3; i++ {
obj.method();
}
expect( obj.method ).toHaveBeenCalledTimes( 3 );
案例 7:断言方法被调用时带有参数
const calculator = { add: ( x: number, y: number ) => x + y };
spyOn( calculator, 'add' ).and.callThrough();
expect( calculator.add( 3, 4 ) ).toEqual( 7 );
expect( calculator.add ).toHaveBeenCalledWith( 3, 4 );
案例 8:断言某个方法被多次调用并带有参数
const ids = [ 'ABC123', 'DEF456' ];
const db = { store: ( id: string) => void };
spyOn( db, 'store' );
ids.forEach( ( id: string ) => db.store( id ) );
expect( db.store ).toHaveBeenCalledWith( 'ABC123' );
expect( db.store ).toHaveBeenCalledWith( 'DEF456' );
用户输入事件
我们可以通过模拟事件来模拟用户输入,而无需与 DOM 交互DebugElement
。AngularDebugElement
组件以独立于浏览器的方式渲染为HTMLElement
。这意味着我们无需浏览器渲染实际的 HTML 即可测试元素。
待测试组件
@Component({
selector: 'simple-button',
template: `
<div class="unnecessary-container">
<button (click)="increment()">Click Me!</button>
</div>
`
})
class SimpleButtonComponent {
clickCounter: number = 0;
increment() {
this.clickCounter += 1;
}
}
测试示例
it( 'When the button is clicked, then click counter should increment', () => {
// GIVEN - Capture reference to DebugElement not NativeElement and verify initial state
const buttonDE = fixture.debugElement.find( By.css( 'button' ) );
expect( component.clickCounter ).toEqual( 0 );
// WHEN - Simulate the user input event and detect changes.
buttonDE.triggerEventHandler( 'click', {} );
fixture.detectChanges();
// THEN - Assert change in component's state
expect( component.clickCounter ).toEqual( 1 );
} );
继承的功能
我们不应该在继承自子类的类中测试父类的功能。相反,应该模拟继承的功能。
父类
class ResourceComponent {
protected getAllResources( resourceName ): Resource[] {
return this.externalSource.get( resourceName );
}
}
Child 类
class ContactsComponent extends ResourceComponent {
getAvailableContacts(): Contact[] {
return this.getAllResources( 'contacts' )
.filter( ( contact: Contact ) => contact.available );
}
}
测试示例
it( 'When the getAvailableContacts method is called, Then it should return contacts where available is true', () => {
// GIVEN - Intercept call to inherited method and return a mocked response.
spyOn( component, 'getAllResources' ).and.returnValue( [
{ id: 1, name: 'Charles McGill', available: false },
{ id: 2, name: 'Tom Tso', available: true },
{ id: 3, name: 'Ruben Blades', available: true }
] );
// WHEN - Perform operation on inheriting class
const contacts = component.getAvailableContacts();
// THEN - Assert that interaction between inherited and inheriting is correctly applied.
expect( component.getAllResources ).toHaveBeenCalledWith( 'contacts' );
expect( contacts.length ).toEqual( 2 );
expect( contacts.any( c => name === 'Charles McGill' ) ).toBe( false );
} );
服务
服务对象使用函数进行测试inject()
。TestBed
每次测试都会注入一个新的服务对象实例。async()
测试异步行为(例如 Observable 和 Promises)时使用此函数。用于of()
模拟可观察对象。
测试代码
class NameService {
constructor( private cache: CacheService ) {}
getNames(): Observable<string[]> {
return this.cache.get( 'names' );
}
}
测试示例
it( 'When getNames is called Then return an observable list of strings', async(
inject( [CacheService, NameService], ( cache, nameService ) => {
// GIVEN - Mock service dependencies with expected value
const testNames = ["Raul", "Fareed", "Mark"];
spyOn( cache, 'get' ).and.returnValue( of( testNames ) );
// WHEN - Subscribe to observable returned by service method
nameService.getNames().subscribe( ( names: string[] ) => {
// THEN - Assert result matches expected value
expect( names ).toMatch( testNames );
} );
} ) );
输入变量
从 Angular 5 开始,组件输入的行为与普通属性一样。我们可以使用 Fixture 变更检测来测试变更。
测试代码
class CounterComponent implements OnChanges {
@Input() value: string;
changeCounter: number = 0;
ngOnChanges() {
changeCounter++;
}
}
测试示例
it( 'When the value input is changed, the changeCounter incrementsByOne', () => {
// GIVEN - Spy on the ngOnChanges lifecycle method and assert initial state.
spyOn( component, 'ngOnChanges' );
expect( component.value ).toBeUndefined();
expect( component.changeCouner ).toEqual( 0 );
// WHEN - Set the input variable and call on fixture to detect changes.
component.value = 'First Value';
fixture.detectChanges();
// THEN - Assert that lifecycle method was called and state has been updated.
expect( component.ngOnChanges ).toHaveBeenCalled();
expect( component.changeCounter ).toEqual( 1 );
} );
输出变量
组件通常会将事件发射器暴露为输出变量。我们可以直接监视这些发射器,从而避免测试异步订阅。
测试代码
class EmittingComponent {
@Output() valueUpdated: EventEmitter<string> = new EventEmitter<>();
updateValue( value: string ) {
this.valueUpdated.emit( value );
}
}
测试示例
it( 'When the updateValue() method is called with a string, Then the valueUpdated output will emit with the string', () => {
// GIVEN - Create a test argument and spy on the emitting output variable.
const value = 'Test Value';
spyOn( component.valueUpdated, 'emit' );
// WHEN - Call a method that will trigger the output variable to emit.
component.updateValue( value );
// THEN - Assert that the output variable has emitted correctly with the test argument.
expect( component.valueUpdated.emit ).toHaveBeenCalledWith( value );
} );
应用程序事件
通过在 fakeAsync 环境中模拟事件调度,可以测试全局对象或父组件触发的事件。我们可以使用该flush()
函数以同步方式解决所有待处理的异步操作。
测试代码
class ListeningComponent {
focus: string;
@HostListener( 'window:focus-on-dashboard', ['$event'] )
onFocusOnDashboard() {
this.focus = 'dashboard';
}
}
测试示例
it( 'When the window dispatches a focus-on-dashboard event, Then the focus is set to dashboard', fakeAsync( () => {
// GIVEN - Prepare spy for callback and validate initial state.
spyOn( component, 'onFocusOnDashboard' );
expect( component.focus ).not.toEqual( 'dashboard' );
// WHEN - Dispatch the event, resolve all pending, asynchronous operations and call fixture to detect changes.
window.dispatchEvent( new Event( 'focus-on-dashboard' ) );
flush();
fixture.detectChanges();
// THEN - Assert that callback was called and state has changed correctly.
expect( component.onFocusOnDashboard ).toHaveBeenCalled();
expect( component.focus ).toEqual( 'dashboard' );
} ) );
生命周期方法
测试生命周期方法其实没什么实际意义。这相当于测试框架,超出了我们的职责范围。生命周期方法所需的任何逻辑都应该封装在辅助方法中。请直接测试辅助方法。有关需要调用生命周期方法的测试,请参阅异步行为ngOnInit()
。
模拟方法链
我们偶尔可能需要以方法链的形式模拟一系列方法调用。这可以使用spyOn
函数来实现。
测试代码
class DatabseService {
db: DatabaseAdapter;
getAdultUsers(): User[] {
return this.db.get( 'users' ).filter( 'age > 17' ).sort( 'age', 'DESC' );
}
}
测试示例
it( 'When getAdultUsers is called, Then return users above 17 years of age', inject([DatabaseService], ( databaseService ) => {
// GIVEN - Mock the database adapter object and the chained methods
const testUsers = [
{ id: 1, name: 'Bob Odenkirk' },
{ id: 2, name: 'Ralph Morty' }
];
const db = { get: () => {}, filter: () => {}, sort: () => {} };
spyOn( db, 'get' ).and.returnValue( db );
spyOn( db, 'filter' ).and.returnValue( db );
spyOn( db, 'sort' ).and.returnValue( testUsers );
databaseService.db = db;
// WHEN - Test the method call
const users = databaseService.getAdultUsers();
// THEN - Test interaction with method chain
expect( db.get ).toHaveBeenCalledWith( 'users' );
expect( db.filter ).toHaveBeenCalledWith( 'age > 17' );
expect( db.sort ).toHaveBeenCalledWith( 'age', 'DESC' );
expect( users ).toEqual( testUsers );
} ) );
HTTP 调用
Angular 提供了一些实用程序,用于在测试套件中拦截和模拟 http 调用。我们不应该在测试期间执行真正的 http 调用。以下是一些重要的对象:
- XHRBackend :拦截由HTTP或HTTPClient执行的请求。
- MockBackend:用于配置 XHRBackend 如何与拦截的请求交互的测试 API。
- MockConnection:用于配置单独的、拦截的请求和响应的测试 API。
测试代码
class SearchService {
private url: string = 'http://localhost:3000/search?query=';
constructor( private http: Http ) {}
search( query: string ): Observable<string[]> {
return this.http.get( this.url + query, { withCredentials: true } ).pipe(
catchError( ( error: any ) => {
UtilService.logError( error );
return of( [] );
} )
);
}
}
文本示例
let backend: MockBackend;
let lastConnection: MockConnection;
beforeEach( () => {
TestBed.configureTestingModule( {
imports: [HttpModule],
providers: [
{ provide: XHRBackend, useClass: MockBackend },
SearchService
]
} );
backend = TestBed.get(XHRBackend) as MockBackend;
backend.connections.subscribe( ( connection: MockConnection ) => {
lastConnection = connection;
} );
} );
it( 'When a search request is sent, Then receive an array of string search results.',
fakeAsync( inject( [SearchService], ( searchService: SearchService ) => {
// GIVEN - Prepare mock search results in the form of a HTTP Response
const expectedSearchResults = [ ... ];
const mockJSON = JSON.stringify( { data: expectedSearchResults } );
const mockBody = new ResponseOptions( { body: mockJSON } );
const mockResponse = new Response( mockBody );
// WHEN - Perform the call and intercept the connection with a mock response.
let receivedSearchResults: string[];
searchService.search( 'reso' ).subscribe( ( searchResults: string[] ) => {
receivedSearchResults = searchResults;
} );
lastConnection.mockRespond( mockResponse );
// THEN - Complete the pending transaction and assert that the mock response
// was received and processed correctly.
flushMicrotasks();
expect( receivedSearchResults ).toBeDefined();
expect( receivedSearchResults ).toEqual( expectedSearchResults );
} ) )
);