为什么在运行Angular单元测试时要创建两个模拟子组件实例



我的Angular 13应用程序具有以下功能:

  • 显示欢迎消息的应用程序组件和父组件
  • 父组件,在div中显示一些文本和子组件,并侦听子组件的选择更改,使用@ViewChild获取子组件
  • 显示mat-select并通过通过公共Observable输出公开的专用EventEmitter发出用户选择的子组件

当我运行应用程序时,一切都正常。当我在子组件中进行选择时,父组件会按预期进行选择更改。但是当我运行父组件单元测试时,使用一个用RxJsReplaySubject代替EventEmitterObservable的模拟子组件,父组件不会得到预期的选择更改。

在研究这种行为时,我添加了一些console.log语句和一些唯一的对象ID,以了解正在创建哪些组件。我了解到,正如预期的那样,创建了一个父组件实例,但创建了两个不同的模拟子组件实例!更糟糕的是,其中一个子组件实例被设置为父组件的ViewChild,并且在ngAfterViewInit中可用,但在查询component.debugElement时,另一个子组件示例在父测试中返回。这意味着,当我人为地在测试的子组件上发出选择更改时,父组件永远不会看到它

查看下面的测试输出,可以看到创建了两个mock子组件。测试通过查询debugElement获得ID为1的组件,但父组件获得ID为2的组件作为其ViewChild。我只希望有一个模拟子组件。

同样,这只是单元测试过程中的一个问题。在运行的应用程序中,只创建一个子组件实例,并且一切正常。

为什么Angular在测试过程中要创建两个模拟子组件实例,以及如何强制它只创建一个?

app.module.ts:

import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AppComponent } from './app.component';
import { ChildComponent } from './child/child.component';
import { ParentComponent } from './parent/parent.component';
@NgModule({
declarations: [
AppComponent,
ParentComponent,
ChildComponent
],
imports: [
BrowserAnimationsModule,
BrowserModule,
FormsModule,
MatSelectModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

app.component.html:

<h1>Welcome</h1>
<app-parent></app-parent>

app.component.ts:

import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
title = 'parent-child-test';
}

parent.component.html:

<div class="order-form">
<h2>Order Form</h2>
<div>
<b>Flavor</b>
<app-child></app-child>
</div>
</div>

parent.component.ts:

import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core';
import { ChildComponent, Flavor, flavors } from '../child/child.component';
@Component({
selector: 'app-parent',
templateUrl: './parent.component.html',
styleUrls: ['./parent.component.scss']
})
export class ParentComponent implements AfterViewInit {
private static lastId = 0;// For testing
readonly id: number;
flavor: Flavor = flavors[0];
@ViewChild(ChildComponent)
private childComponent!: ChildComponent;
constructor() {
this.id = ++ParentComponent.lastId;
console.log(`Created ${this.constructor.name} ${this.id}`);
}
ngAfterViewInit(): void {
console.log(
`In parent afterViewInit, child component ID is ${this.childComponent.id}`
);
this.childComponent.flavorChanged.subscribe(flavor => {
console.log(`New flavor is ${flavor}. Everything seems to be working!`);
this.flavor = flavor;
});
}
}

parent.component.spec.ts:

import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ReplaySubject } from 'rxjs';
import { ChildComponent, Flavor, flavors } from '../child/child.component';
import { ParentComponent } from './parent.component';
@Component({
selector: 'app-child',
template: '',
providers: [
{ provide: ChildComponent, useClass: MockChildComponent }
]
})
class MockChildComponent {
private static lastId = 0;// For testing
readonly id: number;// For testing
readonly flavorChanged = new ReplaySubject<Flavor>(1);
constructor() {
this.id = ++MockChildComponent.lastId;// For testing
console.log(`Created ${this.constructor.name} ${this.id}`);
}
}
describe('ParentComponent', () => {
let component: ParentComponent;
let fixture: ComponentFixture<ParentComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [MockChildComponent, ParentComponent]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ParentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should receive flavor when child component emits it', () => {
let flavor: Flavor = flavors[0];
expect(fixture.componentInstance.flavor).toEqual(flavor);
flavor = flavors[flavors.length - 1];
// I tried queryAll instead of query to see if I could get
// more than one, but it only returned one, and this is it:
const childComponent: MockChildComponent = fixture.debugElement.query(
By.directive(ChildComponent)
).componentInstance;
console.log(`In test, child component ID is ${childComponent.id}`);
childComponent.flavorChanged.next(flavor);
expect(fixture.componentInstance.flavor).toEqual(flavor);
});
});

child.component.html:

<mat-select [(ngModel)]="flavor">
<mat-option *ngFor="let flavorOption of flavors" [value]="flavorOption">
{{ flavorOption }}
</mat-option>
</mat-select>

child.component.ts:

import { Component, EventEmitter, OnInit, Output } from '@angular/core';
export const flavors = ['Chocolate', 'Strawberry', 'Vanilla'] as const;
export type Flavor = typeof flavors[number];
@Component({
selector: 'app-child',
templateUrl: './child.component.html',
styleUrls: ['./child.component.scss']
})
export class ChildComponent {
private static lastId = 0;// For testing
readonly id: number;// For testing
readonly flavors = flavors;
selectedFlavor: Flavor = flavors[0];
private readonly flavorChangedEmitter = new EventEmitter<Flavor>();
@Output()
readonly flavorChanged = this.flavorChangedEmitter.asObservable();
constructor() {
this.id = ++ChildComponent.lastId;// For testing
console.log(`Created ${this.constructor.name} ${this.id}`);
}
get flavor(): Flavor {
return this.selectedFlavor as Flavor;
}
set flavor(newFlavor: Flavor) {
this.selectedFlavor = newFlavor;
this.flavorChangedEmitter.emit(newFlavor);
}
}

运行应用程序并选择口味时的控制台消息(这很好):

parent.component.ts:22 Created ParentComponent 1
child.component.ts:28 Created ChildComponent 1
parent.component.ts:26 In parent afterViewInit, child component ID is 1
parent.component.ts:28 New flavor is Vanilla. Everything seems to be working!

测试输出(不起作用):

LOG: 'Created ParentComponent 1'
LOG: 'Created MockChildComponent 1'
LOG: 'Created MockChildComponent 2'
LOG: 'In parent afterViewInit, child component ID is 2'
LOG: 'In test, child component ID is 1'
Chrome 100.0.4896.75 (Windows 10) ParentComponent should receive flavor when child component emits it FAILED
Error: Expected 'Chocolate' to equal 'Vanilla'.
at <Jasmine>
at UserContext.<anonymous> (src/app/parent/parent.component.spec.ts:61:46)        
at _ZoneDelegate.invoke (node_modules/zone.js/fesm2015/zone.js:372:1)
at ProxyZoneSpec.onInvoke (node_modules/zone.js/fesm2015/zone-testing.js:287:1)   
Chrome 100.0.4896.75 (Windows 10): Executed 1 of 5 (1 FAILED) (skipped 4) (0.117 secs / 0.08 secs)
TOTAL: 1 FAILED, 0 SUCCESS

这是意料之中的事,因为您在@Component中提供服务。

在这种情况下,与@Directive类似,对于组件的每个新实例,都将有其提供程序的新实例,这些实例将用于嵌套的组件/指令/管道。

如果需要覆盖提供者,只需在AppModuleTestBed中覆盖它即可进行测试。

最新更新