阅读(2953) (0)

Angular 测试组件的基础知识

2022-07-01 14:04:59 更新

测试组件的基础知识

组件与 Angular 应用的所有其它部分不同,它结合了 HTML 模板和 TypeScript 类。事实上,组件就是由模板和类一起工作的。要想对组件进行充分的测试,你应该测试它们是否如预期般协同工作。

这些测试需要在浏览器 DOM 中创建该组件的宿主元素,就像 Angular 所做的那样,然后检查组件类与 DOM 的交互是否如模板中描述的那样工作。

Angular 的 ​TestBed ​可以帮你做这种测试,正如你将在下面的章节中看到的那样。但是,在很多情况下,单独测试组件类(不需要 DOM 的参与),就能以更简单,更明显的方式验证组件的大部分行为。

如果你要试验本指南中所讲的应用,请在浏览器中运行它下载并在本地运行它

组件类测试

你可以像测试服务类那样来测试一个组件类本身。

组件类的测试应该保持非常干净和简单。它应该只测试一个单元。一眼看上去,你就应该能够理解正在测试的对象。

考虑这个 ​LightswitchComponent​,当用户单击该按钮时,它会打开和关闭一个指示灯(用屏幕上的一条消息表示)。

@Component({
  selector: 'lightswitch-comp',
  template: `
    <button type="button" (click)="clicked()">Click me!</button>
    <span>{{message}}</span>`
})
export class LightswitchComponent {
  isOn = false;
  clicked() { this.isOn = !this.isOn; }
  get message() { return `The light is ${this.isOn ? 'On' : 'Off'}`; }
}

你可能要测试 ​clicked()​ 方法是否切换了灯的开/关状态并正确设置了这个消息。

这个组件类没有依赖。要测试这种类型的组件类,请遵循与没有依赖的服务相同的步骤:

  1. 使用 new 关键字创建一个组件。
  2. 调用它的 API。
  3. 对其公开状态的期望值进行断言。

describe('LightswitchComp', () => {
  it('#clicked() should toggle #isOn', () => {
    const comp = new LightswitchComponent();
    expect(comp.isOn)
      .withContext('off at first')
      .toBe(false);
    comp.clicked();
    expect(comp.isOn)
      .withContext('on after click')
      .toBe(true);
    comp.clicked();
    expect(comp.isOn)
      .withContext('off after second click')
      .toBe(false);
  });

  it('#clicked() should set #message to "is on"', () => {
    const comp = new LightswitchComponent();
    expect(comp.message)
      .withContext('off at first')
      .toMatch(/is off/i);
    comp.clicked();
    expect(comp.message)
      .withContext('on after clicked')
      .toMatch(/is on/i);
  });
});

下面是“英雄之旅”教程中的 ​DashboardHeroComponent​。

export class DashboardHeroComponent {
  @Input() hero!: Hero;
  @Output() selected = new EventEmitter<Hero>();
  click() { this.selected.emit(this.hero); }
}

它出现在父组件的模板中,把一个英雄绑定到了 ​@Input​ 属性,并监听通过所选@Output​ 属性引发的一个事件。

你可以测试类代码的工作方式,而无需创建 ​DashboardHeroComponent ​或它的父组件。

it('raises the selected event when clicked', () => {
  const comp = new DashboardHeroComponent();
  const hero: Hero = {id: 42, name: 'Test'};
  comp.hero = hero;

  comp.selected.pipe(first()).subscribe((selectedHero: Hero) => expect(selectedHero).toBe(hero));
  comp.click();
});

当组件有依赖时,你可能要使用 ​TestBed ​来同时创建该组件及其依赖。

下列的 ​WelcomeComponent ​依赖于 ​UserService ​来了解要问候的用户的名字。

export class WelcomeComponent implements OnInit {
  welcome = '';
  constructor(private userService: UserService) { }

  ngOnInit(): void {
    this.welcome = this.userService.isLoggedIn ?
      'Welcome, ' + this.userService.user.name : 'Please log in.';
  }
}

你可以先创建一个能满足本组件最低需求的 ​UserService​。

class MockUserService {
  isLoggedIn = true;
  user = { name: 'Test User'};
}

然后在 ​TestBed ​配置中提供并注入所有这些组件和服务。

beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.inject(WelcomeComponent);
  userService = TestBed.inject(UserService);
});

然后,测验组件类,别忘了要像 Angular 运行应用时一样调用生命周期钩子方法

it('should not have welcome message after construction', () => {
  expect(comp.welcome).toBe('');
});

it('should welcome logged in user after Angular calls ngOnInit', () => {
  comp.ngOnInit();
  expect(comp.welcome).toContain(userService.user.name);
});

it('should ask user to log in if not logged in after ngOnInit', () => {
  userService.isLoggedIn = false;
  comp.ngOnInit();
  expect(comp.welcome).not.toContain(userService.user.name);
  expect(comp.welcome).toContain('log in');
});

组件 DOM 测试

测试组件类和测试服务一样简单。

但组件不仅仅是它的类。组件还会与 DOM 以及其他组件进行交互。只对类的测试可以告诉你类的行为。但它们无法告诉你这个组件是否能正确渲染、响应用户输入和手势,或是集成到它的父组件和子组件中。

以上所有只对类的测试都不能回答有关组件会如何在屏幕上实际运行方面的关键问题。

  • Lightswitch.clicked()​ 绑定到了什么?用户可以调用它吗?
  • Lightswitch.message​ 是否显示过?
  • 用户能否真正选中由 ​DashboardHeroComponent ​显示的英雄?
  • 英雄名字是否按预期显示的(比如大写字母)?
  • WelcomeComponent ​的模板是否显示了欢迎信息?

对于上面描述的那些简单组件来说,这些问题可能并不麻烦。但是很多组件都与模板中描述的 DOM 元素进行了复杂的交互,导致一些 HTML 会在组件状态发生变化时出现和消失。

要回答这些问题,你必须创建与组件关联的 DOM 元素,你必须检查 DOM 以确认组件状态是否在适当的时候正确显示了,并且你必须模拟用户与屏幕的交互以确定这些交互是否正确。判断该组件的行为是否符合预期。

为了编写这些类型的测试,你将使用 ​TestBed ​的其它特性以及其他的测试辅助函数。

CLI 生成的测试

当你要求 CLI 生成一个新组件时,它会默认为你创建一个初始的测试文件。

比如,下列 CLI 命令会在 ​app/banner​ 文件夹中生成带有内联模板和内联样式的 ​BannerComponent​:

ng generate component banner --inline-template --inline-style --module app

它还会生成一个初始测试文件 ​banner-external.component.spec.ts​,如下所示:

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';

import { BannerComponent } from './banner.component';

describe('BannerComponent', () => {
  let component: BannerComponent;
  let fixture: ComponentFixture<BannerComponent>;

  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({declarations: [BannerComponent]}).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(BannerComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeDefined();
  });
});

由于 ​compileComponents ​是异步的,所以它使用从 ​@angular/core/testing​ 中导入的实用工具函数 ​waitForAsync​。

减少设置代码

只有这个文件的最后三行才是真正测试组件的,并且所有这些都断言了 Angular 可以创建该组件。

该文件的其它部分是做设置用的样板代码,可以预见,如果组件演变得更具实质性内容,就会需要更高级的测试。

下面你将学习这些高级测试特性。现在,你可以从根本上把这个测试文件减少到一个更容易管理的大小:

describe('BannerComponent (minimal)', () => {
  it('should create', () => {
    TestBed.configureTestingModule({declarations: [BannerComponent]});
    const fixture = TestBed.createComponent(BannerComponent);
    const component = fixture.componentInstance;
    expect(component).toBeDefined();
  });
});

在这个例子中,传给 ​TestBed.configureTestingModule​ 的元数据对象只是声明了要测试的组件 ​BannerComponent​。

TestBed.configureTestingModule({declarations: [BannerComponent]});
没有必要声明或导入任何其他东西。默认的测试模块预先配置了像来自 ​@angular/platform-browser​ 的 ​BrowserModule ​这样的东西。
稍后你会用 ​imports​、​providers ​和更多可声明对象的参数来调用 ​TestBed.configureTestingModule()​,以满足你的测试需求。可选方法 ​override ​可以进一步微调此配置的各个方面。

createComponent()

在配置好 ​TestBed ​之后,你就可以调用它的 ​createComponent()​ 方法了。

const fixture = TestBed.createComponent(BannerComponent);

TestBed.createComponent()​ 会创建 ​BannerComponent ​的实例,它把一个对应元素添加到了测试运行器的 DOM 中,并返回一个​ComponentFixture ​对象。

调用 ​createComponent ​后不能再重新配置 ​TestBed​。
createComponent ​方法会冻结当前的 ​TestBed ​定义,并把它关闭以防止进一步的配置。
你不能再调用任何 ​TestBed ​配置方法,无论是 ​configureTestingModule()​、​get()​ 还是 ​override... ​方法都不行。如果你这样做,​TestBed ​会抛出一个错误。

ComponentFixture

ComponentFixture ​是一个测试挽具,用于与所创建的组件及其对应的元素进行交互。

可以通过测试夹具(fixture)访问组件实例,并用 Jasmine 的期望断言来确认它是否存在:

const component = fixture.componentInstance;
expect(component).toBeDefined();

beforeEach()

随着这个组件的发展,你会添加更多的测试。你不必为每个测试复制 ​TestBed ​的配置代码,而是把它重构到 Jasmine 的 ​beforeEach()​ 和一些支持变量中:

describe('BannerComponent (with beforeEach)', () => {
  let component: BannerComponent;
  let fixture: ComponentFixture<BannerComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({declarations: [BannerComponent]});
    fixture = TestBed.createComponent(BannerComponent);
    component = fixture.componentInstance;
  });

  it('should create', () => {
    expect(component).toBeDefined();
  });
});

现在添加一个测试程序,它从 ​fixture.nativeElement​ 中获取组件的元素,并查找预期的文本。

it('should contain "banner works!"', () => {
  const bannerElement: HTMLElement = fixture.nativeElement;
  expect(bannerElement.textContent).toContain('banner works!');
});

nativeElement

ComponentFixture.nativeElement​ 的值是 ​any ​类型的。稍后你会遇到 ​DebugElement.nativeElement​,它也是 ​any ​类型的。

Angular 在编译时不知道 ​nativeElement ​是什么样的 HTML 元素,甚至可能不是 HTML 元素。该应用可能运行在非浏览器平台(如服务器或 Web Worker)上,在那里本元素可能具有一个缩小版的 API,甚至根本不存在。

本指南中的测试都是为了在浏览器中运行而设计的,因此 ​nativeElement ​的值始终是 ​HTMLElement ​或其派生类之一。

知道了它是某种 ​HTMLElement​,就可以用标准的 HTML ​querySelector ​深入了解元素树。

这是另一个调用 ​HTMLElement.querySelector​ 来获取段落元素并查找横幅文本的测试:

it('should have <p> with "banner works!"', () => {
  const bannerElement: HTMLElement = fixture.nativeElement;
  const p = bannerElement.querySelector('p')!;
  expect(p.textContent).toEqual('banner works!');
});

DebugElement

Angular 的测试夹具可以直接通过 ​fixture.nativeElement​ 提供组件的元素。

const bannerElement: HTMLElement = fixture.nativeElement;

它实际上是一个便利方法,其最终实现为 ​fixture.debugElement.nativeElement​。

const bannerDe: DebugElement = fixture.debugElement;
const bannerEl: HTMLElement = bannerDe.nativeElement;

使用这种迂回的路径访问元素是有充分理由的。

nativeElement ​的属性依赖于其运行时环境。你可以在非浏览器平台上运行这些测试,那些平台上可能没有 DOM,或者其模拟的 DOM 不支持完整的 ​HTMLElement ​API。

Angular 依靠 ​DebugElement ​抽象来在其支持的所有平台上安全地工作。Angular 不会创建 HTML 元素树,而会创建一个 ​DebugElement ​树来封装运行时平台上的原生元素。​nativeElement ​属性会解包 ​DebugElement ​并返回特定于平台的元素对象。

由于本指南的范例测试只能在浏览器中运行,因此 ​nativeElement ​在这些测试中始终是 ​HTMLElement​,你可以在测试中探索熟悉的方法和属性。

下面是把前述测试用 ​fixture.debugElement.nativeElement​ 重新实现的版本:

it('should find the <p> with fixture.debugElement.nativeElement)', () => {
  const bannerDe: DebugElement = fixture.debugElement;
  const bannerEl: HTMLElement = bannerDe.nativeElement;
  const p = bannerEl.querySelector('p')!;
  expect(p.textContent).toEqual('banner works!');
});

这些 ​DebugElement ​还有另一些在测试中很有用的方法和属性,你可以在本指南的其他地方看到。

你可以从 Angular 的 core 库中导入 ​DebugElement ​符号。

import { DebugElement } from '@angular/core';

By.css()

虽然本指南中的测试都是在浏览器中运行的,但有些应用可能至少要在某些时候运行在不同的平台上。

比如,作为优化策略的一部分,该组件可能会首先在服务器上渲染,以便在连接不良的设备上更快地启动本应用。服务器端渲染器可能不支持完整的 HTML 元素 API。如果它不支持 ​querySelector​,之前的测试就会失败。

DebugElement ​提供了适用于其支持的所有平台的查询方法。这些查询方法接受一个谓词函数,当 ​DebugElement ​树中的一个节点与选择条件匹配时,该函数返回 ​true​。

你可以借助从库中为运行时平台导入 ​By​ 类来创建一个谓词。这里的 ​By​ 是从浏览器平台导入的。

import { By } from '@angular/platform-browser';

下面的例子用 ​DebugElement.query()​ 和浏览器的 ​By.css​ 方法重新实现了前面的测试。

it('should find the <p> with fixture.debugElement.query(By.css)', () => {
  const bannerDe: DebugElement = fixture.debugElement;
  const paragraphDe = bannerDe.query(By.css('p'));
  const p: HTMLElement = paragraphDe.nativeElement;
  expect(p.textContent).toEqual('banner works!');
});

一些值得注意的地方:

  • By.css()​ 静态方法使用标准 CSS 选择器选择 ​DebugElement ​节点。
  • 该查询为 p 元素返回了一个 ​DebugElement​。
  • 你必须解包那个结果才能得到 p 元素。

当你使用 CSS 选择器进行过滤并且只测试浏览器原生元素的属性时,用 ​By.css​ 方法可能会有点过度。

用 ​HTMLElement ​方法(比如 ​querySelector()​ 或 ​querySelectorAll()​)进行过滤通常更简单,更清晰。