Angular 组件测试场景

2022-07-08 09:07 更新

组件测试场景

本指南探讨了一些常见的组件测试用例。

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

组件绑定

在范例应用中,​BannerComponent ​在 HTML 模板中展示了静态的标题文本。

在少许更改之后,​BannerComponent ​就会通过绑定组件的 ​title ​属性来渲染动态标题。

@Component({
  selector: 'app-banner',
  template: '<h1>{{title}}</h1>',
  styles: ['h1 { color: green; font-size: 350%}']
})
export class BannerComponent {
  title = 'Test Tour of Heroes';
}

尽管这很小,但你还是决定要添加一个测试来确认该组件实际显示的是你认为合适的内容。

查询 <h1> 元素

你将编写一系列测试来检查 ​<h1>​ 元素中包裹的 title 属性插值绑定。

你可以修改 ​beforeEach ​以找到带有标准 HTML ​querySelector ​的元素,并把它赋值给 ​h1 ​变量。

let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;

beforeEach(() => {
  TestBed.configureTestingModule({
    declarations: [ BannerComponent ],
  });
  fixture = TestBed.createComponent(BannerComponent);
  component = fixture.componentInstance; // BannerComponent test instance
  h1 = fixture.nativeElement.querySelector('h1');
});

createComponent() 不绑定数据

对于你的第一个测试,你希望屏幕上显示默认的 ​title​。你的直觉就是编写一个能立即检查 ​<h1>​ 的测试,就像这样:

it('should display original title', () => {
  expect(h1.textContent).toContain(component.title);
});

那个测试失败了

expected '' to contain 'Test Tour of Heroes'.

当 Angular 执行变更检测时就会发生绑定。

在生产环境中,当 Angular 创建一个组件,或者用户输入按键,或者异步活动(比如 AJAX)完成时,就会自动进行变更检测。

该 ​TestBed.createComponent​ 不会触发变化检测,修改后的测试可以证实这一点:

it('no title in the DOM after createComponent()', () => {
  expect(h1.textContent).toEqual('');
});

detectChanges()

你必须通过调用 ​fixture.detectChanges()​ 来告诉 ​TestBed ​执行数据绑定。只有这样,​<h1>​ 才能拥有预期的标题。

it('should display original title after detectChanges()', () => {
  fixture.detectChanges();
  expect(h1.textContent).toContain(component.title);
});

这里延迟变更检测时机是故意而且有用的。这样才能让测试者在 Angular 启动数据绑定并调用生命周期钩子之前,查看并更改组件的状态。

这是另一个测试,它会在调用 ​fixture.detectChanges()​ 之前改变组件的 ​title ​属性。

it('should display a different test title', () => {
  component.title = 'Test Title';
  fixture.detectChanges();
  expect(h1.textContent).toContain('Test Title');
});

自动变更检测

BannerComponent ​测试会经常调用 ​detectChanges​。一些测试人员更喜欢让 Angular 测试环境自动运行变更检测。

可以通过配置带有 ​ComponentFixtureAutoDetect ​提供者的 ​TestBed ​来实现这一点。我们首先从测试工具函数库中导入它:

import { ComponentFixtureAutoDetect } from '@angular/core/testing';

然后把它添加到测试模块配置的 ​providers ​中:

TestBed.configureTestingModule({
  declarations: [ BannerComponent ],
  providers: [
    { provide: ComponentFixtureAutoDetect, useValue: true }
  ]
});

这里有三个测试来说明自动变更检测是如何工作的。

it('should display original title', () => {
  // Hooray! No `fixture.detectChanges()` needed
  expect(h1.textContent).toContain(comp.title);
});

it('should still see original title after comp.title change', () => {
  const oldTitle = comp.title;
  comp.title = 'Test Title';
  // Displayed title is old because Angular didn't hear the change :(
  expect(h1.textContent).toContain(oldTitle);
});

it('should display updated title after detectChanges', () => {
  comp.title = 'Test Title';
  fixture.detectChanges(); // detect changes explicitly
  expect(h1.textContent).toContain(comp.title);
});

第一个测试显示了自动变更检测的优点。

第二个和第三个测试则揭示了一个重要的限制。该 Angular 测试环境不知道测试改变了组件的 ​title​。​ComponentFixtureAutoDetect ​服务会响应异步活动,比如 Promise、定时器和 DOM 事件。但却看不见对组件属性的直接同步更新。该测试必须用 ​fixture.detectChanges()​ 来触发另一个变更检测周期。

本指南中的范例总是会显式调用 ​detectChanges()​,而不用困惑于测试夹具何时会或不会执行变更检测。更频繁的调用 ​detectChanges()​ 毫无危害,没必要只在非常必要时才调用它。

使用 dispatchEvent() 改变输入框的值

要模拟用户输入,你可以找到 input 元素并设置它的 ​value ​属性。

你会调用 ​fixture.detectChanges()​ 来触发 Angular 的变更检测。但还有一个重要的中间步骤。

Angular 并不知道你为 input 设置过 ​value ​属性。在通过调用 ​dispatchEvent()​ 分发 ​input ​事件之前,它不会读取该属性。紧接着你就调用了 ​detectChanges()​。

下列例子说明了正确的顺序。

it('should convert hero name to Title Case', () => {
  // get the name's input and display elements from the DOM
  const hostElement: HTMLElement = fixture.nativeElement;
  const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
  const nameDisplay: HTMLElement = hostElement.querySelector('span')!;

  // simulate user entering a new name into the input box
  nameInput.value = 'quick BROWN  fOx';

  // Dispatch a DOM event so that Angular learns of input value change.
  nameInput.dispatchEvent(new Event('input'));

  // Tell Angular to update the display binding through the title pipe
  fixture.detectChanges();

  expect(nameDisplay.textContent).toBe('Quick Brown  Fox');
});

包含外部文件的组件

上面的 ​BannerComponent ​是用内联模板内联 css 定义的,它们分别是在 ​@Component.template​ 和 ​@Component.styles​ 属性中指定的。

很多组件都会分别用 ​@Component.templateUrl​ 和 ​@Component.styleUrls​ 属性来指定外部模板外部 css,就像下面的 ​BannerComponent ​变体一样。

@Component({
  selector: 'app-banner',
  templateUrl: './banner-external.component.html',
  styleUrls:  ['./banner-external.component.css']
})

这个语法告诉 Angular 编译器要在组件编译时读取外部文件。

当运行 ​ng test​ 命令时,这不是问题,因为它会在运行测试之前编译应用

但是,如果在非 CLI 环境中运行这些测试,那么这个组件的测试可能会失败。比如,如果你在一个 web 编程环境(比如 plunker 中运行 ​BannerComponent ​测试,你会看到如下消息:

Error: This test module uses the component BannerComponent
which is using a "templateUrl" or "styleUrls", but they were never compiled.
Please call "TestBed.compileComponents" before your test.

当运行环境在测试过程中需要编译源代码时,就会得到这条测试失败的消息。

要解决这个问题,可以像下面 调用 compileComponents 小节中讲的那样调用 ​compileComponents()​。

具有依赖的组件

组件通常都有服务依赖。

WelcomeComponent ​会向登录用户显示一条欢迎信息。它可以基于注入进来的 ​UserService ​的一个属性了解到用户是谁:

import { Component, OnInit } from '@angular/core';
import { UserService } from '../model/user.service';

@Component({
  selector: 'app-welcome',
  template: '<h3 class="welcome"><i>{{welcome}}</i></h3>'
})
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.';
  }
}

WelcomeComponent ​拥有与该服务交互的决策逻辑,该逻辑让这个组件值得测试。这是 spec 文件的测试模块配置:

TestBed.configureTestingModule({
   declarations: [ WelcomeComponent ],
// providers: [ UserService ],  // NO! Don't provide the real service!
                                // Provide a test-double instead
   providers: [ { provide: UserService, useValue: userServiceStub } ],
});

这次,除了声明被测组件外,该配置还在 ​providers ​列表中加入了 ​UserService ​提供者。但它不是真正的 ​UserService​。

为服务提供测试替身

待测组件不必注入真正的服务。事实上,如果它们是测试替身(stubs,fakes,spies 或 mocks),通常会更好。该测试规约的目的是测试组件,而不是服务,使用真正的服务可能会遇到麻烦。

注入真正的 ​UserService ​可能是个噩梦。真正的服务可能要求用户提供登录凭据,并尝试访问认证服务器。这些行为可能难以拦截。为它创建并注册一个测试专用版来代替真正的 ​UserService ​要容易得多,也更安全。

这个特定的测试套件提供了 ​UserService ​的最小化模拟,它满足了 ​WelcomeComponent ​及其测试的需求:

let userServiceStub: Partial<UserService>;

  userServiceStub = {
    isLoggedIn: true,
    user: { name: 'Test User' },
  };

取得所注入的服务

这些测试需要访问注入到 ​WelcomeComponent ​中的 ​UserService ​桩。

Angular 有一个分层注入系统。它具有多个层级的注入器,从 ​TestBed ​创建的根注入器开始,直到组件树中的各个层级。

获得注入服务的最安全的方式(始终有效),就是从被测组件的注入器中获取它。组件注入器是测试夹具所提供的 ​DebugElement ​中的一个属性。

// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);

TestBed.inject()

可能还可以通过 ​TestBed.inject()​ 来从根注入器获得服务。这更容易记忆,也不那么啰嗦。但这只有当 Angular 要把根注入器中的服务实例注入测试组件时才是可行的。

在下面这个测试套件中,​UserService唯一的提供者是根测试模块,因此可以安全地调用 ​TestBed.inject()​,如下所示:

// UserService from the root injector
userService = TestBed.inject(UserService);

最后的设置与测试

这里是完成的 ​beforeEach()​,它使用了 ​TestBed.inject()​ :

let userServiceStub: Partial<UserService>;

beforeEach(() => {
  // stub UserService for test purposes
  userServiceStub = {
    isLoggedIn: true,
    user: { name: 'Test User' },
  };

  TestBed.configureTestingModule({
     declarations: [ WelcomeComponent ],
     providers: [ { provide: UserService, useValue: userServiceStub } ],
  });

  fixture = TestBed.createComponent(WelcomeComponent);
  comp    = fixture.componentInstance;

  // UserService from the root injector
  userService = TestBed.inject(UserService);

  //  get the "welcome" element by CSS selector (e.g., by class name)
  el = fixture.nativeElement.querySelector('.welcome');
});

以下是一些测试:

it('should welcome the user', () => {
  fixture.detectChanges();
  const content = el.textContent;
  expect(content)
    .withContext('"Welcome ..."')
    .toContain('Welcome');
  expect(content)
    .withContext('expected name')
    .toContain('Test User');
});

it('should welcome "Bubba"', () => {
  userService.user.name = 'Bubba'; // welcome message hasn't been shown yet
  fixture.detectChanges();
  expect(el.textContent).toContain('Bubba');
});

it('should request login if not logged in', () => {
  userService.isLoggedIn = false; // welcome message hasn't been shown yet
  fixture.detectChanges();
  const content = el.textContent;
  expect(content)
    .withContext('not welcomed')
    .not.toContain('Welcome');
  expect(content)
    .withContext('"log in"')
    .toMatch(/log in/i);
});

首先是一个健全性测试;它确认了桩服务 ​UserService ​被调用过并能正常工作。

Jasmine 匹配器的第二个参数(比如 ​'expected name'​)是一个可选的失败标签。如果此期望失败,Jasmine 就会把这个标签贴到期望失败的消息中。在具有多个期望的测试规约中,它可以帮我们澄清出现了什么问题以及都有哪些期望失败了。

当该服务返回不同的值时,其余的测试会确认该组件的逻辑。第二个测试验证了更改用户名的效果。当用户未登录时,第三个测试会检查组件是否显示了正确的消息。

带异步服务的组件

在这个例子中,​AboutComponent ​模板托管了一个 ​TwainComponent​。​TwainComponent ​会显示马克·吐温的名言。

template: `
  <p class="twain"><i>{{quote | async}}</i></p>
  <button type="button" (click)="getQuote()">Next quote</button>
  <p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>`,
注意:
组件的 ​quote ​属性的值通过 ​AsyncPipe ​传递。这意味着该属性会返回 ​Promise ​或 ​Observable​。

在这个例子中,​TwainComponent.getQuote()​ 方法告诉你 ​quote ​属性会返回一个 ​Observable​。

getQuote() {
  this.errorMessage = '';
  this.quote = this.twainService.getQuote().pipe(
    startWith('...'),
    catchError( (err: any) => {
      // Wait a turn because errorMessage already set once this turn
      setTimeout(() => this.errorMessage = err.message || err.toString());
      return of('...'); // reset message to placeholder
    })
  );

该 ​TwainComponent ​从注入的 ​TwainService ​中获取名言。该在服务能返回第一条名言之前,该服务会先返回一个占位流(​'...'​)。

catchError ​会拦截服务错误,准备一条错误信息,并在流的成功通道上返回占位值。它必须等一拍(tick)才能设置 ​errorMessage​,以免在同一个变更检测周期内更新此消息两次。

这些都是你想要测试的特性。

使用间谍(spy)进行测试

在测试组件时,只有该服务的公开 API 才有意义。通常,测试本身不应该调用远程服务器。它们应该模拟这样的调用。这个 ​app/twain/twain.component.spec.ts​ 中的环境准备工作展示了一种方法:

beforeEach(() => {
  testQuote = 'Test Quote';

  // Create a fake TwainService object with a `getQuote()` spy
  const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
  // Make the spy return a synchronous Observable with the test data
  getQuoteSpy = twainService.getQuote.and.returnValue(of(testQuote));

  TestBed.configureTestingModule({
    declarations: [TwainComponent],
    providers: [{provide: TwainService, useValue: twainService}]
  });

  fixture = TestBed.createComponent(TwainComponent);
  component = fixture.componentInstance;
  quoteEl = fixture.nativeElement.querySelector('.twain');
});

仔细看一下这个间谍。

// Create a fake TwainService object with a `getQuote()` spy
const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = twainService.getQuote.and.returnValue(of(testQuote));

这个间谍的设计目标是让所有对 ​getQuote ​的调用都会收到一个带有测试名言的可观察对象。与真正的 ​getQuote()​ 方法不同,这个间谍会绕过服务器,并返回一个立即同步提供可用值的可观察对象。

虽然这个 ​Observable ​是同步的,但你也可以用这个间谍编写很多有用的测试。

同步测试

同步 ​Observable ​的一个关键优势是,你通常可以把异步过程转换成同步测试。

it('should show quote after component initialized', () => {
  fixture.detectChanges();  // onInit()

  // sync spy result shows testQuote immediately after init
  expect(quoteEl.textContent).toBe(testQuote);
  expect(getQuoteSpy.calls.any())
    .withContext('getQuote called')
    .toBe(true);
});

当间谍的结果同步返回时,​getQuote()​ 方法会在第一个变更检测周期(Angular 在这里调用 ​ngOnInit​)立即更新屏幕上的消息。

你在测试错误路径时就没有这么幸运了。虽然服务间谍会同步返回一个错误,但该组件方法会调用 ​setTimeout()​。在值可用之前,测试必须等待 JavaScript 引擎的至少一个周期。因此,该测试必须是异步的

使用 fakeAsync() 进行异步测试

要使用 ​fakeAsync()​ 功能,你必须在测试的环境设置文件中导入 ​zone.js/testing​。如果是用 Angular CLI 创建的项目,那么其 ​src/test.ts​ 中已经配置好了 ​zone-testing​。

当该服务返回 ​ErrorObservable ​时,下列测试会对其预期行为进行确认。

it('should display error when TwainService fails', fakeAsync(() => {
     // tell spy to return an error observable
     getQuoteSpy.and.returnValue(throwError(() => new Error('TwainService test failure')));
     fixture.detectChanges();  // onInit()
     // sync spy errors immediately after init

     tick();  // flush the component's setTimeout()

     fixture.detectChanges();  // update errorMessage within setTimeout()

     expect(errorMessage())
      .withContext('should display error')
      .toMatch(/test failure/, );
     expect(quoteEl.textContent)
      .withContext('should show placeholder')
      .toBe('...');
   }));
注意:
it()​ 函数会接收以下形式的参数。
fakeAsync(() => { /* test body */ })

通过在一个特殊的 ​fakeAsync test zone​(译注:Zone.js 的一个特例)中运行测试体,​fakeAsync()​ 函数可以启用线性编码风格。这个测试体看上去是同步的。没有像 ​Promise.then()​ 这样的嵌套语法来破坏控制流。

限制:如果测试体要进行 ​XMLHttpRequest​(XHR)调用,则 ​fakeAsync()​ 函数无效。

tick() 函数

你必须调用 ​tick()​ 来推进(虚拟)时钟。

调用 ​tick()​ 时会在所有挂起的异步活动完成之前模拟时间的流逝。在这种情况下,它会等待错误处理程序中的 ​setTimeout()​。

tick()​ 函数接受毫秒数(milliseconds) 和 tick 选项(tickOptions) 作为参数,毫秒数(默认值为 0)参数表示虚拟时钟要前进多少。比如,如果你在 ​fakeAsync()​ 测试中有一个 ​setTimeout(fn, 100)​,你就需要使用 ​tick(100)​ 来触发其 fn 回调。tickOptions 是一个可选参数,它带有一个名为 ​processNewMacroTasksSynchronously ​的属性(默认为 true),表示在 tick 时是否要调用新生成的宏任务。

it('should run timeout callback with delay after call tick with millis', fakeAsync(() => {
     let called = false;
     setTimeout(() => {
       called = true;
     }, 100);
     tick(100);
     expect(called).toBe(true);
   }));

tick()​ 函数是你用 ​TestBed ​导入的 Angular 测试工具函数之一。它是 ​fakeAsync()​ 的伴生工具,你只能在 ​fakeAsync()​ 测试体内调用它。

tickOptions

it('should run new macro task callback with delay after call tick with millis',
   fakeAsync(() => {
     function nestedTimer(cb: () => any): void {
       setTimeout(() => setTimeout(() => cb()));
     }
     const callback = jasmine.createSpy('callback');
     nestedTimer(callback);
     expect(callback).not.toHaveBeenCalled();
     tick(0);
     // the nested timeout will also be triggered
     expect(callback).toHaveBeenCalled();
   }));

在这个例子中,我们有一个新的宏任务(嵌套的 setTimeout),默认情况下,当 ​tick ​时,setTimeout 的 ​outside ​和 ​nested ​都会被触发。

it('should not run new macro task callback with delay after call tick with millis',
   fakeAsync(() => {
     function nestedTimer(cb: () => any): void {
       setTimeout(() => setTimeout(() => cb()));
     }
     const callback = jasmine.createSpy('callback');
     nestedTimer(callback);
     expect(callback).not.toHaveBeenCalled();
     tick(0, {processNewMacroTasksSynchronously: false});
     // the nested timeout will not be triggered
     expect(callback).not.toHaveBeenCalled();
     tick(0);
     expect(callback).toHaveBeenCalled();
   }));

在某种情况下,你不希望在 tick 时触发新的宏任务,就可以使用 ​tick(milliseconds, {processNewMacroTasksSynchronously: false})​ 来要求不调用新的宏任务。

比较 fakeAsync() 内部的日期

fakeAsync()​ 可以模拟时间的流逝,以便让你计算出 ​fakeAsync()​ 里面的日期差。

it('should get Date diff correctly in fakeAsync', fakeAsync(() => {
     const start = Date.now();
     tick(100);
     const end = Date.now();
     expect(end - start).toBe(100);
   }));

jasmine.clock 与 fakeAsync() 联用

Jasmine 还为模拟日期提供了 ​clock ​特性。而 Angular 会在 ​jasmine.clock().install()​ 于 ​fakeAsync()​ 方法内调用时自动运行这些测试。直到调用了 ​jasmine.clock().uninstall()​ 为止。​fakeAsync()​ 不是必须的,如果嵌套它就抛出错误。

默认情况下,此功能处于禁用状态。要启用它,请在导入 ​zone-testing​ 之前先设置全局标志。

如果你使用的是 Angular CLI,请在 ​src/test.ts​ 中配置这个标志。

(window as any)['__zone_symbol__fakeAsyncPatchLock'] = true;
import 'zone.js/testing';
describe('use jasmine.clock()', () => {
  // need to config __zone_symbol__fakeAsyncPatchLock flag
  // before loading zone.js/testing
  beforeEach(() => {
    jasmine.clock().install();
  });
  afterEach(() => {
    jasmine.clock().uninstall();
  });
  it('should auto enter fakeAsync', () => {
    // is in fakeAsync now, don't need to call fakeAsync(testFn)
    let called = false;
    setTimeout(() => {
      called = true;
    }, 100);
    jasmine.clock().tick(100);
    expect(called).toBe(true);
  });
});

在 fakeAsync() 中使用 RxJS 调度器

fakeAsync()​ 使用 RxJS 的调度器,就像使用 ​setTimeout()​ 或 ​setInterval()​ 一样,但你需要导入 ​zone.js/plugins/zone-patch-rxjs-fake-async​ 来给 RxJS 调度器打补丁。

it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => {
     // need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async'
     // to patch rxjs scheduler
     let result = '';
     of('hello').pipe(delay(1000)).subscribe(v => {
       result = v;
     });
     expect(result).toBe('');
     tick(1000);
     expect(result).toBe('hello');

     const start = new Date().getTime();
     let dateDiff = 0;
     interval(1000).pipe(take(2)).subscribe(() => dateDiff = (new Date().getTime() - start));

     tick(1000);
     expect(dateDiff).toBe(1000);
     tick(1000);
     expect(dateDiff).toBe(2000);
   }));

支持更多的 macroTasks

fakeAsync()​ 默认支持以下宏任务。

  • setTimeout
  • setInterval
  • requestAnimationFrame
  • webkitRequestAnimationFrame
  • mozRequestAnimationFrame

如果你运行其他宏任务,比如 ​HTMLCanvasElement.toBlob()​,就会抛出 "Unknown macroTask scheduled in fake async test" 错误。

  • src/app/shared/canvas.component.spec.ts (failing)
  • import { fakeAsync, TestBed, tick } from '@angular/core/testing';
    
    import { CanvasComponent } from './canvas.component';
    
    describe('CanvasComponent', () => {
      beforeEach(async () => {
        await TestBed
            .configureTestingModule({
              declarations: [CanvasComponent],
            })
            .compileComponents();
      });
    
      it('should be able to generate blob data from canvas', fakeAsync(() => {
           const fixture = TestBed.createComponent(CanvasComponent);
           const canvasComp = fixture.componentInstance;
    
           fixture.detectChanges();
           expect(canvasComp.blobSize).toBe(0);
    
           tick();
           expect(canvasComp.blobSize).toBeGreaterThan(0);
         }));
    });
  • src/app/shared/canvas.component.ts
  • import { Component, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
    
    @Component({
      selector: 'sample-canvas',
      template: '<canvas #sampleCanvas width="200" height="200"></canvas>',
    })
    export class CanvasComponent implements AfterViewInit {
      blobSize = 0;
      @ViewChild('sampleCanvas') sampleCanvas!: ElementRef;
    
      ngAfterViewInit() {
        const canvas: HTMLCanvasElement = this.sampleCanvas.nativeElement;
        const context = canvas.getContext('2d')!;
    
        context.clearRect(0, 0, 200, 200);
        context.fillStyle = '#FF1122';
        context.fillRect(0, 0, 200, 200);
    
        canvas.toBlob(blob => {
          this.blobSize = blob?.size ?? 0;
        });
      }
    }

如果你想支持这种情况,就要在 ​beforeEach()​ 定义你要支持的宏任务。比如:

beforeEach(() => {
  (window as any).__zone_symbol__FakeAsyncTestMacroTask = [
    {
      source: 'HTMLCanvasElement.toBlob',
      callbackArgs: [{size: 200}],
    },
  ];
});
注意:
要在依赖 Zone.js 的应用中使用 ​<canvas>​ 元素,你需要导入 ​zone-patch-canvas​ 补丁(或者在 ​polyfills.ts​ 中,或者在用到 ​<canvas>​ 的那个文件中):
// Import patch to make async `HTMLCanvasElement` methods (such as `.toBlob()`) Zone.js-aware.
// Either import in `polyfills.ts` (if used in more than one places in the app) or in the component
// file using `HTMLCanvasElement` (if it is only used in a single file).
import 'zone.js/plugins/zone-patch-canvas';

异步可观察对象

你可能已经对前面这些测试的测试覆盖率感到满意。

但是,你可能也会为另一个事实感到不安:真实的服务并不是这样工作的。真实的服务会向远程服务器发送请求。服务器需要一定的时间才能做出响应,并且其响应体肯定不会像前面两个测试中一样是立即可用的。

如果能像下面这样从 ​getQuote()​ 间谍中返回一个异步的可观察对象,你的测试就会更真实地反映现实世界。

// Simulate delayed observable values with the `asyncData()` helper
getQuoteSpy.and.returnValue(asyncData(testQuote));

异步可观察对象测试助手

异步可观察对象可以由测试助手 ​asyncData ​生成。测试助手 ​asyncData ​是一个你必须自行编写的工具函数,当然也可以从下面的范例代码中复制它。

/**
 * Create async observable that emits-once and completes
 * after a JS engine turn
 */
export function asyncData<T>(data: T) {
  return defer(() => Promise.resolve(data));
}

这个助手返回的可观察对象会在 JavaScript 引擎的下一个周期中发送 ​data ​值。

RxJS 的defer()操作符返回一个可观察对象。它的参数是一个返回 Promise 或可观察对象的工厂函数。当某个订阅者订阅 defer 生成的可观察对象时,defer 就会调用此工厂函数生成新的可观察对象,并让该订阅者订阅这个新对象。

defer()​ 操作符会把 ​Promise.resolve()​ 转换成一个新的可观察对象,它和 ​HttpClient ​一样只会发送一次然后立即结束(complete)。这样,当订阅者收到数据后就会自动取消订阅。

还有一个类似的用来生成异步错误的测试助手。

/**
 * Create async observable error that errors
 * after a JS engine turn
 */
export function asyncError<T>(errorObject: any) {
  return defer(() => Promise.reject(errorObject));
}

更多异步测试

现在,​getQuote()​ 间谍正在返回异步可观察对象,你的大多数测试都必须是异步的。

下面是一个 ​fakeAsync()​ 测试,用于演示你在真实世界中所期望的数据流。

it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
     fixture.detectChanges();  // ngOnInit()
     expect(quoteEl.textContent)
      .withContext('should show placeholder')
      .toBe('...');

     tick();                   // flush the observable to get the quote
     fixture.detectChanges();  // update view

     expect(quoteEl.textContent)
      .withContext('should show quote')
      .toBe(testQuote);
     expect(errorMessage())
      .withContext('should not show error')
      .toBeNull();
   }));

注意,quote 元素会在 ​ngOnInit()​ 之后显示占位符 ​'...'​。因为第一句名言尚未到来。

要清除可观察对象中的第一句名言,你可以调用 ​tick()​。然后调用 ​detectChanges()​ 来告诉 Angular 更新屏幕。

然后,你可以断言 quote 元素是否显示了预期的文本。

用 waitForAsync() 进行异步测试

要使用 ​waitForAsync()​ 函数,你必须在 test 的设置文件中导入 ​zone.js/testing​。如果你是用 Angular CLI 创建的项目,那就已经在 ​src/test.ts​ 中导入过 ​zone-testing​ 了。

这是之前的 ​fakeAsync()​ 测试,用 ​waitForAsync()​ 工具函数重写的版本。

it('should show quote after getQuote (waitForAsync)', waitForAsync(() => {
     fixture.detectChanges();  // ngOnInit()
     expect(quoteEl.textContent)
      .withContext('should show placeholder')
      .toBe('...');

     fixture.whenStable().then(() => {  // wait for async getQuote
       fixture.detectChanges();         // update view with quote
       expect(quoteEl.textContent).toBe(testQuote);
       expect(errorMessage())
        .withContext('should not show error')
        .toBeNull();
     });
   }));

waitForAsync()​ 工具函数通过把测试代码安排到在特殊的异步测试区(async test zone)下运行来隐藏某些用来处理异步的样板代码。你不需要把 Jasmine 的 ​done()​ 传给测试并让测试调用 ​done()​,因为它在 Promise 或者可观察对象的回调函数中是 ​undefined​。

但是,可以通过调用 ​fixture.whenStable()​ 函数来揭示本测试的异步性,因为该函数打破了线性的控制流。

在 ​waitForAsync()​ 中使用 ​intervalTimer()​(比如 ​setInterval()​)时,别忘了在测试后通过 ​clearInterval()​ 取消这个定时器,否则 ​waitForAsync()​ 永远不会结束。

whenStable

测试必须等待 ​getQuote()​ 可观察对象发出下一句名言。它并没有调用 ​tick()​,而是调用了 ​fixture.whenStable()​。

fixture.whenStable()​ 返回一个 Promise,它会在 JavaScript 引擎的任务队列变空时解析。在这个例子中,当可观察对象发出第一句名言时,任务队列就会变为空。

测试会在该 Promise 的回调中继续进行,它会调用 ​detectChanges()​ 来用期望的文本更新 quote 元素。

Jasmine done()

虽然 ​waitForAsync()​ 和 ​fakeAsync()​ 函数可以大大简化 Angular 的异步测试,但你仍然可以回退到传统技术,并给 ​it ​传一个以 ​done​ 回调为参数的函数。

但你不能在 ​waitForAsync()​ 或 ​fakeAsync()​ 函数中调用 ​done()​,因为那里的 ​done ​参数是 ​undefined​。

现在,你要自己负责串联各种 Promise、处理错误,并在适当的时机调用 ​done()​。

编写带有 ​done()​ 的测试函数要比用 ​waitForAsync()​ 和 ​fakeAsync()​ 的形式笨重。但是当代码涉及到像 ​setInterval ​这样的 ​intervalTimer()​ 时,它往往是必要的。

这里是上一个测试的另外两种版本,用 ​done()​ 编写。第一个订阅了通过组件的 ​quote ​属性暴露给模板的 ​Observable​。

it('should show last quote (quote done)', (done: DoneFn) => {
  fixture.detectChanges();

  component.quote.pipe(last()).subscribe(() => {
    fixture.detectChanges();  // update view with quote
    expect(quoteEl.textContent).toBe(testQuote);
    expect(errorMessage())
      .withContext('should not show error')
      .toBeNull();
    done();
  });
});

RxJS 的 ​last()​ 操作符会在完成之前发出可观察对象的最后一个值,它同样是测试名言。​subscribe ​回调会调用 ​detectChanges()​ 来使用测试名言刷新的 quote 元素,方法与之前的测试一样。

在某些测试中,你可能更关心注入的服务方法是如何被调的以及它返回了什么值,而不是屏幕显示的内容。

服务间谍,比如伪 ​TwainService ​上的 ​qetQuote()​ 间谍,可以给你那些信息,并对视图的状态做出断言。

it('should show quote after getQuote (spy done)', (done: DoneFn) => {
  fixture.detectChanges();

  // the spy's most recent call returns the observable with the test quote
  getQuoteSpy.calls.mostRecent().returnValue.subscribe(() => {
    fixture.detectChanges();  // update view with quote
    expect(quoteEl.textContent).toBe(testQuote);
    expect(errorMessage())
      .withContext('should not show error')
      .toBeNull();
    done();
  });
});

组件的弹珠测试

前面的 ​TwainComponent ​测试通过 ​asyncData ​和 ​asyncError ​工具函数模拟了一个来自 ​TwainService ​的异步响应体可观察对象。

你可以自己编写这些简短易用的函数。不幸的是,对于很多常见的场景来说,它们太简单了。可观察对象经常会发送很多次,可能是在经过一段显著的延迟之后。组件可以用重叠的值序列和错误序列来协调多个可观察对象。

RxJS 弹珠测试是一种测试可观察场景的好方法,它既简单又复杂。你很可能已经看过用于说明可观察对象是如何工作弹珠图。弹珠测试使用类似的弹珠语言来指定测试中的可观察流和期望值。

下面的例子用弹珠测试再次实现了 ​TwainComponent ​中的两个测试。

首先安装 npm 包 ​jasmine-marbles​。然后导入你需要的符号。

import { cold, getTestScheduler } from 'jasmine-marbles';

获取名言的完整测试方法如下:

it('should show quote after getQuote (marbles)', () => {
  // observable test quote value and complete(), after delay
  const q$ = cold('---x|', { x: testQuote });
  getQuoteSpy.and.returnValue( q$ );

  fixture.detectChanges(); // ngOnInit()
  expect(quoteEl.textContent)
    .withContext('should show placeholder')
    .toBe('...');

  getTestScheduler().flush(); // flush the observables

  fixture.detectChanges(); // update view

  expect(quoteEl.textContent)
    .withContext('should show quote')
    .toBe(testQuote);
  expect(errorMessage())
    .withContext('should not show error')
    .toBeNull();
});

注意,这个 Jasmine 测试是同步的。没有 ​fakeAsync()​。弹珠测试使用测试调度程序(scheduler)来模拟同步测试中的时间流逝。

弹珠测试的美妙之处在于对可观察对象流的视觉定义。这个测试定义了一个冷可观察对象,它等待三帧(​---​),发出一个值(​x​),并完成(​|​)。在第二个参数中,你把值标记(​x​)映射到了发出的值(​testQuote​)。

const q$ = cold('---x|', { x: testQuote });

这个弹珠库会构造出相应的可观察对象,测试程序把它用作 ​getQuote ​间谍的返回值。

当你准备好激活弹珠的可观察对象时,就告诉 ​TestScheduler ​把它准备好的任务队列刷新一下。

getTestScheduler().flush(); // flush the observables

这个步骤的作用类似于之前的 ​fakeAsync()​ 和 ​waitForAsync()​ 例子中的 ​tick()​ 和 ​whenStable()​ 测试。对这种测试的权衡策略与那些例子是一样的。

弹珠错误测试

下面是 ​getQuote()​ 错误测试的弹珠测试版。

it('should display error when TwainService fails', fakeAsync(() => {
  // observable error after delay
  const q$ = cold('---#|', null, new Error('TwainService test failure'));
  getQuoteSpy.and.returnValue( q$ );

  fixture.detectChanges(); // ngOnInit()
  expect(quoteEl.textContent)
    .withContext('should show placeholder')
    .toBe('...');

  getTestScheduler().flush(); // flush the observables
  tick();                     // component shows error after a setTimeout()
  fixture.detectChanges();    // update error message

  expect(errorMessage())
    .withContext('should display error')
    .toMatch(/test failure/);
  expect(quoteEl.textContent)
    .withContext('should show placeholder')
    .toBe('...');
}));

它仍然是异步测试,调用 ​fakeAsync()​ 和 ​tick()​,因为该组件在处理错误时会调用 ​setTimeout()​。

看看这个弹珠的可观察定义。

const q$ = cold('---#|', null, new Error('TwainService test failure'));

这是一个可观察对象,等待三帧,然后发出一个错误,井号(​#​)标出了在第三个参数中指定错误的发生时间。第二个参数为 null,因为该可观察对象永远不会发出值。

了解弹珠测试

弹珠帧是测试时间线上的虚拟单位。每个符号(-,x,|,#)都表示经过了一帧。

可观察对象在你订阅它之前不会产生值。你的大多数应用中可观察对象都是冷的。所有的 ​HttpClient ​方法返回的都是冷可观察对象。

热的可观察对象在订阅它之前就已经在生成了这些值。用来报告路由器活动的 ​Router.events​ 可观察对象就是一种可观察对象。

RxJS 弹珠测试这个主题非常丰富,超出了本指南的范围。你可以在网上了解它,先从其官方文档开始。

具有输入和输出属性的组件

具有输入和输出属性的组件通常会出现在宿主组件的视图模板中。宿主使用属性绑定来设置输入属性,并使用事件绑定来监听输出属性引发的事件。

本测试的目标是验证这些绑定是否如预期般工作。这些测试应该设置输入值并监听输出事件。

DashboardHeroComponent ​是这类组件的一个小例子。它会显示由 ​DashboardComponent ​提供的一个英雄。点击这个英雄就会告诉 ​DashboardComponent​,用户已经选择了此英雄。

DashboardHeroComponent ​会像这样内嵌在 ​DashboardComponent ​模板中的:

<dashboard-hero *ngFor="let hero of heroes"  class="col-1-4"
  [hero]=hero  (selected)="gotoDetail($event)" >
</dashboard-hero>

DashboardHeroComponent ​出现在 ​*ngFor​ 复写器中,把它的输入属性 ​hero ​设置为当前的循环变量,并监听该组件的 ​selected ​事件。

这里是组件的完整定义:

@Component({
  selector: 'dashboard-hero',
  template: `
    <button type="button" (click)="click()" class="hero">
      {{hero.name | uppercase}}
    </button>
  `,
  styleUrls: [ './dashboard-hero.component.css' ]
})
export class DashboardHeroComponent {
  @Input() hero!: Hero;
  @Output() selected = new EventEmitter<Hero>();
  click() { this.selected.emit(this.hero); }
}

在测试一个组件时,像这样简单的场景没什么内在价值,但值得了解它。你可以继续尝试这些方法:

  • 用 ​DashboardComponent ​来测试它。
  • 把它作为一个独立的组件进行测试。
  • 用 ​DashboardComponent ​的一个替代品来测试它。

快速看一眼 ​DashboardComponent ​构造函数就知道不建议采用第一种方法:

constructor(
  private router: Router,
  private heroService: HeroService) {
}

DashboardComponent ​依赖于 Angular 的路由器和 ​HeroService​。你可能不得不用测试替身来代替它们,这有很多工作。路由器看上去特别有挑战性。

当前的目标是测试 ​DashboardHeroComponent​,而不是 ​DashboardComponent​,所以试试第二个和第三个选项。

单独测试 DashboardHeroComponent

这里是 spec 文件中环境设置部分的内容。

TestBed
    .configureTestingModule({declarations: [DashboardHeroComponent]})
fixture = TestBed.createComponent(DashboardHeroComponent);
comp = fixture.componentInstance;

// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;

// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};

// simulate the parent setting the input property with that hero
comp.hero = expectedHero;

// trigger initial data binding
fixture.detectChanges();

注意这些设置代码如何把一个测试英雄(​expectedHero​)赋值给组件的 ​hero ​属性的,它模仿了 ​DashboardComponent ​在其复写器中通过属性绑定来设置它的方式。

下面的测试验证了英雄名是通过绑定传播到模板的。

it('should display hero name in uppercase', () => {
  const expectedPipedName = expectedHero.name.toUpperCase();
  expect(heroEl.textContent).toContain(expectedPipedName);
});

因为模板把英雄的名字传给了 ​UpperCasePipe​,所以测试必须要让元素值与其大写形式的名字一致。

这个小测试演示了 Angular 测试会如何验证一个组件的可视化表示形式 - 这是组件类测试所无法实现的 - 成本相对较低,无需进行更慢、更复杂的端到端测试。

点击

单击该英雄应该会让一个宿主组件(可能是 ​DashboardComponent​)监听到 ​selected ​事件。

it('should raise selected event when clicked (triggerEventHandler)', () => {
  let selectedHero: Hero | undefined;
  comp.selected.pipe(first()).subscribe((hero: Hero) => selectedHero = hero);

  heroDe.triggerEventHandler('click');
  expect(selectedHero).toBe(expectedHero);
});

该组件的 ​selected ​属性给消费者返回了一个 ​EventEmitter​,它看起来像是 RxJS 的同步 ​Observable​。该测试只有在宿主组件隐式触发时才需要显式订阅它。

当组件的行为符合预期时,单击此英雄的元素就会告诉组件的 ​selected ​属性发出了一个 ​hero ​对象。

该测试通过对 ​selected ​的订阅来检测该事件。

triggerEventHandler

前面测试中的 ​heroDe ​是一个指向英雄条目 ​<div>​ 的 ​DebugElement​。

它有一些用于抽象与原生元素交互的 Angular 属性和方法。这个测试会使用事件名称 ​click ​来调用 ​DebugElement.triggerEventHandler​。​click ​的事件绑定到了 ​DashboardHeroComponent.click()​。

Angular 的 ​DebugElement.triggerEventHandler​ 可以用事件的名字触发任何数据绑定事件。第二个参数是传给事件处理器的事件对象。

该测试触发了一个 “click” 事件。

heroDe.triggerEventHandler('click');

测试程序假设(在这里应该这样)运行时间的事件处理器(组件的 ​click()​ 方法)不关心事件对象。

其它处理器的要求比较严格。比如,​RouterLink ​指令期望一个带有 ​button ​属性的对象,该属性用于指出点击时按下的是哪个鼠标按钮。如果不给出这个事件对象,​RouterLink ​指令就会抛出一个错误。

点击该元素

下面这个测试改为调用原生元素自己的 ​click()​ 方法,它对于这个组件来说相当完美。

it('should raise selected event when clicked (element.click)', () => {
  let selectedHero: Hero | undefined;
  comp.selected.pipe(first()).subscribe((hero: Hero) => selectedHero = hero);

  heroEl.click();
  expect(selectedHero).toBe(expectedHero);
});

click() 帮助器

点击按钮、链接或者任意 HTML 元素是很常见的测试任务。

点击事件的处理过程包装到如下的 ​click()​ 辅助函数中,可以让这项任务更一致、更简单:

/** Button events to pass to `DebugElement.triggerEventHandler` for RouterLink event handler */
export const ButtonClickEvents = {
   left:  { button: 0 },
   right: { button: 2 }
};

/** Simulate element click. Defaults to mouse left-button click event. */
export function click(el: DebugElement | HTMLElement, eventObj: any = ButtonClickEvents.left): void {
  if (el instanceof HTMLElement) {
    el.click();
  } else {
    el.triggerEventHandler('click', eventObj);
  }
}

第一个参数是用来点击的元素。如果你愿意,可以将自定义的事件对象传给第二个参数。 默认的是(局部的)鼠标左键事件对象,它被许多事件处理器接受,包括 RouterLink 指令。

click()​ 辅助函数不是Angular 测试工具之一。它是在本章的例子代码中定义的函数方法,被所有测试例子所用。如果你喜欢它,将它添加到你自己的辅助函数集。

下面是把前面的测试用 ​click ​辅助函数重写后的版本。

it('should raise selected event when clicked (click helper with DebugElement)', () => {
  let selectedHero: Hero | undefined;
  comp.selected.pipe(first()).subscribe((hero: Hero) => selectedHero = hero);

  click(heroDe);  // click helper with DebugElement

  expect(selectedHero).toBe(expectedHero);
});

位于测试宿主中的组件

前面的这些测试都是自己扮演宿主元素 ​DashboardComponent ​的角色。但是当 ​DashboardHeroComponent ​真的绑定到某个宿主元素时还能正常工作吗?

固然,你也可以测试真实的 ​DashboardComponent​。但要想这么做需要做很多准备工作,特别是它的模板中使用了某些特性,如 ​*ngFor​、 其它组件、布局 HTML、附加绑定、注入了多个服务的构造函数、如何用正确的方式与那些服务交互等。

想出这么多需要努力排除的干扰,只是为了证明一点 —— 可以造出这样一个令人满意的测试宿主

@Component({
  template: `
    <dashboard-hero
      [hero]="hero" (selected)="onSelected($event)">
    </dashboard-hero>`
})
class TestHostComponent {
  hero: Hero = {id: 42, name: 'Test Name'};
  selectedHero: Hero | undefined;
  onSelected(hero: Hero) {
    this.selectedHero = hero;
  }
}

这个测试宿主像 ​DashboardComponent ​那样绑定了 ​DashboardHeroComponent​,但是没有 ​Router​、 没有 ​HeroService​,也没有 ​*ngFor​。

这个测试宿主使用其测试用的英雄设置了组件的输入属性 ​hero​。它使用 ​onSelected ​事件处理器绑定了组件的 ​selected ​事件,其中把事件中发出的英雄记录到了 ​selectedHero ​属性中。

稍后,这个测试就可以轻松检查 ​selectedHero ​以验证 ​DashboardHeroComponent.selected​ 事件确实发出了所期望的英雄。

这个测试宿主中的准备代码和独立测试中的准备过程类似:

TestBed
    .configureTestingModule({declarations: [DashboardHeroComponent, TestHostComponent]})
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges();  // trigger initial data binding

这个测试模块的配置信息有三个重要的不同点:

  • 它同时声明了 ​DashboardHeroComponent ​和 ​TestHostComponent​。
  • 它创建了 ​TestHostComponent​,而非 ​DashboardHeroComponent​。
  • TestHostComponent ​通过绑定机制设置了 ​DashboardHeroComponent.hero​。

createComponent ​返回的 ​fixture ​里有 ​TestHostComponent ​实例,而非 ​DashboardHeroComponent ​组件实例。

当然,创建 ​TestHostComponent ​有创建 ​DashboardHeroComponent ​的副作用,因为后者出现在前者的模板中。英雄元素(​heroEl​)的查询语句仍然可以在测试 DOM 中找到它,尽管元素树比以前更深。

这些测试本身和它们的孤立版本几乎相同:

it('should display hero name', () => {
  const expectedPipedName = testHost.hero.name.toUpperCase();
  expect(heroEl.textContent).toContain(expectedPipedName);
});

it('should raise selected event when clicked', () => {
  click(heroEl);
  // selected hero should be the same data bound hero
  expect(testHost.selectedHero).toBe(testHost.hero);
});

只有 selected 事件的测试不一样。它确保被选择的 ​DashboardHeroComponent ​英雄确实通过事件绑定被传递到宿主组件。

路由组件

所谓路由组件就是指会要求 ​Router ​导航到其它组件的组件。​DashboardComponent ​就是一个路由组件,因为用户可以通过点击仪表盘中的某个英雄按钮来导航到 ​HeroDetailComponent​。

路由确实很复杂。测试 ​DashboardComponent ​看上去有点令人生畏,因为它牵扯到和 ​HeroService ​一起注入进来的 ​Router​。

constructor(
  private router: Router,
  private heroService: HeroService) {
}

使用间谍来 Mock ​HeroService ​是一个熟悉的故事。 但是 ​Router ​的 API 很复杂,并且与其它服务和应用的前置条件纠缠在一起。它应该很难进行 Mock 吧?

庆幸的是,在这个例子中不会,因为 ​DashboardComponent ​并没有深度使用 ​Router​。

gotoDetail(hero: Hero) {
  const url = `/heroes/${hero.id}`;
  this.router.navigateByUrl(url);
}

这是路由组件中的通例。一般来说,你应该测试组件而不是路由器,应该只关心组件有没有根据给定的条件导航到正确的地址。

这个组件的测试套件提供路由器的间谍就像提供 ​HeroService ​的间谍一样简单。

const routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']);
const heroServiceSpy = jasmine.createSpyObj('HeroService', ['getHeroes']);

TestBed
    .configureTestingModule({
      providers: [
        {provide: HeroService, useValue: heroServiceSpy}, {provide: Router, useValue: routerSpy}
      ]
    })

下面这个测试会点击正在显示的英雄,并确认 ​Router.navigateByUrl​ 曾用所期待的 URL 调用过。

it('should tell ROUTER to navigate when hero clicked', () => {
  heroClick();  // trigger click on first inner <div class="hero">

  // args passed to router.navigateByUrl() spy
  const spy = router.navigateByUrl as jasmine.Spy;
  const navArgs = spy.calls.first().args[0];

  // expecting to navigate to id of the component's first hero
  const id = comp.heroes[0].id;
  expect(navArgs)
    .withContext('should nav to HeroDetail for first hero')
    .toBe('/heroes/' + id);
});

路由目标组件

路由目标组件是指 ​Router ​导航到的目标。它测试起来可能很复杂,特别是当路由到的这个组件包含参数的时候。​HeroDetailComponent ​就是一个路由目标组件,它是某个路由定义指向的目标。

当用户点击仪表盘中的英雄时,​DashboardComponent ​会要求 ​Router ​导航到 ​heroes/:id​。​:id​ 是一个路由参数,它的值就是所要编辑的英雄的 ​id​。

该 ​Router ​会根据那个 URL 匹配到一个指向 ​HeroDetailComponent ​的路由。它会创建一个带有路由信息的 ​ActivatedRoute ​对象,并把它注入到一个 ​HeroDetailComponent ​的新实例中。

下面是 ​HeroDetailComponent ​的构造函数:

constructor(
  private heroDetailService: HeroDetailService,
  private route: ActivatedRoute,
  private router: Router) {
}

HeroDetailComponent ​组件需要一个 ​id ​参数,以便通过 ​HeroDetailService ​获取相应的英雄。该组件只能从 ​ActivatedRoute.paramMap​ 属性中获取这个 ​id​,这个属性是一个 ​Observable​。

它不能仅仅引用 ​ActivatedRoute.paramMap​ 的 ​id ​属性。该组件不得不订阅 ​ActivatedRoute.paramMap​ 这个可观察对象,要做好它在生命周期中随时会发生变化的准备。

ngOnInit(): void {
  // get hero when `id` param changes
  this.route.paramMap.subscribe(pmap => this.getHero(pmap.get('id')));
}

通过操纵注入到组件构造函数中的这个 ​ActivatedRoute​,测试可以探查 ​HeroDetailComponent ​是如何对不同的 ​id ​参数值做出响应的。

你已经知道了如何给 ​Router ​和数据服务安插间谍。

不过对于 ​ActivatedRoute​,你要采用另一种方式,因为:

  • 在测试期间,​paramMap ​会返回一个能发出多个值的 ​Observable​。
  • 你需要路由器的辅助函数 ​convertToParamMap()​ 来创建 ​ParamMap​。
  • 针对路由目标组件的其它测试需要一个 ​ActivatedRoute ​的测试替身。

这些差异表明你需要一个可复用的桩类(stub)。

ActivatedRouteStub

下面的 ​ActivatedRouteStub ​类就是作为 ​ActivatedRoute ​类的测试替身使用的。

import { convertToParamMap, ParamMap, Params } from '@angular/router';
import { ReplaySubject } from 'rxjs';

/**
 * An ActivateRoute test double with a `paramMap` observable.
 * Use the `setParamMap()` method to add the next `paramMap` value.
 */
export class ActivatedRouteStub {
  // Use a ReplaySubject to share previous values with subscribers
  // and pump new values into the `paramMap` observable
  private subject = new ReplaySubject<ParamMap>();

  constructor(initialParams?: Params) {
    this.setParamMap(initialParams);
  }

  /** The mock paramMap observable */
  readonly paramMap = this.subject.asObservable();

  /** Set the paramMap observable's next value */
  setParamMap(params: Params = {}) {
    this.subject.next(convertToParamMap(params));
  }
}

考虑把这类辅助函数放进一个紧邻 ​app ​文件夹的 ​testing ​文件夹。这个例子把 ​ActivatedRouteStub ​放在了 ​testing/activated-route-stub.ts​ 中。

使用 ActivatedRouteStub 进行测试

下面的测试程序是演示组件在被观察的 ​id ​指向现有英雄时的行为:

describe('when navigate to existing hero', () => {
  let expectedHero: Hero;

  beforeEach(async () => {
    expectedHero = firstHero;
    activatedRoute.setParamMap({id: expectedHero.id});
    await createComponent();
  });

  it("should display that hero's name", () => {
    expect(page.nameDisplay.textContent).toBe(expectedHero.name);
  });
});

稍后会对 ​createComponent()​ 方法和 ​page ​对象进行讨论。不过目前,你只要凭直觉来理解就行了。

当找不到 ​id ​的时候,组件应该重新路由到 ​HeroListComponent​。

测试套件的准备代码提供了一个和前面一样的路由器间谍,它会充当路由器的角色,而不用发起实际的导航。

这个测试中会期待该组件尝试导航到 ​HeroListComponent​。

describe('when navigate to non-existent hero id', () => {
  beforeEach(async () => {
    activatedRoute.setParamMap({id: 99999});
    await createComponent();
  });

  it('should try to navigate back to hero list', () => {
    expect(page.gotoListSpy.calls.any())
      .withContext('comp.gotoList called')
      .toBe(true);
    expect(page.navigateSpy.calls.any())
      .withContext('router.navigate called')
      .toBe(true);
  });
});

虽然本应用没有在缺少 ​id ​参数的时候,继续导航到 ​HeroDetailComponent ​的路由,但是,将来它可能会添加这样的路由。当没有 ​id ​时,该组件应该作出合理的反应。

在本例中,组件应该创建和显示新英雄。新英雄的 ​id ​为零,​name ​为空。本测试程序确认组件是按照预期的这样做的:

describe('when navigate with no hero id', () => {
  beforeEach(async () => {
    await createComponent();
  });

  it('should have hero.id === 0', () => {
    expect(component.hero.id).toBe(0);
  });

  it('should display empty hero name', () => {
    expect(page.nameDisplay.textContent).toBe('');
  });
});

对嵌套组件的测试

组件的模板中通常还会有嵌套组件,嵌套组件的模板还可能包含更多组件。

这棵组件树可能非常深,并且大多数时候在测试这棵树顶部的组件时,这些嵌套的组件都无关紧要。

比如,​AppComponent ​会显示一个带有链接及其 ​RouterLink ​指令的导航条。

<app-banner></app-banner>
<app-welcome></app-welcome>
<nav>
  <a routerLink="/dashboard">Dashboard</a>
  <a routerLink="/heroes">Heroes</a>
  <a routerLink="/about">About</a>
</nav>
<router-outlet></router-outlet>

虽然 ​AppComponent ​类是空的,但你可能会希望写个单元测试来确认这些链接是否正确使用了 ​RouterLink ​指令。

要想验证这些链接,你不必用 ​Router ​进行导航,也不必使用 ​<router-outlet>​ 来指出 ​Router ​应该把路由目标组件插入到什么地方。

而 ​BannerComponent ​和 ​WelcomeComponent​(写作 ​<app-banner>​ 和 ​<app-welcome>​)也同样风马牛不相及。

然而,任何测试,只要能在 DOM 中创建 ​AppComponent​,也就同样能创建这三个组件的实例。如果要创建它们,你就要配置 ​TestBed​。

如果你忘了声明它们,Angular 编译器就无法在 ​AppComponent ​模板中识别出 ​<app-banner>​、​<app-welcome>​ 和 ​<router-outlet>​ 标记,并抛出一个错误。

如果你声明的这些都是真实的组件,那么也同样要声明它们的嵌套组件,并要为这棵组件树中的任何组件提供要注入的所有服务。

如果只是想回答关于链接的一些简单问题,做这些显然就太多了。

本节会讲减少此类准备工作的两项技术。单独使用或组合使用它们,可以让这些测试聚焦于要测试的主要组件上。

对不需要的组件提供桩(stub)

这项技术中,你要为那些在测试中无关紧要的组件或指令创建和声明一些测试桩。

@Component({selector: 'app-banner', template: ''})
class BannerStubComponent {
}

@Component({selector: 'router-outlet', template: ''})
class RouterOutletStubComponent {
}

@Component({selector: 'app-welcome', template: ''})
class WelcomeStubComponent {
}

这些测试桩的选择器要和其对应的真实组件一致,但其模板和类是空的。

然后在 ​TestBed ​的配置中那些真正有用的组件、指令、管道之后声明它们。

TestBed
    .configureTestingModule({
      declarations: [
        AppComponent, RouterLinkDirectiveStub, BannerStubComponent, RouterOutletStubComponent,
        WelcomeStubComponent
      ]
    })

AppComponent ​是该测试的主角,因此当然要用它的真实版本。

而 ​RouterLinkDirectiveStub ​是一个真实的 ​RouterLink ​的测试版,它能帮你对链接进行测试。

其它都是测试桩。

NO_ERRORS_SCHEMA

第二种办法就是把 ​NO_ERRORS_SCHEMA ​添加到 ​TestBed.schemas​ 的元数据中。

TestBed
    .configureTestingModule({
      declarations: [
        AppComponent,
        RouterLinkDirectiveStub
      ],
      schemas: [NO_ERRORS_SCHEMA]
    })

NO_ERRORS_SCHEMA ​会要求 Angular 编译器忽略不认识的那些元素和属性。

编译器将会识别出 ​<app-root>​ 元素和 ​RouterLink ​属性,因为你在 ​TestBed ​的配置中声明了相应的 ​AppComponent ​和 ​RouterLinkDirectiveStub​。

但编译器在遇到 ​<app-banner>​、​<app-welcome>​ 或 ​<router-outlet>​ 时不会报错。它只会把它们渲染成空白标签,而浏览器会忽略这些标签。

你不用再提供桩组件了。

同时使用这两项技术

这些是进行浅层测试要用到的技术,之所以叫浅层测试是因为只包含本测试所关心的这个组件模板中的元素。

NO_ERRORS_SCHEMA ​方法在这两者中比较简单,但也不要过度使用它。

NO_ERRORS_SCHEMA ​还会阻止编译器告诉你因为的疏忽或拼写错误而缺失的组件和属性。你如果人工找出这些 bug 可能要浪费几个小时,但编译器可以立即捕获它们。

桩组件方式还有其它优点。虽然这个例子中的桩是空的,但你如果想要和它们用某种形式互动,也可以给它们一些裁剪过的模板和类。

在实践中,你可以在准备代码中组合使用这两种技术,例子如下。

TestBed
    .configureTestingModule({
      declarations: [
        AppComponent,
        BannerStubComponent,
        RouterLinkDirectiveStub
      ],
      schemas: [NO_ERRORS_SCHEMA]
    })

Angular 编译器会为 ​<app-banner>​ 元素创建 ​BannerComponentStub​,并把 ​RouterLinkStubDirective ​应用到带有 ​routerLink ​属性的链接上,不过它会忽略 ​<app-welcome>​ 和 ​<router-outlet>​ 标签。

带有 RouterLink 的组件

真实的 ​RouterLinkDirective ​太复杂了,而且与 ​RouterModule ​中的其它组件和指令有着千丝万缕的联系。要在准备阶段 Mock 它以及在测试中使用它具有一定的挑战性。

这段范例代码中的 ​RouterLinkDirectiveStub ​用一个代用品替换了真实的指令,这个代用品用来验证 ​AppComponent ​中所用链接的类型。

@Directive({
  selector: '[routerLink]'
})
export class RouterLinkDirectiveStub {
  @Input('routerLink') linkParams: any;
  navigatedTo: any = null;

  @HostListener('click')
  onClick() {
    this.navigatedTo = this.linkParams;
  }
}

这个 URL 被绑定到了 ​[routerLink]​ 属性,它的值流入了该指令的 ​linkParams ​属性。

它的元数据中的 ​host ​属性把宿主元素(即 ​AppComponent ​中的 ​<a>​ 元素)的 ​click ​事件关联到了这个桩指令的 ​onClick ​方法。

点击这个链接应该触发 ​onClick()​ 方法,其中会设置该桩指令中的警示器属性 ​navigatedTo​。测试中检查 ​navigatedTo ​以确认点击该链接确实如预期的那样根据路由定义设置了该属性。

路由器的配置是否正确和是否能按照那些路由定义进行导航,是测试中一组独立的问题。

By.directive 与注入的指令

再一步配置触发了数据绑定的初始化,获取导航链接的引用:

beforeEach(() => {
  fixture.detectChanges();  // trigger initial data binding

  // find DebugElements with an attached RouterLinkStubDirective
  linkDes = fixture.debugElement.queryAll(By.directive(RouterLinkDirectiveStub));

  // get attached link directive instances
  // using each DebugElement's injector
  routerLinks = linkDes.map(de => de.injector.get(RouterLinkDirectiveStub));
});

有三点特别重要:

  • 你可以使用 ​By.directive​ 来定位一个带附属指令的链接元素。
  • 该查询返回包含了匹配元素的 ​DebugElement ​包装器。
  • 每个 ​DebugElement ​都会导出该元素中的一个依赖注入器,其中带有指定的指令实例。

AppComponent ​中要验证的链接如下:

<nav>
  <a routerLink="/dashboard">Dashboard</a>
  <a routerLink="/heroes">Heroes</a>
  <a routerLink="/about">About</a>
</nav>

下面这些测试用来确认那些链接是否如预期般连接到了 ​RouterLink ​指令中:

it('can get RouterLinks from template', () => {
  expect(routerLinks.length)
    .withContext('should have 3 routerLinks')
    .toBe(3);
  expect(routerLinks[0].linkParams).toBe('/dashboard');
  expect(routerLinks[1].linkParams).toBe('/heroes');
  expect(routerLinks[2].linkParams).toBe('/about');
});

it('can click Heroes link in template', () => {
  const heroesLinkDe = linkDes[1];    // heroes link DebugElement
  const heroesLink = routerLinks[1];  // heroes link directive

  expect(heroesLink.navigatedTo)
    .withContext('should not have navigated yet')
    .toBeNull();

  heroesLinkDe.triggerEventHandler('click');
  fixture.detectChanges();

  expect(heroesLink.navigatedTo).toBe('/heroes');
});
其实这个例子中的“click”测试误入歧途了。它测试的重点其实是 ​RouterLinkDirectiveStub​,而不是该组件。这是写桩指令时常见的错误。
在本章中,它有存在的必要。它演示了如何在不涉及完整路由器机制的情况下,如何找到 ​RouterLink ​元素、点击它并检查结果。要测试更复杂的组件,你可能需要具备这样的能力,能改变视图和重新计算参数,或者当用户点击链接时,有能力重新安排导航选项。

这些测试有什么优点?

用 ​RouterLink ​的桩指令进行测试可以确认带有链接和 outlet 的组件的设置的正确性,确认组件有应该有的链接,确认它们都指向了正确的方向。这些测试程序不关心用户点击链接时,也不关心应用是否会成功的导航到目标组件。

对于这些有限的测试目标,使用 RouterLink 桩指令和 RouterOutlet 桩组件 是最佳选择。依靠真正的路由器会让它们很脆弱。它们可能因为与组件无关的原因而失败。比如,一个导航守卫可能防止没有授权的用户访问 ​HeroListComponent​。这并不是 ​AppComponent ​的过错,并且无论该组件怎么改变都无法修复这个失败的测试程序。

一组不同的测试程序可以探索当存在影响守卫的条件时(比如用户是否已认证和授权),该应用是否如期望般导航。

使用 page 对象

HeroDetailComponent ​是带有标题、两个英雄字段和两个按钮的简单视图。


但即使是这么简单的表单,其模板中也涉及到不少复杂性。

<div *ngIf="hero">
  <h2><span>{{hero.name | titlecase}}</span> Details</h2>
  <div>
    <span>id: </span>{{hero.id}}</div>
  <div>
    <label for="name">name: </label>
    <input id="name" [(ngModel)]="hero.name" placeholder="name" />
  </div>
  <button type="button" (click)="save()">Save</button>
  <button type="button" (click)="cancel()">Cancel</button>
</div>

这些供练习用的组件需要 ……

  • 等获取到英雄之后才能让元素出现在 DOM 中
  • 一个对标题文本的引用
  • 一个对 name 输入框的引用,以便对它进行探查和修改
  • 引用两个按钮,以便点击它们
  • 为组件和路由器的方法安插间谍

即使是像这样一个很小的表单,也能产生令人疯狂的错综复杂的条件设置和 CSS 元素选择。

可以使用 ​Page ​类来征服这种复杂性。​Page ​类可以处理对组件属性的访问,并对设置这些属性的逻辑进行封装。

下面是一个供 ​hero-detail.component.spec.ts​ 使用的 ​Page ​类

class Page {
  // getter properties wait to query the DOM until called.
  get buttons() {
    return this.queryAll<HTMLButtonElement>('button');
  }
  get saveBtn() {
    return this.buttons[0];
  }
  get cancelBtn() {
    return this.buttons[1];
  }
  get nameDisplay() {
    return this.query<HTMLElement>('span');
  }
  get nameInput() {
    return this.query<HTMLInputElement>('input');
  }

  gotoListSpy: jasmine.Spy;
  navigateSpy: jasmine.Spy;

  constructor(someFixture: ComponentFixture<HeroDetailComponent>) {
    // get the navigate spy from the injected router spy object
    const routerSpy = someFixture.debugElement.injector.get(Router) as any;
    this.navigateSpy = routerSpy.navigate;

    // spy on component's `gotoList()` method
    const someComponent = someFixture.componentInstance;
    this.gotoListSpy = spyOn(someComponent, 'gotoList').and.callThrough();
  }

  //// query helpers ////
  private query<T>(selector: string): T {
    return fixture.nativeElement.querySelector(selector);
  }

  private queryAll<T>(selector: string): T[] {
    return fixture.nativeElement.querySelectorAll(selector);
  }
}

现在,用来操作和检查组件的重要钩子都被井然有序的组织起来了,可以通过 ​page ​实例来使用它们。

createComponent ​方法会创建一个 ​page ​对象,并在 ​hero ​到来时自动填补空白。

/** Create the HeroDetailComponent, initialize it, set test variables  */
function createComponent() {
  fixture = TestBed.createComponent(HeroDetailComponent);
  component = fixture.componentInstance;
  page = new Page(fixture);

  // 1st change detection triggers ngOnInit which gets a hero
  fixture.detectChanges();
  return fixture.whenStable().then(() => {
    // 2nd change detection displays the async-fetched hero
    fixture.detectChanges();
  });
}

前面小节中的 HeroDetailComponent 测试示范了如何 ​createComponent​,而 ​page ​让这些测试保持简短而富有表达力。 而且还不用分心:不用等待承诺被解析,不必在 DOM 中找出元素的值才能进行比较。

还有更多的 ​HeroDetailComponent ​测试可以证明这一点。

it("should display that hero's name", () => {
  expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});

it('should navigate when click cancel', () => {
  click(page.cancelBtn);
  expect(page.navigateSpy.calls.any())
    .withContext('router.navigate called')
    .toBe(true);
});

it('should save when click save but not navigate immediately', () => {
  // Get service injected into component and spy on its`saveHero` method.
  // It delegates to fake `HeroService.updateHero` which delivers a safe test result.
  const hds = fixture.debugElement.injector.get(HeroDetailService);
  const saveSpy = spyOn(hds, 'saveHero').and.callThrough();

  click(page.saveBtn);
  expect(saveSpy.calls.any())
    .withContext('HeroDetailService.save called')
    .toBe(true);
  expect(page.navigateSpy.calls.any())
    .withContext('router.navigate not called')
    .toBe(false);
});

it('should navigate when click save and save resolves', fakeAsync(() => {
     click(page.saveBtn);
     tick();  // wait for async save to complete
     expect(page.navigateSpy.calls.any())
      .withContext('router.navigate called')
      .toBe(true);
   }));

it('should convert hero name to Title Case', () => {
  // get the name's input and display elements from the DOM
  const hostElement: HTMLElement = fixture.nativeElement;
  const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
  const nameDisplay: HTMLElement = hostElement.querySelector('span')!;

  // simulate user entering a new name into the input box
  nameInput.value = 'quick BROWN  fOx';

  // Dispatch a DOM event so that Angular learns of input value change.
  nameInput.dispatchEvent(new Event('input'));

  // Tell Angular to update the display binding through the title pipe
  fixture.detectChanges();

  expect(nameDisplay.textContent).toBe('Quick Brown  Fox');
});

调用 compileComponents()

如果你只想使用 CLI 的 ​ng test​ 命令来运行测试,那么可以忽略这一节。

如果你在非 CLI 环境中运行测试,这些测试可能会报错,错误信息如下:

Error: This test module uses the component BannerComponent
which is using a "templateUrl" or "styleUrls", but they were never compiled.
Please call "TestBed.compileComponents" before your test.

问题的根源在于这个测试中至少有一个组件引用了外部模板或外部 CSS 文件,就像下面这个版本的 ​BannerComponent ​所示。

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

@Component({
  selector: 'app-banner',
  templateUrl: './banner-external.component.html',
  styleUrls:  ['./banner-external.component.css']
})
export class BannerComponent {
  title = 'Test Tour of Heroes';
}

当 ​TestBed ​视图创建组件时,这个测试失败了。

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ BannerComponent ],
  }); // missing call to compileComponents()
  fixture = TestBed.createComponent(BannerComponent);
});

回想一下,这个应用从未编译过。所以当你调用 ​createComponent()​ 的时候,​TestBed ​就会进行隐式编译。

当它的源码都在内存中的时候,这样做没问题。不过 ​BannerComponent ​需要一些外部文件,编译时必须从文件系统中读取它,而这是一个天生的异步操作。

如果 ​TestBed ​继续执行,这些测试就会继续运行,并在编译器完成这些异步工作之前导致莫名其妙的失败。

这些错误信息告诉你要使用 ​compileComponents()​ 进行显式的编译。

compileComponents() 是异步的

你必须在异步测试函数中调用 ​compileComponents()​。

如果你忘了把测试函数标为异步的(比如忘了像稍后的代码中那样使用 ​waitForAsync()​),就会看到下列错误。

Error: ViewDestroyedError: Attempt to use a destroyed view

典型的做法是把准备逻辑拆成两个独立的 ​beforeEach()​ 函数:

函数

详情

异步 beforeEach()

负责编译组件

同步 beforeEach()

负责执行其余的准备代码

异步的 beforeEach

像下面这样编写第一个异步的 ​beforeEach​。

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ BannerComponent ],
  }).compileComponents();  // compile template and css
});

TestBed.configureTestingModule()​ 方法返回 ​TestBed ​类,所以你可以链式调用其它 ​TestBed ​中的静态方法,比如 ​compileComponents()​。

在这个例子中,​BannerComponent ​是仅有的待编译组件。其它例子中可能会使用多个组件来配置测试模块,并且可能引入某些具有其它组件的应用模块。它们中的任何一个都可能需要外部文件。

TestBed.compileComponents​ 方法会异步编译测试模块中配置过的所有组件。

在调用了 ​compileComponents()​ 之后就不能再重新配置 ​TestBed ​了。

调用 ​compileComponents()​ 会关闭当前的 ​TestBed ​实例,不再允许进行配置。你不能再调用任何 ​TestBed ​中的配置方法,既不能调 ​configureTestingModule()​,也不能调用任何 ​override...​ 方法。如果你试图这么做,​TestBed ​就会抛出错误。

确保 ​compileComponents()​ 是调用 ​TestBed.createComponent()​ 之前的最后一步。

同步的 beforeEach

第二个同步 ​beforeEach()​ 的例子包含剩下的准备步骤,包括创建组件和查询那些要检查的元素。

beforeEach(() => {
  fixture = TestBed.createComponent(BannerComponent);
  component = fixture.componentInstance;  // BannerComponent test instance
  h1 = fixture.nativeElement.querySelector('h1');
});

测试运行器(runner)会先等待第一个异步 ​beforeEach ​函数执行完再调用第二个。

整理过的准备代码

你可以把这两个 ​beforeEach()​ 函数重整成一个异步的 ​beforeEach()​。

compileComponents()​ 方法返回一个承诺,所以你可以通过把同步代码移到 ​await ​关键字后面,在那里,这个 Promise 已经解析了。

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ BannerComponent ],
  }).compileComponents();
  fixture = TestBed.createComponent(BannerComponent);
  component = fixture.componentInstance;
  h1 = fixture.nativeElement.querySelector('h1');
});

compileComponents() 是无害的

在不需要 ​compileComponents()​ 的时候调用它也不会有害处。

虽然在运行 ​ng test​ 时永远都不需要调用 ​compileComponents()​,但 CLI 生成的组件测试文件还是会调用它。

但这篇指南中的这些测试只会在必要时才调用 ​compileComponents​。

准备模块的 imports

此前的组件测试程序使用了一些 ​declarations ​来配置模块,就像这样:

TestBed
    .configureTestingModule({declarations: [DashboardHeroComponent]})

DashbaordComponent ​非常简单。它不需要帮助。但是更加复杂的组件通常依赖其它组件、指令、管道和提供者,所以这些必须也被添加到测试模块中。

幸运的是,​TestBed.configureTestingModule​ 参数与传入 ​@NgModule​ 装饰器的元数据一样,也就是所你也可以指定 ​providers ​和 ​imports​。

虽然 ​HeroDetailComponent ​很小,结构也很简单,但是它需要很多帮助。除了从默认测试模块 ​CommonModule ​中获得的支持,它还需要:

  • FormsModule ​里的 ​NgModel ​和其它,来进行双向数据绑定
  • shared ​目录里的 ​TitleCasePipe
  • 一些路由器服务(测试程序将 stub 伪造它们)
  • 英雄数据访问服务(同样被 stub 伪造了)

一种方法是从各个部分配置测试模块,就像这样:

beforeEach(async () => {
  const routerSpy = createRouterSpy();

  await TestBed
      .configureTestingModule({
        imports: [FormsModule],
        declarations: [HeroDetailComponent, TitleCasePipe],
        providers: [
          {provide: ActivatedRoute, useValue: activatedRoute},
          {provide: HeroService, useClass: TestHeroService},
          {provide: Router, useValue: routerSpy},
        ]
      })
      .compileComponents();
});
注意,​beforeEach()​ 是异步的,它调用 ​TestBed.compileComponents​ 是因为 ​HeroDetailComponent​ 有外部模板和 CSS 文件。
调用 compileComponents() 中所解释的那样,这些测试可以运行在非 CLI 环境下,那里 Angular 并不会在浏览器中编译它们。

导入共享模块

因为很多应用组件都需要 ​FormsModule ​和 ​TitleCasePipe​,所以开发者创建了 ​SharedModule ​来把它们及其它常用的部分组合在一起。

这些测试配置也可以使用 ​SharedModule​,如下所示:

beforeEach(async () => {
  const routerSpy = createRouterSpy();

  await TestBed
      .configureTestingModule({
        imports: [SharedModule],
        declarations: [HeroDetailComponent],
        providers: [
          {provide: ActivatedRoute, useValue: activatedRoute},
          {provide: HeroService, useClass: TestHeroService},
          {provide: Router, useValue: routerSpy},
        ]
      })
      .compileComponents();
});

它的导入声明少一些(未显示),稍微干净一些,小一些。

导入特性模块

HeroDetailComponent ​是 ​HeroModule ​这个特性模块的一部分,它聚合了更多相互依赖的片段,包括 ​SharedModule​。试试下面这个导入了 ​HeroModule ​的测试配置:

beforeEach(async () => {
  const routerSpy = createRouterSpy();

  await TestBed
      .configureTestingModule({
        imports: [HeroModule],
        providers: [
          {provide: ActivatedRoute, useValue: activatedRoute},
          {provide: HeroService, useClass: TestHeroService},
          {provide: Router, useValue: routerSpy},
        ]
      })
      .compileComponents();
});

这样特别清爽。只有 ​providers ​里面的测试替身被保留。连 ​HeroDetailComponent ​声明都消失了。

事实上,如果你试图声明它,Angular 就会抛出一个错误,因为 ​HeroDetailComponent ​同时声明在了 ​HeroModule ​和 ​TestBed ​创建的 ​DynamicTestModule ​中。

如果模块中有很多共同依赖,并且该模块很小(这也是特性模块的应有形态),那么直接导入组件的特性模块可以成为配置这些测试的最佳方式。

改写组件的服务提供者

HeroDetailComponent ​提供自己的 ​HeroDetailService ​服务。

@Component({
  selector:    'app-hero-detail',
  templateUrl: './hero-detail.component.html',
  styleUrls:  ['./hero-detail.component.css' ],
  providers:  [ HeroDetailService ]
})
export class HeroDetailComponent implements OnInit {
  constructor(
    private heroDetailService: HeroDetailService,
    private route: ActivatedRoute,
    private router: Router) {
  }
}

在 ​TestBed.configureTestingModule​ 的 ​providers ​中 stub 伪造组件的 ​HeroDetailService ​是不可行的。这些是测试模块的提供者,而非组件的。组件级别的提供者应该在 fixture 级别的依赖注入器中进行准备。

Angular 会使用自己的注入器来创建这些组件,这个注入器是夹具的注入器的子注入器。它使用这个子注入器注册了该组件服务提供者(这里是 ​HeroDetailService​)。

测试没办法从测试夹具的注入器中获取子注入器中的服务,而 ​TestBed.configureTestingModule​ 也没法配置它们。

Angular 始终都在创建真实 ​HeroDetailService ​的实例。

如果 ​HeroDetailService ​向远程服务器发出自己的 XHR 请求,这些测试可能会失败或者超时。这个远程服务器可能根本不存在。
幸运的是,​HeroDetailService ​将远程数据访问的责任交给了注入进来的 ​HeroService​。
@Injectable()
export class HeroDetailService {
  constructor(private heroService: HeroService) {  }
/* . . . */
}
前面的测试配置使用 ​TestHeroService​ 替换了真实的 ​HeroService​,它拦截了发往服务器的请求,并伪造了服务器的响应。

如果你没有这么幸运怎么办?如果伪造 ​HeroService ​很难怎么办?如果 ​HeroDetailService ​自己发出服务器请求怎么办?

TestBed.overrideComponent​ 方法可以将组件的 ​providers ​替换为容易管理的测试替身,参阅下面的变体准备代码:

beforeEach(async () => {
  const routerSpy = createRouterSpy();

  await TestBed
      .configureTestingModule({
        imports: [HeroModule],
        providers: [
          {provide: ActivatedRoute, useValue: activatedRoute},
          {provide: Router, useValue: routerSpy},
        ]
      })

      // Override component's own provider
      .overrideComponent(
          HeroDetailComponent,
          {set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}})

      .compileComponents();
});

注意,​TestBed.configureTestingModule​ 不再提供(伪造的)​HeroService​,因为并不需要

overrideComponent 方法

注意这个 ​overrideComponent ​方法。

.overrideComponent(
    HeroDetailComponent,
    {set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}})

它接受两个参数:要改写的组件类型(​HeroDetailComponent​),以及用于改写的元数据对象。用于改写的元数据对象是一个泛型,其定义如下:

type MetadataOverride<T> = {
  add?: Partial<T>;
  remove?: Partial<T>;
  set?: Partial<T>;
};

元数据重载对象可以添加和删除元数据属性的项目,也可以彻底重设这些属性。这个例子重新设置了组件的 ​providers ​元数据。

这个类型参数 ​T​ 就是你传给 ​@Component​ 装饰器的元数据:

selector?: string;
template?: string;
templateUrl?: string;
providers?: any[];
…

提供 间谍桩 (HeroDetailServiceSpy)

这个例子把组件的 ​providers ​数组完全替换成了一个包含 ​HeroDetailServiceSpy ​的新数组。

HeroDetailServiceSpy ​是实际 ​HeroDetailService ​服务的桩版本,它伪造了该服务的所有必要特性。但它既不需要注入也不会委托给低层的 ​HeroService ​服务,因此不用为 ​HeroService ​提供测试替身。

通过对该服务的方法进行刺探,​HeroDetailComponent ​的关联测试将会对 ​HeroDetailService ​是否被调用过进行断言。因此,这个桩类会把它的方法实现为刺探方法:

class HeroDetailServiceSpy {
  testHero: Hero = {id: 42, name: 'Test Hero'};

  /* emit cloned test hero */
  getHero = jasmine.createSpy('getHero').and.callFake(
      () => asyncData(Object.assign({}, this.testHero)));

  /* emit clone of test hero, with changes merged in */
  saveHero = jasmine.createSpy('saveHero')
                 .and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero)));
}

改写测试

现在,测试程序可以通过操控这个 spy-stub 的 ​testHero​,直接控制组件的英雄,并确认那个服务方法被调用过。

let hdsSpy: HeroDetailServiceSpy;

beforeEach(async () => {
  await createComponent();
  // get the component's injected HeroDetailServiceSpy
  hdsSpy = fixture.debugElement.injector.get(HeroDetailService) as any;
});

it('should have called `getHero`', () => {
  expect(hdsSpy.getHero.calls.count())
    .withContext('getHero called once')
    .toBe(1, 'getHero called once');
});

it("should display stub hero's name", () => {
  expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});

it('should save stub hero change', fakeAsync(() => {
     const origName = hdsSpy.testHero.name;
     const newName = 'New Name';

     page.nameInput.value = newName;

     page.nameInput.dispatchEvent(new Event('input')); // tell Angular

     expect(component.hero.name)
      .withContext('component hero has new name')
      .toBe(newName);
     expect(hdsSpy.testHero.name)
      .withContext('service hero unchanged before save')
      .toBe(origName);

     click(page.saveBtn);
     expect(hdsSpy.saveHero.calls.count())
      .withContext('saveHero called once')
      .toBe(1);

     tick();  // wait for async save to complete
     expect(hdsSpy.testHero.name)
      .withContext('service hero has new name after save')
      .toBe(newName);
     expect(page.navigateSpy.calls.any())
      .withContext('router.navigate called')
      .toBe(true);
   }));

更多的改写

TestBed.overrideComponent​ 方法可以在相同或不同的组件中被反复调用。​TestBed ​还提供了类似的 ​overrideDirective​、​overrideModule ​和 ​overridePipe ​方法,用来深入并重载这些其它类的部件。


以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号