测试时使用Spectator库简化组件

学习目标:

  • 使用Spectator库简化组件测试

  • 使用统一的Spectator接口

  • 与组件和渲染的DOM进行交互

  • 发送合成DOM事件以模拟用户输入

  • 使用Spectator和ng-mocks来伪造子组件和服务

我们使用了Angular的测试工具来设置模块、渲染组件、查询DOM等等。这些工具包括TestBedComponentFixtureDebugElement,也包括HttpClientTestingModuleRouterTestingModule

结构性弱点

内置工具比较低级和没有明确的意见。它们有几个缺点:

  • TestBed需要大量的样板代码来设置一个常见的组件或服务测试。

  • DebugElement缺少必要的功能,是一个“泄漏”的抽象。你被迫使用包装的本地DOM元素来处理常见任务。

  • 没有默认的解决方案来安全地伪造组件和服务依赖。

  • 测试本身变得冗长和重复。你必须建立测试约定并自己编写帮助程序。

我们已经使用了小型 元素测试辅助函数。它们解决了隔离问题,以便编写更一致和紧凑的规范。

如果你编写数百或数千个规范,你会发现这些帮助函数不够。它们没有解决上述结构性问题。

统一测试API

Spectator 是一个用于测试Angular应用程序的有意见的库。从技术上讲,它位于TestBedComponentFixtureDebugElement之上。但主要思想是在一个一致、强大和用户友好的接口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);
  });
});

这个套件已经使用了expectTextfindEl,但是仍然使用了不太可靠的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.configureTestingModuleTestBed.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', /* … */) 多次重复了三个关键任务:

  1. 通过测试ID查找元素

  2. 检查其文本内容

  3. 检查其属性值

首先,规范找到具有测试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的元素,以检查其srcalt属性。我们使用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版本不一定更短。但它在 一致的抽象级别上 工作。

不再有TestBedComponentFixtureDebugElement和助手函数的混合,而是有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;
  }
}
子组件

由于这是汇聚所有事物的组件,因此需要进行大量测试。

  1. 最初,渲染的是SearchFormComponentPhotoListComponent,而不是FullPhotoComponent。照片列表为空。

  2. SearchFormComponent发出 search 输出时,使用搜索词调用FlickrService

  3. 搜索词和照片列表通过输入传递到PhotoListComponent

  4. 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-formapp-photo-listapp-full-photo)。这使我们可以检查它们的存在、它们的输入和输出。

  • 我们使用我们的findComponent测试助手来查找子元素。

  • 要检查输入值,我们使用DebugElementproperties

  • 要模拟输出发出,我们在DebugElement上使用triggerEventListener

  • 我们提供自己的虚假FlickrService。它包含一个Jasmine间谍,返回一个带有固定照片列表的Observable。

  fakeFlickrService = {
    searchPublicPhotos: jasmine
      .createSpy('searchPublicPhotos')
      .and.returnValue(of(photos)),
  };
使用 Spectator

使用Spectator重写这个套件带来了两个主要变化:

  1. 我们使用 ng-mocks 创建虚假的子组件来替换子组件。虚假的组件在其输入和输出方面模仿原始组件,但它们不会渲染任何内容。我们将使用这些组件实例来代替在DebugElement上操作。

  2. 我们使用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中查找元素,还可以查找子组件和其他嵌套指令。我们找到了三个子组件并将它们保存在变量中,因为它们将在所有规范中使用。

请注意,searchFormphotoListfullPhoto被定义为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

这就是为什么当photoListnull时我们手动抛出一个错误。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 。出于这个原因,我们引入了clickmakeClickEvent助手。

合成事件

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等替代方案。然后决定是坚持使用隔离的测试助手还是转向更全面的测试库。