测试时使用Spectator库简化组件
学习目标:
-
使用Spectator库简化组件测试
-
使用统一的Spectator接口
-
与组件和渲染的DOM进行交互
-
发送合成DOM事件以模拟用户输入
-
使用Spectator和ng-mocks来伪造子组件和服务
我们使用了Angular的测试工具来设置模块、渲染组件、查询DOM等等。这些工具包括TestBed
、ComponentFixture
和DebugElement
,也包括HttpClientTestingModule
和RouterTestingModule
。
结构性弱点 |
内置工具比较低级和没有明确的意见。它们有几个缺点:
-
TestBed
需要大量的样板代码来设置一个常见的组件或服务测试。 -
DebugElement
缺少必要的功能,是一个“泄漏”的抽象。你被迫使用包装的本地DOM元素来处理常见任务。 -
没有默认的解决方案来安全地伪造组件和服务依赖。
-
测试本身变得冗长和重复。你必须建立测试约定并自己编写帮助程序。
我们已经使用了小型 元素测试辅助函数。它们解决了隔离问题,以便编写更一致和紧凑的规范。
如果你编写数百或数千个规范,你会发现这些帮助函数不够。它们没有解决上述结构性问题。
统一测试API |
Spectator 是一个用于测试Angular应用程序的有意见的库。从技术上讲,它位于TestBed
、ComponentFixture
和DebugElement
之上。但主要思想是在一个一致、强大和用户友好的接口Spectator
对象中统一所有这些API。
Spectator简化了测试组件、服务、指令、管道、路由和HTTP通信。Spectator的优势是具有输入、输出、子项、事件处理、服务依赖等组件测试。
对于 伪造子组件,Spectator采用了ng-mocks库,就像我们一样。
本指南无法介绍所有Spectator功能,但我们将讨论使用Spectator进行组件测试的基础知识。
两个 示例应用程序 都使用我们的元素帮助程序进行测试,也使用Spectator进行测试。前面的规范使用后缀.spec.ts
,而后者使用后缀.spectator.spec.ts
。这样,您可以将测试并排进行比较。
在本章中,我们将讨论使用Spectator测试Flickr搜索。
具有输入的组件
让我们从 FullPhotoComponent
开始,因为它是一个 展示组件,是组件树中的叶子。它期望一个照片对象作为输入,并渲染 图像(Photo)
以及照片元数据。没有输出、没有子项、没有服务依赖。
describe('FullPhotoComponent', () => {
let component: FullPhotoComponent;
let fixture: ComponentFixture<FullPhotoComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [FullPhotoComponent],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(FullPhotoComponent);
component = fixture.componentInstance;
component.photo = photo1;
fixture.detectChanges();
});
it('renders the photo information', () => {
expectText(fixture, 'full-photo-title', photo1.title);
const img = findEl(fixture, 'full-photo-image');
expect(img.properties.src).toBe(photo1.url_m);
expect(img.properties.alt).toBe(photo1.title);
expectText(fixture, 'full-photo-ownername', photo1.ownername);
expectText(fixture, 'full-photo-datetaken', photo1.datetaken);
expectText(fixture, 'full-photo-tags', photo1.tags);
const link = findEl(fixture, 'full-photo-link');
expect(link.properties.href).toBe(photo1Link);
expect(link.nativeElement.textContent.trim()).toBe(photo1Link);
});
});
这个套件已经使用了expectText
和findEl
,但是仍然使用了不太可靠的DebugElement
抽象。
组件工厂 |
在使用Spectator时,模块配置和组件创建的方式不同。在测试套件的范围内,我们创建一个组件工厂:
import { createComponentFactory } from '@ngneat/spectator';
describe('FullPhotoComponent with spectator', () => {
/* … */
const createComponent = createComponentFactory({
component: FullPhotoComponent,
shallow: true,
});
/* … */
});
createComponentFactory
期望一个配置对象。component: FullPhotoComponent
指定要测试的组件。shallow: true表示我们想要 浅层渲染,而不是深层渲染。但是对于FullPhotoComponent
来说没有区别,因为它没有子元素。
配置对象可以包括更多用于测试模块的选项,稍后我们将看到。
在内部,createComponentFactory
创建了一个beforeEach
块,该块调用TestBed.configureTestingModule
和TestBed.compileComponents
,就像我们手动做的那样。
createComponentFactory
返回一个用于创建FullPhotoComponent
的工厂函数。我们将该函数保存在createComponent
常量中。
创建组件 |
下一步是添加一个beforeEach
块,该块创建组件实例。createComponent
再次接受一个选项对象。为了设置photo
输入属性,我们传递 props: { photo: photo1 }
。
import { createComponentFactory, Spectator } from '@ngneat/spectator';
describe('FullPhotoComponent with spectator', () => {
let spectator: Spectator<FullPhotoComponent>;
const createComponent = createComponentFactory({
component: FullPhotoComponent,
shallow: true,
});
beforeEach(() => {
spectator = createComponent({ props: { photo: photo1 } });
});
/* … */
});
Spectator |
createComponent
返回一个Spectator
对象。这是我们将在规范中使用的强大接口。
规范 it('renders the photo information', /* … */)
多次重复了三个关键任务:
-
通过测试ID查找元素
-
检查其文本内容
-
检查其属性值
首先,规范找到具有测试ID full-photo-title
的元素,并期望它包含照片的标题。
使用Spectator,它看起来像这样:
expect(
spectator.query(byTestId('full-photo-title'))
).toHaveText(photo1.title);
spectator.query
|
中心的spectator.query
方法在DOM中查找元素。本指南建议 通过测试ID(data-testid
属性)查找元素。
Spectator支持测试ID,所以我们可以写成:
spectator.query(byTestId('full-photo-title'))
spectator.query
返回一个本地DOM元素,如果没有找到匹配项,则返回null
。请注意,它不返回DebugElement
。
使用Spectator时,您直接使用DOM元素对象进行操作。乍一看似乎很麻烦,但实际上却减轻了泄漏的DebugElement
抽象的负担。
Jasmine匹配器 |
Spectator使得使用普通DOM元素非常容易。Jasmine添加了几个匹配器,可以对元素进行期望。
为了检查元素的文本内容,Spectator提供了toHaveText
匹配器。这将导致我们有以下期望:
expect(
spectator.query(byTestId('full-photo-title'))
).toHaveText(photo1.title);
此代码等同于我们的expectText
助手,但更符合惯用语并且更易于阅读。
接下来,我们需要验证组件使用img
元素呈现完整的照片。
const img = spectator.query(byTestId('full-photo-image'));
expect(img).toHaveAttribute('src', photo1.url_m);
expect(img).toHaveAttribute('alt', photo1.title);
在这里,我们找到了具有测试ID full-photo-image
的元素,以检查其src
和alt
属性。我们使用Spectator的匹配器toHaveAttribute
来实现此目的。
规格说明的其余部分找到更多元素以检查其内容和属性。
使用Spectator的完整测试套件(仅显示来自Spectator的导入):
import {
byTestId, createComponentFactory, Spectator
} from '@ngneat/spectator';
describe('FullPhotoComponent with spectator', () => {
let spectator: Spectator<FullPhotoComponent>;
const createComponent = createComponentFactory({
component: FullPhotoComponent,
shallow: true,
});
beforeEach(() => {
spectator = createComponent({ props: { photo: photo1 } });
});
it('renders the photo information', () => {
expect(
spectator.query(byTestId('full-photo-title'))
).toHaveText(photo1.title);
const img = spectator.query(byTestId('full-photo-image'));
expect(img).toHaveAttribute('src', photo1.url_m);
expect(img).toHaveAttribute('alt', photo1.title);
expect(
spectator.query(byTestId('full-photo-ownername'))
).toHaveText(photo1.ownername);
expect(
spectator.query(byTestId('full-photo-datetaken'))
).toHaveText(photo1.datetaken);
expect(
spectator.query(byTestId('full-photo-tags'))
).toHaveText(photo1.tags);
const link = spectator.query(byTestId('full-photo-link'));
expect(link).toHaveAttribute('href', photo1Link);
expect(link).toHaveText(photo1Link);
});
});
与具有自定义测试助手版本相比,Spectator版本不一定更短。但它在 一致的抽象级别上 工作。
不再有TestBed
,ComponentFixture
,DebugElement
和助手函数的混合,而是有createComponentFactory
函数和一个Spectator
实例。
Spectator避免包装DOM元素,但为常见的DOM期望提供了方便的Jasmine匹配器。
具有子项和服务依赖项的组件
在测试 容器组件 时,Spectator真正发挥作用。这些是具有子项和服务依赖项的组件。
在Flickr搜索中,最上层的 FlickrSearchComponent
调用FlickrService
并保存状态。它协调其他三个组件,传递状态并监听输出。
FlickrSearchComponent
模板:
<app-search-form (search)="handleSearch($event)"></app-search-form>
<div class="photo-list-and-full-photo">
<app-photo-list
[title]="searchTerm"
[photos]="photos"
(focusPhoto)="handleFocusPhoto($event)"
class="photo-list"
></app-photo-list>
<app-full-photo
*ngIf="currentPhoto"
[photo]="currentPhoto"
class="full-photo"
data-testid="full-photo"
></app-full-photo>
</div>
FlickrSearchComponent
类:
@Component({
selector: 'app-flickr-search',
templateUrl: './flickr-search.component.html',
styleUrls: ['./flickr-search.component.css'],
})
export class FlickrSearchComponent {
public searchTerm = '';
public photos: Photo[] = [];
public currentPhoto: Photo | null = null;
constructor(private flickrService: FlickrService) {}
public handleSearch(searchTerm: string): void {
this.flickrService.searchPublicPhotos(searchTerm).subscribe(
(photos) => {
this.searchTerm = searchTerm;
this.photos = photos;
this.currentPhoto = null;
}
);
}
public handleFocusPhoto(photo: Photo): void {
this.currentPhoto = photo;
}
}
子组件 |
由于这是汇聚所有事物的组件,因此需要进行大量测试。
-
最初,渲染的是
SearchFormComponent
和PhotoListComponent
,而不是FullPhotoComponent
。照片列表为空。 -
当
SearchFormComponent
发出search
输出时,使用搜索词调用FlickrService
。 -
搜索词和照片列表通过输入传递到
PhotoListComponent
。 -
当
PhotoListComponent
发出focusPhoto
输出时,渲染FullPhotoComponent
。所选照片通过输入传递下来。
没有Spectator |
使用我们的助手程序的 FlickrSearchComponent
测试套件如下:
describe('FlickrSearchComponent', () => {
let fixture: ComponentFixture<FlickrSearchComponent>;
let component: FlickrSearchComponent;
let fakeFlickrService: Pick<FlickrService, keyof FlickrService>;
let searchForm: DebugElement;
let photoList: DebugElement;
beforeEach(async () => {
fakeFlickrService = {
searchPublicPhotos: jasmine
.createSpy('searchPublicPhotos')
.and.returnValue(of(photos)),
};
await TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
declarations: [FlickrSearchComponent],
providers: [
{ provide: FlickrService, useValue: fakeFlickrService }
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(FlickrSearchComponent);
component = fixture.debugElement.componentInstance;
fixture.detectChanges();
searchForm = findComponent(fixture, 'app-search-form');
photoList = findComponent(fixture, 'app-photo-list');
});
it('renders the search form and the photo list, not the full photo', () => {
expect(searchForm).toBeTruthy();
expect(photoList).toBeTruthy();
expect(photoList.properties.title).toBe('');
expect(photoList.properties.photos).toEqual([]);
expect(() => {
findComponent(fixture, 'app-full-photo');
}).toThrow();
});
it('searches and passes the resulting photos to the photo list', () => {
const searchTerm = 'beautiful flowers';
searchForm.triggerEventHandler('search', searchTerm);
fixture.detectChanges();
expect(fakeFlickrService.searchPublicPhotos).toHaveBeenCalledWith(searchTerm);
expect(photoList.properties.title).toBe(searchTerm);
expect(photoList.properties.photos).toBe(photos);
});
it('renders the full photo when a photo is focussed', () => {
expect(() => {
findComponent(fixture, 'app-full-photo');
}).toThrow();
photoList.triggerEventHandler('focusPhoto', photo1);
fixture.detectChanges();
const fullPhoto = findComponent(fixture, 'app-full-photo');
expect(fullPhoto.properties.photo).toBe(photo1);
});
});
不过多详细说明,以下是一些注释:
-
我们使用 浅渲染 。子组件未声明,只渲染空的外壳元素(
app-search-form
、app-photo-list
和app-full-photo
)。这使我们可以检查它们的存在、它们的输入和输出。 -
我们使用我们的
findComponent
测试助手来查找子元素。 -
要检查输入值,我们使用
DebugElement
的properties
。 -
要模拟输出发出,我们在
DebugElement
上使用triggerEventListener
。 -
我们提供自己的虚假
FlickrService
。它包含一个Jasmine间谍,返回一个带有固定照片列表的Observable。
fakeFlickrService = {
searchPublicPhotos: jasmine
.createSpy('searchPublicPhotos')
.and.returnValue(of(photos)),
};
使用 Spectator |
使用Spectator重写这个套件带来了两个主要变化:
-
我们使用 ng-mocks 创建虚假的子组件来替换子组件。虚假的组件在其输入和输出方面模仿原始组件,但它们不会渲染任何内容。我们将使用这些组件实例来代替在
DebugElement
上操作。 -
我们使用Spectator创建虚假的
FlickrService
。
测试套件设置:
import {
createComponentFactory, mockProvider, Spectator
} from '@ngneat/spectator';
describe('FlickrSearchComponent with spectator', () => {
/* … */
const createComponent = createComponentFactory({
component: FlickrSearchComponent,
shallow: true,
declarations: [
MockComponents(
SearchFormComponent, PhotoListComponent, FullPhotoComponent
),
],
providers: [mockProvider(FlickrService)],
});
/* … */
});
再次,我们使用Spectator的createComponentFactory
。这次,我们使用ng-mocks的MockComponents
函数用虚拟子组件替换子组件。
mockProvider
|
然后,我们使用Spectator的mockProvider
函数创建一个假的FlickrService
。在底层,这与我们手动创建的fakeFlickrService
大致相同。它创建一个类似于原始对象的对象,但方法被替换为Jasmine间谍。
在beforeEach
块中创建组件。
import {
createComponentFactory, mockProvider, Spectator
} from '@ngneat/spectator';
describe('FlickrSearchComponent with spectator', () => {
let spectator: Spectator<FlickrSearchComponent>;
let searchForm: SearchFormComponent | null;
let photoList: PhotoListComponent | null;
let fullPhoto: FullPhotoComponent | null;
const createComponent = createComponentFactory(/* … */);
beforeEach(() => {
spectator = createComponent();
spectator.inject(FlickrService).searchPublicPhotos.and.returnValue(of(photos));
searchForm = spectator.query(SearchFormComponent);
photoList = spectator.query(PhotoListComponent);
fullPhoto = spectator.query(FullPhotoComponent);
});
/* … */
});
spectator.inject
相当于TestBed.inject
。我们获取FlickrService
的假实例并配置searchPublicPhotos
间谍以返回固定数据。
查找子元素 |
spectator.query
不仅可以在DOM中查找元素,还可以查找子组件和其他嵌套指令。我们找到了三个子组件并将它们保存在变量中,因为它们将在所有规范中使用。
请注意,searchForm
、photoList
和fullPhoto
被定义为Component实例,而不是DebugElement。这是准确的,因为假组件具有相同的公共接口、相同的输入和输出。
由于 伪造物和原始组件的等价性,我们可以使用模式componentInstance.input
访问输入。我们使用模式 componentInstance.output.emit(…)
让一个输出发出信号。
第一个规范检查初始状态:
it('renders the search form and the photo list, not the full photo', () => {
if (!(searchForm && photoList)) {
throw new Error('searchForm or photoList not found');
}
expect(photoList.title).toBe('');
expect(photoList.photos).toEqual([]);
expect(fullPhoto).not.toExist();
});
spectator.query(PhotoListComponent)
要么返回组件实例,要么返回null
,如果不存在这样的嵌套组件。因此,photoList
变量被定义为 PhotoListComponent | null
类型。
手动类型保护 |
不幸的是,expect
不是 TypeScript类型保护。Jasmine的expectations无法将类型从 PhotoListComponent | null
缩小到 PhotoListComponent
。
我们不能调用 expect(photoList).not.toBe(null)
并继续使用 expect(photoList.title).toBe('')
。第一个expectation在null
情况下抛出错误,但TypeScript不知道这一点。TypeScript仍然假定类型为 PhotoListComponent | null
,所以它会抱怨 photoList.title
。
这就是为什么当photoList
为null
时我们手动抛出一个错误。TypeScript推断在规范的其余部分中类型必须为PhotoListComponent
。
相比之下,我们的findComponent
帮助函数在没有匹配项时直接抛出异常,提前失败测试。为了验证是否缺少子组件,我们必须期望该异常:
expect(() => {
findComponent(fixture, 'app-full-photo');
}).toThrow();`.
Spectator规范继续使用 expect(fullPhoto).not.toExist()
,它相当于 expect(fullPhoto).toBe(null)
。Jasmine匹配器toExist
来自Spectator。
测试搜索 |
第二个规范涵盖了搜索:
it('searches and passes the resulting photos to the photo list', () => {
if (!(searchForm && photoList)) {
throw new Error('searchForm or photoList not found');
}
const searchTerm = 'beautiful flowers';
searchForm.search.emit(searchTerm);
spectator.detectChanges();
const flickrService = spectator.inject(FlickrService);
expect(flickrService.searchPublicPhotos).toHaveBeenCalledWith(searchTerm);
expect(photoList.title).toBe(searchTerm);
expect(photoList.photos).toBe(photos);
});
当SearchFormComponent
发布搜索词时,我们期望FlickrService
已被调用。此外,我们期望将搜索词和来自Service的照片列表传递给PhotoListComponent
。
spectator.detectChanges()
只是Spectator对fixture.detectChanges()
的快捷方式。
测试聚焦照片 |
最后一个规范聚焦一张照片:
it('renders the full photo when a photo is focussed', () => {
expect(fullPhoto).not.toExist();
if (!photoList) {
throw new Error('photoList not found');
}
photoList.focusPhoto.emit(photo1);
spectator.detectChanges();
fullPhoto = spectator.query(FullPhotoComponent);
if (!fullPhoto) {
throw new Error('fullPhoto not found');
}
expect(fullPhoto.photo).toBe(photo1);
});
再次强调,主要区别在于我们直接使用输入和输出。
使用Spectator处理事件
大多数组件处理输入事件,如鼠标点击,按键或表单字段更改。为了模拟它们,我们在DebugElement
上使用triggerEventHandler
方法。此方法实际上不会模拟DOM事件,它只是调用由 (click)="handler($event)"
等注册的事件处理程序。
triggerEventHandler要求您创建一个事件对象,该对象在模板中成为 $event
。出于这个原因,我们引入了click
和makeClickEvent
助手。
合成事件 |
Spectator采用了不同的方法:它分派合成的DOM事件。这使得测试更加真实。合成事件可以像真实事件一样在DOM树中冒泡。Spectator为您创建事件对象,同时您可以配置详细信息。
spectator.click
|
要执行简单的单击操作,我们使用spectator.click
并传递目标元素或byTestId
选择器。来自 PhotoItemComponent 测试 的示例:
describe('PhotoItemComponent with spectator', () => {
/* … */
it('focusses a photo on click', () => {
let photo: Photo | undefined;
spectator.component.focusPhoto.subscribe((otherPhoto: Photo) => {
photo = otherPhoto;
});
spectator.click(byTestId('photo-item-link'));
expect(photo).toBe(photo1);
});
/* … */
});
另一个常见的任务是模拟表单字段输入。到目前为止,我们已经使用 setFieldValue助手 来完成此任务。
spectator.typeInElement
|
Spectator有一个等效的方法称为spectator.typeInElement
。它在 SearchFormComponent测试中使用:
describe('SearchFormComponent with spectator', () => {
/* … */
it('starts a search', () => {
let actualSearchTerm: string | undefined;
spectator.component.search.subscribe((otherSearchTerm: string) => {
actualSearchTerm = otherSearchTerm;
});
spectator.typeInElement(searchTerm, byTestId('search-term-input'));
spectator.dispatchFakeEvent(byTestId('form'), 'submit');
expect(actualSearchTerm).toBe(searchTerm);
});
});
触发 ngSubmit 事件
|
该规范模拟在搜索字段中键入搜索词。然后它在 表单(form)
元素上模拟ngSubmit
事件。我们使用通用方法spectator.dispatchFakeEvent
来完成此目的。
Spectator提供了更多方便的快捷方式来触发事件。Flickr搜索Spectator测试只使用了最常见的方法。
Spectator:总结
Spectator是一个成熟的库,解决了Angular开发人员的实际需求。它为常见的Angular测试问题提供解决方案。上面的示例仅介绍了Spectator的一些功能。
测试代码应简洁易懂。Spectator为编写Angular测试提供了一种表达力强、高层次的语言。Spectator使简单的任务变得简单,而不失去任何功能。
Spectator的成功凸显了标准的Angular测试工具很麻烦而且不一致。替代概念既是必要的,也是有益的。
一旦您熟悉了标准工具,应该尝试使用Spectator和ng-mocks等替代方案。然后决定是坚持使用隔离的测试助手还是转向更全面的测试库。