测试带有子组件的组件
学习目标
-
渲染带有或不带子组件的组件
-
检查父组件及其子组件的正确连接
-
使用虚拟组件替换子组件
-
使用 ng-mocks 库来模拟依赖关系
展示型组件 |
到目前为止,我们已经测试了一个独立的组件,它只渲染了普通的 HTML 元素,没有包含子组件。这种低级组件是 Angular 应用程序的核心组成部分。
-
它们直接呈现给用户看到并与之交互的内容。
-
它们通常是高度通用和可重用的。
-
它们通过输入属性进行控制,并使用输出属性进行反馈。
-
它们的依赖性很少或几乎没有。
-
它们易于理解,因此易于测试。
-
测试这些组件的首选方式是单元测试。
这些组件被称为 展示型组件 ,因为它们直接使用 HTML 和 CSS 展示用户界面的一部分。展示型组件需要组合和连接在一起,形成一个工作的用户界面。
容器组件 |
这是 容器组件 的职责。这些高级组件将多个低级组件组合在一起。它们从不同的来源(如服务和状态管理器)获取数据,并将其分发给它们的子组件。
容器组件有几种类型的依赖关系。它们依赖于嵌套的子组件,还依赖于可注入对象。可注入对象是通过依赖注入提供的类、函数、对象等,比如服务。这些依赖关系使得测试容器组件变得复杂。
浅渲染 vs. 深渲染 |
有两种基本的测试带有子组件的组件的方式:
-
使用 浅渲染 进行单元测试。子组件不会被渲染。
-
使用 深渲染 进行集成测试。子组件会被渲染。
同样,这两种方法都是有效的,我们将进行讨论。
浅渲染 vs. 深渲染
在计数器示例应用程序中, HomeComponent
包含 CounterComponent
、ServiceCounterComponent
和 NgRxCounterComponent
。
从 模板 中:
<app-counter
[startCount]="5"
(countChange)="handleCountChange($event)"
></app-counter>
<!-- … -->
<app-service-counter></app-service-counter>
<!-- … -->
<app-ngrx-counter></app-ngrx-counter>
这些自定义的 app-
元素最终出现在 DOM 树中。它们成为子组件的 _宿主元素。
仅检查连接 |
HomeComponent
的 单元测试 不会渲染这些子组件。宿主元素被渲染,但保持为空。你可能会想,这样的测试有什么意义?它到底做了什么呢?
从 HomeComponent
的角度来看,其子组件的内部工作并不重要。我们需要测试模板是否包含这些子组件。此外,我们还需要检查 HomeComponent
及其子组件是否使用输入和输出正确地进行了连接。
特别是,HomeComponent
的单元测试检查是否存在 app-counter
元素,startCount
输入是否正确传递,以及 HomeComponent
是否处理了 countChange
事件。对于其他子组件,如 app-service-counter
和 app-ngrx-counter
,也是同样的做法。
渲染子组件 |
HomeComponent
的集成测试 会渲染子组件。宿主元素将分别填充 CounterComponent
、ServiceCounterComponent
和 NgRxCounterComponent
的输出内容。这个集成测试实际上测试了这四个组件。
测试协同工作 |
我们需要确定测试嵌套组件的详细程度。如果它们有单独的单元测试,我们就不需要点击每个相应的增加按钮。毕竟,集成测试需要证明这四个组件可以协同工作,而不涉及子组件的细节。
单元测试
让我们先为 HomeComponent
编写一个单元测试。设置过程与 CounterComponent
的测试套件相似。我们使用 TestBed
来配置一个测试模块,并渲染要测试的组件。
describe('HomeComponent', () => {
let fixture: ComponentFixture<HomeComponent>;
let component: HomeComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders without errors', () => {
expect(component).toBeTruthy();
});
});
冒烟测试 |
这个测试套件包含一个 冒烟测试 规范。它检查组件实例的存在性,但并不对组件的具体行为进行断言。它只是验证组件能够在没有错误的情况下正常渲染。
如果冒烟测试失败,说明测试设置存在问题。
未知的自定义元素 |
从 Angular 9 开始,这个测试规范通过了,但是在控制台上产生了一系列警告信息:
'app-counter' is not a known element:
1. If 'app-counter' is an Angular component, then verify that it is part of this module.
2. If 'app-counter' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.
我们在 app-service-counter
和 app-ngrx-counter
中也得到了同样的警告。还有另一个警告信息:
无法绑定到 'startCount',因为它不是 'app-counter' 的已知属性。
这些警告的含义是什么呢?Angular 无法识别自定义元素 app-counter
、app-service-counter
和 app-ngrx-counter
,因为我们没有声明与这些选择器匹配的组件。警告指向了两个解决方案:
-
要么在测试模块中声明子组件,将测试转为集成测试;
-
要么告诉 Angular 忽略这些未知元素,将测试保持为单元测试。
忽略子元素 |
由于我们计划编写的是单元测试,我们选择了第二个解决方案。
在配置测试模块时,我们可以指定 schemas
来告诉 Angular 如何处理那些未被指令或组件处理的元素。
警告建议使用 CUSTOM_ELEMENTS_SCHEMA
,但是这些元素并不是 Web 组件。我们希望 Angular 简单地忽略这些元素。因此,我们使用 NO_ERRORS_SCHEMA
,这是一个“允许任何元素上的任何属性”的模式。
await TestBed.configureTestingModule({
declarations: [HomeComponent],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
加上这个配置后,我们的冒烟测试通过了。
现在让我们编写一个更有意义的规范!我们从嵌套的 app-counter
开始。这是我们需要覆盖的代码:
<app-counter
[startCount]="5"
(countChange)="handleCountChange($event)"
></app-counter>
子元素存在性测试 |
首先,我们需要测试独立计数器 app-counter
的存在。我们创建一个新的规范来完成这个目的:
it('renders an independent counter', () => {
/* … */
});
为了验证在 DOM 中存在一个 app-counter
元素,我们使用最顶层的 DebugElement
的熟悉的 query
方法。
const { debugElement } = fixture;
const counter = debugElement.query(By.css('app-counter'));
这段代码使用了 app-counter
类型选择器来查找元素。你可能会想,为什么不使用测试 ID 和 findEl
辅助函数呢?
通过元素类型查找 |
在这种罕见的情况下,我们需要强制使用 app-counter
元素,因为这是 CounterComponent
的选择器。
使用测试 ID 会使元素类型变得任意。这在其他情况下可以使测试更加健壮。然而,在测试子组件存在性时,正是元素类型调用了子组件。
我们的测试还缺少一个期望值。query 方法返回一个 DebugElement
或 null
。我们只需期望返回值为真值:
it('renders an independent counter', () => {
const { debugElement } = fixture;
const counter = debugElement.query(By.css('app-counter'));
expect(counter).toBeTruthy();
});
查找子组件是一个常见的任务。这样重复的模式是测试辅助函数的好候选对象。不是因为它是很多代码,而是因为代码具有特定的含义,我们希望传达出来。
debugElement.query(By.css('app-counter'))
并不特别描述性。读者需要花一点时间才能意识到这段代码试图找到一个嵌套的组件。
findComponent
|
因此,让我们引入一个名为 findComponent
的辅助函数。
export function findComponent<T>(
fixture: ComponentFixture<T>,
selector: string,
): DebugElement {
return fixture.debugElement.query(By.css(selector));
}
我们的规范现在如下所示:
it('renders an independent counter', () => {
const counter = findComponent(fixture, 'app-counter');
expect(counter).toBeTruthy();
});
检查输入 |
接下来,我们需要测试的是 startCount
输入。特别是 HomeComponent
模板中的属性绑定 [startCount]="5"
。让我们创建一个新的规范:
it('passes a start count', () => {
const counter = findComponent(fixture, 'app-counter');
/* … */
});
properties
|
我们如何读取输入值?每个 DebugElement
都有一个 properties
对象,其中包含DOM属性及其值。此外,它还包含特定的属性绑定(类型为 { [key: string]: any }
)。
在使用浅层渲染的单元测试中,properties
包含子组件的输入。首先,我们找到 app-counter
以获取相应的 DebugElement
。然后,我们检查输入值 properties.startCount
。
it('passes a start count', () => {
const counter = findComponent(fixture, 'app-counter');
expect(counter.properties.startCount).toBe(5);
});
那非常简单!最后但同样重要的是,我们需要测试输出事件。
输出事件 |
从 HomeComponent
的角度来看,对输出事件的反应就像处理 app-counter
元素上的事件一样。模板使用了熟悉的 (event)="handler($event)"
语法:
<app-counter
[startCount]="5"
(countChange)="handleCountChange($event)"
></app-counter>
handleCountChange
方法在组件类中定义。它只是调用 console.log
来证明子组件和父组件之间的通信成功了:
export class HomeComponent {
public handleCountChange(count: number): void {
console.log('countChange event from CounterComponent', count);
}
}
让我们添加一个新的规范来测试输出(Output):
it('listens for count changes', () => {
/* … */
});
该规范需要执行两个步骤:
-
Act: 找到子组件,并让
countChange
输出(Output)发出一个值。 -
Assert: 检查
console.log
是否已被调用。
从父组件的角度来看,countChange
只是一个事件。浅渲染意味着没有 CounterComponent
实例,也没有名为 countChange
的 EventEmitter
。Angular只会看到一个元素 app-counter
,带有一个事件处理器 (countChange)="handleCountChange($event)"
。
模拟输出(Output) |
在这种设置中,我们可以使用已知的 triggerEventHandler
方法来模拟输出(Output)。
it('listens for count changes', () => {
/* … */
const counter = findComponent(fixture, 'app-counter');
const count = 5;
counter.triggerEventHandler('countChange', 5);
/* … */
});
该规范找到 app-counter
元素并触发 countChange
事件处理程序。
第二个 triggerEventHandler
参数 5
不是我们从DOM事件(如 click
)中所了解的事件对象。它是一个输出(Output)将会发出的值。countChange
输出的类型是 EventEmitter<number>
,因此我们在测试中使用固定的数字 5
。
输出效果 |
在内部,triggerEventHandler
使用 $event
为 5
运行 handleCountChange($event)
。handleCountChange
调用 console.log
。这是我们需要测试的可观察效果。
如何验证是否已调用 console.log
?我们可以使用Jasmine的 spyOn
来 对现有方法进行监听。
spyOn(console, 'log');
在整个测试运行期间,这将使用spy覆盖 console.log
。我们需要在测试规范的开始处,在 Arrange 阶段设置spy。
it('listens for count changes', () => {
spyOn(console, 'log');
const counter = findComponent(fixture, 'app-counter');
const count = 5;
counter.triggerEventHandler('countChange', count);
/* … */
});
在 断言(Assert) 阶段,我们期望该spy已被调用,并传入特定的文本和Output所发出的数字。
it('listens for count changes', () => {
spyOn(console, 'log');
const counter = findComponent(fixture, 'app-counter');
const count = 5;
counter.triggerEventHandler('countChange', count);
expect(console.log).toHaveBeenCalledWith(
'countChange event from CounterComponent',
count,
);
});
至此我们已经测试了 CounterComponent
子组件。HomeComponent
还会以类似以下方式渲染 ServiceCounterComponent
和NgRxCounterComponent
:
<app-service-counter></app-service-counter>
<!-- … -->
<app-ngrx-counter></app-ngrx-counter>
子组件存在性 |
由于它们没有输入或输出,我们只需测试它们是否在模板中被提及。我们添加两个额外的规范,分别检查这些 app-service-counter
和 app-ngrx-counter
元素的存在。
it('renders a service counter', () => {
const serviceCounter = findComponent(fixture, 'app-service-counter');
expect(serviceCounter).toBeTruthy();
});
it('renders a NgRx counter', () => {
const ngrxCounter = findComponent(fixture, 'app-ngrx-counter');
expect(ngrxCounter).toBeTruthy();
});
就是这样了!我们使用浅渲染编写了一个单元测试,证明了 HomeComponent
正确嵌入了几个子组件。
请注意,这只是一种可能的测试方法。和全面的集成测试相比,设置工作很少。规范可以使用Angular的 DebugElement
抽象来测试存在性以及输入和输出。
单元测试的可信度 |
然而,单元测试对于 HomeComponent
在生产环境中的正常工作并没有太多的信心。我们已经指示Angular忽略了元素 app-counter
、app-service-counter
和app-ngrx-counter
。
如果HomeComponent
使用了错误的元素名称,并且测试复制了该错误,那么测试将错误地通过。我们需要将涉及的组件一起渲染,以便发现错误。
模拟子组件
在天真的单元测试和集成测试之间有一种中间地带。我们可以使用 假 的子组件来渲染,而不是使用空的自定义元素。
假组件具有相同的选择器、输入和输出,但没有依赖项,也不需要渲染任何内容。当测试带有子组件的组件时,我们可以用假组件替换子组件。
让我们将CounterComponent
简化为一个什么都不做但提供相同公共API的空壳子:
@Component({
selector: 'app-counter',
template: '',
})
class FakeCounterComponent implements Partial<CounterComponent> {
@Input()
public startCount = 0;
@Output()
public countChange = new EventEmitter<number>();
}
这个伪造的组件缺少模板和任何逻辑,但具有相同的选择器、输入和输出。
相同的公共API |
还记得 伪造依赖关系的规则 吗?我们需要确保伪造的组件与原始组件相似。FakeCounterComponent 实现 Partial<CounterComponent>
要求该类实现 CounterComponent
的一个子集。TypeScript 会强制要求给定的属性和方法具有与原始类相同的类型。
声明伪造组件 |
在我们的测试套件中,我们将 FakeCounterComponent
放置在 describe
块之前。下一步是将该组件添加到测试模块中:
TestBed.configureTestingModule({
declarations: [HomeComponent, FakeCounterComponent],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
当 Angular 遇到 app-counter
元素时,它会实例化并挂载一个 FakeCounterComponent
。由于伪造的模板也是空的,因此该元素保持为空。startCount
输入属性被设置,并且父级 HomeComponent
订阅 countChange
输出。
现在,由于子组件被渲染,我们需要调整测试套件。我们不再搜索 app-counter
元素并检查其属性,而是显式搜索 FakeCounterComponent
实例。
到目前为止,我们使用了 DebugElement
的 query
方法来查找嵌套元素。例如:
const element = fixture.debugElement.query(By.css('…'));
我们的辅助函数 findEl
和 findComponent
也使用了这种模式。
通过指令查找 |
现在我们想要找到一个嵌套的组件。我们可以使用 query
方法和 By.directive
这个断言函数:
const counterEl = fixture.debugElement.query(
By.directive(FakeCounterComponent)
);
By.directive
可以找到各种类型的指令,组件也是指令的一种。
query
方法会返回一个 DebugElement
,如果没有找到匹配的元素,则返回 null
。正如我们所了解的,DebugElement
总是包装了一个原生的 DOM 元素。当我们查询 FakeCounterComponent
时,我们会得到一个包装了 app-counter
元素的 DebugElement
,就像 By.css('app-counter')
一样。
获取子组件实例 |
不同的是,现在我们可以通过 componentInstance
属性访问渲染的 FakeCounterComponent
的实例:
const counterEl = fixture.debugElement.query(
By.directive(FakeCounterComponent)
);
const counter: CounterComponent = counterEl.componentInstance;
由于 Angular 不知道组件的类型,componentInstance
的类型是 any
。因此,我们添加了一个显式的类型注释。
子组件的存在 |
有了对子组件实例的访问权限,我们可以对其进行断言。首先,我们验证其是否存在。
it('renders an independent counter', () => {
const counterEl = fixture.debugElement.query(
By.directive(FakeCounterComponent)
);
const counter: CounterComponent = counterEl.componentInstance;
expect(counter).toBeTruthy();
});
这是一个冒烟测试,如果没有找到 FakeCounterComponent
的实例,它将会提前失败。query
会返回 null
,并且 counterEl.componentInstance
会因为 TypeError: counterEl is null
而失败。
检查输入 |
第二个规范检查输入。输入是组件实例的一个属性,因此 counter.startCount
可以给出 startCount
输入的值。
it('passes a start count', () => {
const counterEl = fixture.debugElement.query(
By.directive(FakeCounterComponent)
);
const counter: CounterComponent = counterEl.componentInstance;
expect(counter.startCount).toBe(5);
});
第三个规范检查输出的处理:如果计数器发出一个值,HomeComponent
将其传递给 console.log
。
触发输出 |
正如之前提到的,输出是组件实例上的 EventEmitter
属性。之前我们使用 triggerEventHandler
抽象来模拟输出事件。现在我们可以直接访问输出,并调用其 emit
方法,就像子组件中的代码一样。
it('listens for count changes', () => {
const counterEl = fixture.debugElement.query(
By.directive(FakeCounterComponent)
);
const counter: CounterComponent = counterEl.componentInstance;
spyOn(console, 'log');
const count = 5;
counter.countChange.emit(5);
expect(console.log).toHaveBeenCalledWith(
'countChange event from CounterComponent',
count,
);
});
我们完成了!这是对 CounterComponent
子组件进行验证的 HomeComponent
测试套件。为了减少重复和噪音,我们将查询部分移到 beforeEach
块中。
@Component({
selector: 'app-counter',
template: '',
})
class FakeCounterComponent implements Partial<CounterComponent> {
@Input()
public startCount = 0;
@Output()
public countChange = new EventEmitter<number>();
}
describe('HomeComponent (faking a child Component)', () => {
let fixture: ComponentFixture<HomeComponent>;
let component: HomeComponent;
let counter: FakeCounterComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent, FakeCounterComponent],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
const counterEl = fixture.debugElement.query(
By.directive(FakeCounterComponent)
);
counter = counterEl.componentInstance;
});
it('renders an independent counter', () => {
expect(counter).toBeTruthy();
});
it('passes a start count', () => {
expect(counter.startCount).toBe(5);
});
it('listens for count changes', () => {
spyOn(console, 'log');
const count = 5;
counter.countChange.emit(count);
expect(console.log).toHaveBeenCalledWith(
'countChange event from CounterComponent',
count,
);
});
});
让我们总结一下使用这种方式测试 HomeComponent
所获得的收益。
我们用一个行为相同的伪造组件替代了一个组件依赖项,就 HomeComponent
来说,它们的行为是一样的。伪造的子组件被渲染,但模板可能是空的。
原始的子组件 CounterComponent
仅被导入用于创建衍生的伪造组件。我们的测试仍然是一个快速、简短的单元测试。
优势 |
与其搜索名为 app-counter
的元素,我们搜索组件实例。这样更加稳健。主机元素的存在是一个很好的指示,但更重要的是组件是否已被渲染到该元素中。
使用组件实例比使用 DebugElement
抽象更直观。我们可以读取组件的属性来了解输入和输出。基本的 JavaScript 和 Angular 知识足以编写针对这种实例的规范。
手动伪造的缺点 |
我们简单的伪造子组件方法有其缺点。我们手动创建了伪造组件。这是繁琐和耗时的,同时也存在风险。伪造组件只与原始组件部分相关。
例如,如果原始组件更改了其选择器 app-counter
,测试应该失败,并提醒我们调整模板。然而,由于我们没有继承组件元数据 { selector: 'app-counter', … }
,而是在测试中复制了它,因此测试会通过而不是失败。
在下一章中,我们将解决这些缺点。
使用 ng-mocks 伪造子组件
我们手动创建了一个组件伪造。这是一个重要的练习,用于理解如何伪造组件,但它不能产生一个稳健、多用途的伪造组件。在本指南中,我们无法讨论创建严密伪造组件所需的所有要点和细节。
相反,我们将使用一个成熟的解决方案: ng-mocks 是一个功能丰富的库,用于使用伪造依赖项测试组件。 (请记住,本指南使用总称“伪造(fake)”而其他文章和工具使用“模拟(mock)”或“存根(stub)”等术语。)
从原始组件创建伪造组件 |
ng-mocks 帮助创建伪造组件以替代子组件,是其中的一项功能。MockComponent
函数接受原始组件并返回一个类似原始组件的伪造组件。
我们不再创建 FakeCounterComponent
,而是调用 MockComponent(CounterComponent)
并将伪造组件添加到测试模块中。
import { MockComponent } from 'ng-mocks';
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent, MockComponent(CounterComponent)],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
然后,我们可以查询渲染的 DOM 来查找 CounterComponent
的实例。找到的实例实际上是由 ng-mocks 创建的伪造组件。
尽管如此,我们仍然可以声明类型 CounterComponent
。
describe('HomeComponent with ng-mocks', () => {
let fixture: ComponentFixture<HomeComponent>;
let component: HomeComponent;
// Original type!
let counter: CounterComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent, MockComponent(CounterComponent)],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
const counterEl = fixture.debugElement.query(
// Original class!
By.directive(CounterComponent)
);
counter = counterEl.componentInstance;
});
/* … */
});
从 TypeScript 的角度来看,伪造组件符合 CounterComponent
类型。TypeScript 使用结构类型系统,检查是否满足所有类型要求。
类型等价性 |
对于 CounterComponent
适用的每个命题也适用于伪造组件。伪造组件具有与原始组件相同的所有属性和方法。这就是为什么我们可以安全地用伪造组件替换原始组件,并在测试中将伪造组件视为相同的原始组件。
完整代码:
describe('HomeComponent with ng-mocks', () => {
let fixture: ComponentFixture<HomeComponent>;
let component: HomeComponent;
let counter: CounterComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent, Mock(CounterComponent)],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
const counterEl = fixture.debugElement.query(
By.directive(CounterComponent)
);
counter = counterEl.componentInstance;
});
it('renders an independent counter', () => {
expect(counter).toBeTruthy();
});
it('passes a start count', () => {
expect(counter.startCount).toBe(5);
});
it('listens for count changes', () => {
spyOn(console, 'log');
const count = 5;
counter.countChange.emit(count);
expect(console.log).toHaveBeenCalledWith(
'countChange event from CounterComponent',
count,
);
});
});
我们消除了手动创建的 FakeCounterComponent
。我们使用 MockComponent(CounterComponent)
来创建伪造组件,并使用原始的 CounterComponent
类。测试本身并没有改变。
这只是 ng-mocks 的一个简单示例。该库不仅帮助处理嵌套组件,还提供了高级的辅助方法来设置 Angular 测试环境。ng-mocks 可以替代传统的 TestBed.configureTestingModule
设置,并帮助伪造模块、组件、指令、管道和服务。