Angular 测试备忘单 Angular 单元测试备忘单异步行为

2025-05-25

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 } ) );
    ...
}
Enter fullscreen mode Exit fullscreen mode

上面的代码示例很难测试。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 } ) );
}
Enter fullscreen mode Exit fullscreen mode

在我们改进的示例中,我们不再需要确保所有其他操作都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 } ) );
}
Enter fullscreen mode Exit fullscreen mode

在我们最好的实现中,逻辑完全独立于组件的状态。组件编译时我们不需要模拟任何东西,只需提供原生的 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 );

} );
Enter fullscreen mode Exit fullscreen mode

异步行为

Angular Testing 模块提供了两个用于测试异步操作的实用程序。

异步测试工具说明

  • async:测试将等到所有异步行为都解析完毕后才结束。最好测试简单的异步行为,这些行为不应阻塞很长时间。避免与可能挂起或持续很长时间才能解析的异步行为一起使用。
  • fakeAsync:测试将拦截异步行为并以同步方式执行。最适合测试异步行为链或可能挂起或需要很长时间才能解决的不可靠异步行为。
  • tick :在fakeAsync测试中模拟时间的流逝。需要一个表示已用时间(以毫秒为单位)的数字参数。
  • flushMicrotasks:强制完成所有待处理的微任务,例如PromisesObservables
  • flush:强制完成所有待处理的宏任务,例如setIntervalsetTimeout等。#### 要测试的代码
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;
        } );
    }

}
Enter fullscreen mode Exit fullscreen mode

测试示例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 );
    } );

} ) ) );
Enter fullscreen mode Exit fullscreen mode

测试示例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 );

} ) ) );
Enter fullscreen mode Exit fullscreen mode

间谍和模拟

通过监视函数,我们可以验证组件之间的交互是否在正确的条件下进行。我们使用模拟对象来减少需要测试的代码量。Jasmine 提供了spyOn()管理间谍和模拟的功能。

情况 1:断言某个方法已被调用。

const obj = { method: () => null };
spyOn( obj, 'method' );
obj.method();
expect( obj.method ).toHaveBeenCalled();
Enter fullscreen mode Exit fullscreen mode

警告:监视方法将阻止该方法的实际执行。

情况 2:断言某个方法被调用并执行方法。

const obj = { getName: () => 'Sam' };
spyOn( obj, 'getName' ).and.callThrough();
expect( obj.getName() ).toEqual( 'Sam' );
expect( obj.getName ).toHaveBeenCalled();
Enter fullscreen mode Exit fullscreen mode

情况 3:断言某个方法被调用并执行某个函数。

const obj = { getName: () => 'Sam' };
spyOn( obj, 'getName' ).and.callFake((args) => console.log(args));
expect( obj.getName() ).toEqual( 'Sam' );
expect( obj.getName ).toHaveBeenCalled();
Enter fullscreen mode Exit fullscreen mode

案例 4:模拟现有方法的响应。

const obj = { mustBeTrue: () => false };
spyOn( obj, 'mustBeTrue' ).and.returnValue( true );
expect( obj.mustBeTrue() ).toBe( true );
Enter fullscreen mode Exit fullscreen mode

案例 5:模拟现有方法的几种响应。

const iterator = { next: () => null };
spyOn( iterator, 'next' ).and.returnValues( 1, 2 );
expect( iterator.next ).toEqual( 1 );
expect( iterator.next ).toEqual( 2 );
Enter fullscreen mode Exit fullscreen mode

情况 6:断言某个方法被调用了多次。

const obj = { method: () => null };
spyOn( obj, 'method' );
for ( let i = 0; i < 3; i++ {
    obj.method();
}
expect( obj.method ).toHaveBeenCalledTimes( 3 );
Enter fullscreen mode Exit fullscreen mode

案例 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 );
Enter fullscreen mode Exit fullscreen mode

案例 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' );
Enter fullscreen mode Exit fullscreen mode

用户输入事件

我们可以通过模拟事件来模拟用户输入,而无需与 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;
    }

}
Enter fullscreen mode Exit fullscreen mode

测试示例

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 );

} );
Enter fullscreen mode Exit fullscreen mode

继承的功能

我们不应该在继承自子类的类中测试父类的功能。相反,应该模拟继承的功能。

父类

class ResourceComponent {

    protected getAllResources( resourceName ): Resource[] {
        return this.externalSource.get( resourceName );
    }

}
Enter fullscreen mode Exit fullscreen mode

Child 类

class ContactsComponent extends ResourceComponent {

    getAvailableContacts(): Contact[] {
        return this.getAllResources( 'contacts' )
            .filter( ( contact: Contact ) => contact.available );
    }

}
Enter fullscreen mode Exit fullscreen mode

测试示例

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 );

} );
Enter fullscreen mode Exit fullscreen mode

服务

服务对象使用函数进行测试inject()TestBed每次测试都会注入一个新的服务对象实例。async()测试异步行为(例如 Observable 和 Promises)时使用此函数。用于of()模拟可观察对象。

测试代码

class NameService {

    constructor( private cache: CacheService ) {}

    getNames(): Observable<string[]> {
        return this.cache.get( 'names' );
    }

}
Enter fullscreen mode Exit fullscreen mode

测试示例

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 );

    } );

} ) );

Enter fullscreen mode Exit fullscreen mode

输入变量

从 Angular 5 开始,组件输入的行为与普通属性一样。我们可以使用 Fixture 变更检测来测试变更。

测试代码

class CounterComponent implements OnChanges {

    @Input() value: string;
    changeCounter: number = 0;

    ngOnChanges() {
        changeCounter++;
    }

}
Enter fullscreen mode Exit fullscreen mode

测试示例

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 );

} );
Enter fullscreen mode Exit fullscreen mode

输出变量

组件通常会将事件发射器暴露为输出变量。我们可以直接监视这些发射器,从而避免测试异步订阅。

测试代码

class EmittingComponent {

    @Output() valueUpdated: EventEmitter<string> = new EventEmitter<>();

    updateValue( value: string ) {
        this.valueUpdated.emit( value );
    }

}
Enter fullscreen mode Exit fullscreen mode

测试示例

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 );

} );
Enter fullscreen mode Exit fullscreen mode

应用程序事件

通过在 fakeAsync 环境中模拟事件调度,可以测试全局对象或父组件触发的事件。我们可以使用该flush()函数以同步方式解决所有待处理的异步操作。

测试代码

class ListeningComponent {

    focus: string;

    @HostListener( 'window:focus-on-dashboard', ['$event'] )
    onFocusOnDashboard() {
        this.focus = 'dashboard';
    }

}
Enter fullscreen mode Exit fullscreen mode

测试示例

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' );

} ) );
Enter fullscreen mode Exit fullscreen mode

生命周期方法

测试生命周期方法其实没什么实际意义。这相当于测试框架,超出了我们的职责范围。生命周期方法所需的任何逻辑都应该封装在辅助方法中。请直接测试辅助方法。有关需要调用生命周期方法的测试,请参阅异步行为ngOnInit()


模拟方法链

我们偶尔可能需要以方法链的形式模拟一系列方法调用。这可以使用spyOn函数来实现。

测试代码

class DatabseService {

    db: DatabaseAdapter;

    getAdultUsers(): User[] {
        return this.db.get( 'users' ).filter( 'age > 17' ).sort( 'age', 'DESC' );
    }

}
Enter fullscreen mode Exit fullscreen mode

测试示例

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 );

} ) );
Enter fullscreen mode Exit fullscreen mode

HTTP 调用

Angular 提供了一些实用程序,用于在测试套件中拦截和模拟 http 调用。我们不应该在测试期间执行真正的 http 调用。以下是一些重要的对象:

  • XHRBackend :拦截由HTTPHTTPClient执行的请求
  • 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( [] );
            } )
        );
    }

}
Enter fullscreen mode Exit fullscreen mode

文本示例

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 );
    } ) )
);
Enter fullscreen mode Exit fullscreen mode
文章来源:https://dev.to/lysofdev/an-angular-testing-cheatsheet-5hj2
PREV
如何创建 GitHub 个人资料自述文件 GitHub 个人资料自述文件生成器
NEXT
我在 Vue CLI 3 项目中的 SCSS 设置