一文带你了解 Angular 单元测试

394 阅读32分钟

本文带您了解 Angular 中内置的测试框架的使用,相信通过本文的阅读您能够对于 Angular 的测试有一个全面的认识,为您之后的深入学习打下坚实的基础。

1. 测试的结构

  1. Unit Test
  2. Integration and Functional Testing
  3. Complete Application Test

2. 端到端的测试

83cc7ade-0fbd-458b-95e6-95404a4fa9a1.jpg

在端对端的测试中,一个应用程序中涉及的节点有:前端、网络服务器、数据库。

3. Angular 的集成测试示意图

daabc0ed-4f07-48dd-ba47-2e17aacccb1e.jpg

3.1 Angular 集成测试数据 mock

ea889ae2-27d3-4b53-bf75-171d06be0083.jpg

数据 mock 的四个层次:

  1. Dummies
  2. Stubs
  3. Spies
  4. True mocks

4. Angular 中的单元测试

  1. 独立测试 Isolated
  2. 集成测试 Integration
    • 浅集成测试
    • 深度集成测试

Angular 单元测试使用的工具有:

  • Karma (宿命 /ˈkɑːmə/)
  • Jasmine (茉莉花 /ˈdʒæzmɪn/)

其它的单元测试工具(除了我们需要使用的 Karma 和 Jasmine):

  • Jest
  • Mocha/Chai/etc /tʃaɪ/ chai 印度茶
  • Sinon 拉丁语,表示 except if
  • TestDouble
  • Wallaby /ˈwɒləbi/ 小型袋鼠
  • Cypress /ˈsaɪprəs/ 一种常青树
  • End to end tools

5. 拉去我们的学习代码

使用 git clone https://github.com/joeeames/PSAngularUnitTestingCourse.git ngUnitTestingDemo 拉去我们这篇文章的配合代码。拉取之后:code ngUnitTestingDemo && npm i

完成代码的拉取之后,在项目的 src/app 目录下创建名为 first-test.spec.ts 的文件。然后输入下面的代码:

describe('my first test', () => {
    let sut; // System Under Test (测试对象)

    beforeEach(() => {
        sut = {};
    });

    it('should be true if true', () => {
        // Arrange
        sut.a = false;
        // Act
        sut.a = true;
        // Assert
        expect(sut.a).toBe(true);
    });
});

虽然这段代码的作用就是为了让我们的测试跑起来,但是这段还是有一些需要说道说道的。首先,一个完整的测试文件的组成部分采用的是:describe -> beforeEach -> it 的搭配方案。其中 describe 统领全局,beforeEach 中完成对 公共资源 的初始化过程;最后在 it 中完成每一个细小的测试任务。

我们在 package.json 中增加一个 script 任务标签:"test": "ng test", 最后执行 yarn test 将测试跑起来。

这个仓库的代码将会帮助我们学习 单元测试 孤立测试 集成测试(深或者浅)

6. 如何写一个好的单元测试

一个好的单元测试是具有结构化的测试。即 structuring tests:

  1. 将所有的预条件和输入放在一个数组中。
  2. 测试的对象应该是 js 中的 object 或者 class.
  3. 对期待的结果进行断言。

6.1 两种测试思想 DRY v.s. DAMP

  1. DRY 的含义为 don't repeat yourself 即不要写重复的代码!
  2. DAMP 的含义为 repeat yourself if neccessary 即,必要的话可以写一些重复性代码。

6.2 好的测试本质上是一个完整的故事

  1. 你需要在一个 it 块中将此次测试以一个完整故事说明清楚。并且其他人不用在超出此 it 块的范围之外去理解这个测试。
  2. 一些小的技巧:将和测试本身关系不太大的 setup 逻辑提升到 beforeEach 中;将测试本身关系密切的 setup 逻辑放在 it 测试块中;只在 it 块中使用 Arrange Act Assert.

7. 对管道进行测试

我们在 src/app/strength 中创建名为 strength.pipe.spec.ts 的文件,用来测试名为 strength 的管道:touch src/app/strength/strength.pipe.spec.ts

image.png

该测试文件中的代码为:

import { StrengthPipe } from './strength.pipe';

describe('StrengthPipe', () => {
    it('should display waek if strength is 5', () => {
        let pipe = new StrengthPipe();

        expect(pipe.transform(5)).toEqual('5 (week)');
    })
})

注意上面的代码中,我们对管道的测试是将其当成一个普通类对待的,将其当成普通类的时候,我们测试的时候需要两个点:

  1. 我们需要注意由于 StrengthPipe 的构造函数中没有构造参数,所以我们实例化的时候也不需要传递任何参数,下面是名为 StrengthPipe 的管道中的代码:
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'strength'
})
export class StrengthPipe implements PipeTransform {
  transform(value: number): string {
    if(value < 10) {
      return value + " (weak)";
    } else if(value >= 10 && value < 20) {
      return value + " (strong)";
    } else {
      return value + " (unbelievable)";
    }
  }
}

  1. 我们测试的实际上是 class StrengthPipe 上的 transform 方法。最后使用 ctrl + c 停住刚才的测试,然后使用 yarn test 重启测试。

8. 测试服务类

在这个小节中,我们对 Angular 中的 service 进行测试,我们在 src/app 目录中创建名为 message.service.spec.ts 的文件作为对服务 message.service.ts 的测试。touch src/app/message.service.spec.ts 此文件的内容如下:

import { MessageService } from "./message.service";

describe('MessageService', () => {
    let service: MessageService;

    beforeEach(() => {
        service = new MessageService();
    })

    it('should have no messages to start', () => {
        expect(service.messages.length).toBe(0);
    })

    it('should add a message when add is called', () => {
        service.add('message 1');

        expect(service.messages.length).toBe(1);
    })
})

我们不难看出来,测试一个服务和测试一个管道是非常相似的,都是将其作为一个普通的类进行测试,测试的目标是其上的方法。下面是该服务的全部代码:

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

@Injectable()
export class MessageService {
  messages: string[] = [];

  add(message: string) {
    this.messages.push(message);
  }

  clear() {
    this.messages = [];
  }
}

9. 对组件进行测试

我们测试的组件名为 HeroesComponent 这个组件的代码如下所示:

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

import { Hero } from '../hero';
import { HeroService } from '../hero.service';

@Component({
  selector: 'app-heroes',
  templateUrl: './heroes.component.html',
  styleUrls: ['./heroes.component.css'],
})
export class HeroesComponent implements OnInit {
  heroes: Hero[];

  constructor(private heroService: HeroService) {}

  ngOnInit() {
    this.getHeroes();
  }

  getHeroes(): void {
    this.heroService.getHeroes().subscribe((heroes) => (this.heroes = heroes));
  }

  add(name: string): void {
    name = name.trim();
    var strength = 11;
    if (!name) {
      return;
    }
    this.heroService.addHero({ name, strength } as Hero).subscribe((hero) => {
      this.heroes.push(hero);
    });
  }

  delete(hero: Hero): void {
    this.heroes = this.heroes.filter((h) => h !== hero);
    this.heroService.deleteHero(hero).subscribe();
  }
}

src/app/heroes 这个目录中创建名为 heroes.components.spec.ts 的测试文件 touch src/app/heroes/heroes.components.spec.ts 。然后在其中键入以下代码:

import { HeroesComponent } from "./heroes.component";

describe('HeroComponents', () => {
    let component: HeroesComponent;
    let HEROES;
    let mockHeroService;

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        component = new HeroesComponent(mockHeroService);
    })

    describe('delete', () => {
        it('should remove the indicated hero from the heroes list', () => {
            component.heroes = HEROES;
            component.delete(HEROES[2]);
            expect(component.heroes.length).toBe(2);
        })
    })
})

上述代码结合被测试的组件代码可以看出:

  1. 我们测试组件,本质上还是将其作为一个类进行测量的,测试其上的一些方法。
  2. 既然将其作为类进行测试了,那么就需要对其进行实例化,观察 HeroesComponent 的构造函数发现其需要一个名为 HeroService 类型的服务注入。
  3. 我们使用的是 jasmine 框架上面的 createSpyObj 方法去模拟这个服务类,传递的构造参数是一个数组其中有三个元素,都是字符串,表示的是用 jasmine.createSpyObj 模拟的服务所具有的三个方法的名称。
  4. 注意在后续的测试中,我们没有使用到这个服务类的功能,但是作为测试组件的必经环节又必须注入此服务,这个时候我们采用的方式就是使用 jasmine 进行模拟。让我们看一下真正的服务类的代码:
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';

import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';

import { Hero } from './hero';
import { MessageService } from './message.service';

const httpOptions = {
  headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};

@Injectable()
export class HeroService {

  private heroesUrl = 'api/heroes';  // URL to web api

  constructor(
    private http: HttpClient,
    private messageService: MessageService) { }

  /** GET heroes from the server */
  getHeroes (): Observable<Hero[]> {
    return this.http.get<Hero[]>(this.heroesUrl)
      .pipe(
        tap(heroes => this.log(`fetched heroes`)),
        catchError(this.handleError('getHeroes', []))
      );
  }

  /** GET hero by id. Return `undefined` when id not found */
  getHeroNo404<Data>(id: number): Observable<Hero> {
    const url = `${this.heroesUrl}/?id=${id}`;
    return this.http.get<Hero[]>(url)
      .pipe(
        map(heroes => heroes[0]), // returns a {0|1} element array
        tap(h => {
          const outcome = h ? `fetched` : `did not find`;
          this.log(`${outcome} hero id=${id}`);
        }),
        catchError(this.handleError<Hero>(`getHero id=${id}`))
      );
  }

  /** GET hero by id. Will 404 if id not found */
  getHero(id: number): Observable<Hero> {
    const url = `${this.heroesUrl}/${id}`;
    return this.http.get<Hero>(url).pipe(
      tap(_ => this.log(`fetched hero id=${id}`)),
      catchError(this.handleError<Hero>(`getHero id=${id}`))
    );
  }

  /* GET heroes whose name contains search term */
  searchHeroes(term: string): Observable<Hero[]> {
    if (!term.trim()) {
      // if not search term, return empty hero array.
      return of([]);
    }
    return this.http.get<Hero[]>(`api/heroes/?name=${term}`).pipe(
      tap(_ => this.log(`found heroes matching "${term}"`)),
      catchError(this.handleError<Hero[]>('searchHeroes', []))
    );
  }

  //////// Save methods //////////

  /** POST: add a new hero to the server */
  addHero (hero: Hero): Observable<Hero> {
    return this.http.post<Hero>(this.heroesUrl, hero, httpOptions).pipe(
      tap((hero: Hero) => this.log(`added hero w/ id=${hero.id}`)),
      catchError(this.handleError<Hero>('addHero'))
    );
  }

  /** DELETE: delete the hero from the server */
  deleteHero (hero: Hero | number): Observable<Hero> {
    const id = typeof hero === 'number' ? hero : hero.id;
    const url = `${this.heroesUrl}/${id}`;

    return this.http.delete<Hero>(url, httpOptions).pipe(
      tap(_ => this.log(`deleted hero id=${id}`)),
      catchError(this.handleError<Hero>('deleteHero'))
    );
  }

  /** PUT: update the hero on the server */
  updateHero (hero: Hero): Observable<any> {
    return this.http.put(this.heroesUrl, hero, httpOptions).pipe(
      tap(_ => this.log(`updated hero id=${hero.id}`)),
      catchError(this.handleError<any>('updateHero'))
    );
  }

  /**
   * Handle Http operation that failed.
   * Let the app continue.
   * @param operation - name of the operation that failed
   * @param result - optional value to return as the observable result
   */
  private handleError<T> (operation = 'operation', result?: T) {
    return (error: any): Observable<T> => {

      // TODO: send the error to remote logging infrastructure
      console.error(error); // log to console instead

      // TODO: better job of transforming error for user consumption
      this.log(`${operation} failed: ${error.message}`);

      // Let the app keep running by returning an empty result.
      return of(result as T);
    };
  }

  /** Log a HeroService message with the MessageService */
  private log(message: string) {
    this.messageService.add('HeroService: ' + message);
  }
}

这下能够感受到 mock 的乐趣了吗?

实际上上面的测试没有办法通过,会报错,如下所示:

image.png

原因也很简单,就是我们忘记写 mockHeroService.deleteHero.and.returnValue(of(true)) 现在补充完整:

import { of } from "rxjs";
import { HeroesComponent } from "./heroes.component";

describe('HeroComponents', () => {
    let component: HeroesComponent;
    let HEROES;
    let mockHeroService;

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        component = new HeroesComponent(mockHeroService);
    })

    describe('delete', () => {
        it('should remove the indicated hero from the heroes list', () => {
            mockHeroService.deleteHero.and.returnValue(of(true));
            component.heroes = HEROES;
            component.delete(HEROES[2]);
            expect(component.heroes.length).toBe(2);
        })
    })
})

上面这段代码是使用Angular和Jasmine测试框架编写的,目的是对HeroesComponent组件中的delete方法进行单元测试。下面是对代码的逐句解释:

  1. import { of } from "rxjs";

    • 这行代码从rxjs库中导入of函数。of是一个创建Observable的函数,它可以接受一个或多个参数,并将这些参数按顺序发出。
  2. import { HeroesComponent } from "./heroes.component";

    • 这行代码从当前目录下的heroes.component文件中导入HeroesComponent组件。
  3. describe('HeroComponents', () => {

    • 这是一个Jasmine测试套件的定义,它描述了要测试的组件或功能,这里是HeroComponentsdescribe函数接受两个参数,第一个参数是测试套件的名称,第二个参数是一个函数,其中包含了所有的测试用例。
  4. let component: HeroesComponent;

    • 在测试套件的作用域内声明一个变量component,它的类型是HeroesComponent。这个变量将用于实例化HeroesComponent组件,以便进行测试。
  5. let HEROES;

    • 声明一个变量HEROES,用于存储英雄列表的示例数据。
  6. let mockHeroService;

    • 声明一个变量mockHeroService,用于存储模拟的英雄服务。
  7. beforeEach(() => {

    • beforeEach是一个Jasmine钩子,它在每个测试用例执行之前运行。这里用于设置每个测试用例的初始状态。
  8. HEROES = [ ... ];

    • 初始化HEROES变量,为其分配一个包含三个英雄对象的数组。
  9. mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

    • 使用Jasmine的createSpyObj函数创建一个模拟的英雄服务对象,这个对象包含getHeroesaddHerodeleteHero三个方法的间谍(spy)。
  10. component = new HeroesComponent(mockHeroService);

    • 使用模拟的英雄服务实例化HeroesComponent组件。
  11. describe('delete', () => {

    • 定义一个子测试套件,专门用于测试HeroesComponent组件中的delete方法。
  12. it('should remove the indicated hero from the heroes list', () => {

    • 使用it函数定义一个测试用例,这个测试用例验证delete方法是否正确地从英雄列表中移除了指定的英雄。
  13. mockHeroService.deleteHero.and.returnValue(of(true));

    • 设置模拟的英雄服务的deleteHero方法的返回值。当这个方法被调用时,它会返回一个Observable,该Observable发出一个true值。
  14. component.heroes = HEROES;

    • HEROES数组赋值给组件的heroes属性。
  15. component.delete(HEROES[2]);

    • 调用组件的delete方法,传入HEROES数组中的第三个英雄对象(索引为2)作为参数。
  16. expect(component.heroes.length).toBe(2);

    • 使用Jasmine的expect函数断言组件的heroes数组的长度是否为2。这是为了验证delete方法是否成功地从数组中移除了一个英雄对象。

10. xit 和 it

在测试框架中,itxit都用于定义测试用例,但两者在执行行为上有所不同。it是定义正常执行的测试用例,测试运行时会被执行。xit则是定义了一个被“忽略”的测试用例,它不会被执行。使用xit可以帮助暂时禁用某个测试用例,但保留其在测试文件中的位置,便于后续重新启用或参考。 在上述代码中,“should remove the indicated hero from the heroes list” 是一个正常执行的测试用例,而 “should call deleteHero” 则被标记为xit,表示这个测试用例当前不会被执行。

image.png

我们甚至还可以对调用函数时候传入的参数进行断言:

    describe('delete', () => {
        it('should remove the indicated hero from the heroes list', () => {
            mockHeroService.deleteHero.and.returnValue(of(true));
            component.heroes = HEROES;
            component.delete(HEROES[2]);
            expect(component.heroes.length).toBe(2);
        })

        xit('should call deteleHero', () => {
            mockHeroService.deleteHero.and.returnValue(of(true));
            component.heroes = HEROES;

            component.delete(HEROES[2]);

            // expect(mockHeroService.deleteHero).toHaveBeenCalled();
            expect(mockHeroService.deleteHero).toHaveBeenCalledWith(HEROES[2]);
        })
    })

11. Angular 中的浅测试和深测试

上节中所示的对于 HeroesComponent 组件的测试就是 浅测试,所谓浅测试指的就是只测试本组件中的属性和方法,实际上 HeroesComponent 组件的模板中还套用了一个子组件,如下所示:

<h2>My Heroes</h2>

<div>
  <label>Hero name:
    <input #heroName />
  </label>
  <!-- (click) passes input value to add() and then clears the input -->
  <button (click)="add(heroName.value); heroName.value=''">
    add
  </button>
</div>

<ul class="heroes">
  <li *ngFor="let hero of heroes">
    <app-hero [hero]="hero" (delete)="delete(hero)"></app-hero>
  </li>
</ul>

我们上述的组件测试从未涉及过 app-hero 子组件的测试,因此被称为浅测试

而与之对应的当然就是深测试了。所谓深测试,就是递归测试,不仅测试最外层组件,而是对其内部的组件也进行测试。关于这个话题我们下节在深入讨论。

12. Angular 中的深测试

本节将通过从最外层组件测试开始,在排除错误的过程中一步步的引入深测试。在正式开始之前需要介绍 @angular/core/testing 这个库中的 TestBed 对象,这个对象上有两个方法和这个小节息息相关:

  1. 用来模拟 NgModule 装饰器的 TestBed.configureTestingModule.
  2. 用来创建模拟 component 的 Test.createComponent.
  3. 除此之外需要额外介绍 TestBed.configureTestingModule 配置参数中的 schemas: [NO_ERRORS_SCHEMA],, 这个配置可以在我们初步学习,无法模拟组件模板中复杂结构的时候直接忽略错误,将测试成功跑起来。等到后面深入学习之后,能够对组件模板中的常见结构进行 mock 的时候,再将其关掉。

12.1

好的现在让我们开始测试最外层的组件。在 Angular 正常运行的过程中,组件的渲染一般由其所在的模块提供编译环境,在测试环境中,通过上面的代码也可以看出来,我们是将 component 当成一个 class 来测试的,所以其中的 html 的编译环境由谁来提供呢?

实际上,我们的 Angular 框架中提供了相应的模块 TestBed, 而 TestBed 则是从 @angular/core/testing 中导出的!如下所示:

import { TestBed } from "@angular/core/testing";
...
...
TestBed.configureTestingModule({
    declarations: [HeroesComponent, FakeHeroComponent],
    providers: [{
        provide: HeroService, useValue: mockHeroService,
    }]
});

使用 TestBed 中的配置方法进行配置的时机是在 beforeEach(() => {}) 中进行的!

通过上面的分析,我们知道,在外层组件中,镶嵌名为 app-hero 的子组件,那么这个子组件是需要体现在 TestBed.configureTestingModule 中的, 如何做呢?

实际上,我们可以做一个假的子组件,如下所示:

    @Component({
        selector: 'app-hero',
        template: '<div></div>',
    })
    class FakeHeroComponent {
        @Input() hero: Hero;
        // @Output()delete = new EventEmitter();
    }

通过上述代码的配置,我们可以在测试文件中现场造一个假的标签为 app-hero 的子组件,用来替代真的组件。

除了 declarations 中的假的子组件,我们还在配置对象中使用别名的方式模拟了 HeroService, 注意到使用 TestBed.configureTestingModule 的配置模拟的方式是可以注入模拟服务的,也就是说,我们不必像上面一样在实例化组件的时候手动注入模拟的服务了,取而代之的是另外一种方式:TestBed.createComponent 如下所示:

import { HeroesComponent } from "./heroes.component";
import { Component, Input } from "@angular/core";
import { Hero } from "../hero";
import { HeroService } from "../hero.service";
import { TestBed } from "@angular/core/testing"

describe('HeroComponents', () => {
    let HEROES;
    let mockHeroService;
    let fixture;

    @Component({
        selector: 'app-hero',
        template: '<div></div>',
    })
    class FakeHeroComponent {
        @Input() hero: Hero;
    }

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        TestBed.configureTestingModule({
            declarations: [HeroesComponent, FakeHeroComponent],
            providers: [{
                provide: HeroService, useValue: mockHeroService,
            }]
        });

        fixture = TestBed.createComponent(HeroesComponent);

    })

    describe('delete', () => {
        it('should be true', () => {
            expect(true).toBe(true);
        })
    })
})

在 before Each 中,我们通过 TestBed 中的 createComponent 方法模拟了 HeroesComponent 这个组件,并将其保存在全局变量 fixture 中,而 fixture 本身的含义正是 固定装置

12.2

你可能会想,我们为什么非要做一个假的子组件呢,既然现在可以模拟 module 中的配置行为了,我直接将真的子组件声明不就可以了吗?例如:

import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";

describe('HeroComponents', () => {
    let HEROES;
    let mockHeroService;
    let fixture;

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        TestBed.configureTestingModule({
            declarations: [HeroesComponent, HeroComponent],
            providers: [{
                provide: HeroService, useValue: mockHeroService,
            }]
        });

        fixture = TestBed.createComponent(HeroesComponent);

    })

    describe('delete', () => {
        it('should be true', () => {
            expect(true).toBe(true);
        })
    })
})

这肯定是可以的!但是,有个问题就是,比起 mock 的子组件,如果要使用真正的组件,那么保不齐子组件中还会不会有孙子组件(最常见的情况就是子组件中的哪些和路由跳转相关的 directives 如 routerLink 等)?如果有的话那岂不是又报错了?

image.png

为了解决这种无休止的嵌套问题,我们可以在配置的时候设置遇到不认识的东西不要报错,即:

import { NO_ERRORS_SCHEMA } from "@angular/core";
...
...
  schemas: [NO_ERRORS_SCHEMA],
...

这样做依然有风险,那就是如果编码中真的有错误,比如写了一个<btuton></btuton>这样的标签,测试过程中真的不会报错的。

12.3

fixture 的初始化。使用 TestBed 上面的 createComponent 创建新的组件之后,这个组件在测试环境中的行为和在正常开发环境中不同,具体表现在不会自动的调用组件的生命周期函数。也就是说你必须手动的调用 ngOnInit,实际上,我们不会去直接调用这个钩子函数的,取而代之的是如下的做法:fixture.detectChanges(); 此时整个测试文件的代码就如下所示了:

import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";

describe('HeroComponents', () => {
    let HEROES;
    let mockHeroService;
    let fixture;

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        TestBed.configureTestingModule({
            declarations: [HeroesComponent, HeroComponent],
            providers: [{
                provide: HeroService, useValue: mockHeroService,
            }],
            schemas: [NO_ERRORS_SCHEMA],
        });

        fixture = TestBed.createComponent(HeroesComponent);
        mockHeroService.getHeroes.and.returnValue(of(HEROES));
        fixture.detectChanges();

    })

    describe('delete', () => {
        it('should be true', () => {
            expect(true).toBe(true);
        })
    })
})

由于 HeroesComponent 组件在 ngOnInit 钩子中调用了 HeroService 上面的方法,所以我们必须使用下面的代码以防止模拟的服务类报错:

ngOnInit() {
this.getHeroes();
}
//
//
fixture = TestBed.createComponent(HeroesComponent);
mockHeroService.getHeroes.and.returnValue(of(HEROES));
fixture.detectChanges();

13. 测试的时候不要使用 sourceMap

将我们的 script 修改成如下的样子:

"test": "ng test --source-map=false"

14. 对组件 component 模板的测试

  1. 在上面的内容中,我们通过TestBed.configTestingModule配置了组件所需的测试环境。
  2. 为了解决模板中缺少实际内容而导致的错误,我们使用了schemas来阻止错误的弹出。
  3. 尽管如此,我们还没有对组件的内部功能进行实质性的测试。
  4. 下一步,我们将展示如何修改组件的属性,并且验证这些属性的更改是否能够正确地反映在组件的视图上。
  5. 为了进行这项测试,我们需要找到组件在视图中对应的标签,并针对这些标签进行测试。
  • 如何拿到创建的 component 的实例? 注意到我们的 fixture 的值为 TestBed.createComponent(HeroesComponent), 为了拿到创建的组件实例,我们可以对 fixture 进行类型约束:let fixture: ComponentFixture<HeroesComponent>; 其中:import { ComponentFixture } from "@angular/core/testing"; 这样一来键入 fixture 的时候,编译器会自动弹出名为 componentInstance 的属性。这就是我们所需要的实例。

  • 如何修改实例上的属性? 修改实例属性通过两步走的方式:直接修改 + 更新,如下代码所示:

const heroes = [{ id: 1, name: 'SuperDude', strength: 3 }];
fixture.componentInstance.heroes = heroes;
fixture.detectChanges(); // 改变组件值之后不要忘记执行此句进行更新
  • 如何测试 component 上面的属性?直接测试就可以了,如下所示
expect(fixture.componentInstance.heroes[0].name).toEqual(heroes[0].name);
  • 如何获取 fixture 的视图层中的标签对象? 我们使用 fixture 上除了 componentInstance 的另外一个属性 nativeElement 获取此 fixture 整个的视图层。nativeElement 可以看成是这个组件渲染完成之后的最外层的 container, 例如我们可以用它结合 query 类的方法获取 dom 树上的其它 tag:
fixture.nativeElement.querySelector('a')

总结一下,我们想要对 component 中的内容进行测试主要用到的是 fixture 上面的两个属性,一个是 componentInstance 表示的是 class 层,一个是 nativeElement 表示的是 template 层。

全部的测试代码为:

import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";

describe('HeroComponents', () => {
    let HEROES;
    let mockHeroService;
    let fixture: ComponentFixture<HeroesComponent>;

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        TestBed.configureTestingModule({
            declarations: [HeroesComponent, HeroComponent],
            providers: [{
                provide: HeroService, useValue: mockHeroService,
            }],
            schemas: [NO_ERRORS_SCHEMA],
        });

        fixture = TestBed.createComponent(HeroesComponent);
        mockHeroService.getHeroes.and.returnValue(of(HEROES));
        fixture.detectChanges();

    })

    it('should have the correct hero', () => {
        const heroes = [{ id: 1, name: 'SuperDude', strength: 3 }];
        fixture.componentInstance.heroes = heroes;
        fixture.detectChanges(); // 改变组件值之后不要忘记执行此句进行更新
        expect(fixture.componentInstance.heroes[0].name).toEqual(heroes[0].name);
    })

    it('should have the correct hero name in an anchor tag', () => {
        const heroes = [{ id: 1, name: 'SuperDude', strength: 3 }];
        fixture.componentInstance.heroes = heroes;
        fixture.detectChanges();
        expect(fixture.nativeElement.querySelector('a').textContent).toContain(`${heroes[0].id} ${heroes[0].name}`);
    })
})

被测试的组件为:

<a routerLink="/detail/{{hero.id}}">
  <span class="badge">{{hero.id}}</span> {{hero.name}}
</a>
<button class="delete" (click)="onDeleteClick($event)">x</button>

补充一点:除了使用 nativeElement 我们还可以使用 debugElement, 下面的代码具有相同的功效:

import { By } from "@angular/platform-browser";
fixture.debugElement.query(By.css('a')).nativeElement.textContent;

fixture.nativeElement.querySelector('a').textContent;

这里使用 query(By.css('*')) 的组合,但是查询得到之后需要用一层 nativeElement.

it('should have the correct hero name in an anchor tag', () => {
    const heroes = [{ id: 1, name: 'SuperDude', strength: 3 }];
    fixture.componentInstance.heroes = heroes;
    fixture.detectChanges();
    // expect(fixture.nativeElement.querySelector('a').textContent).toContain(`${heroes[0].id} ${heroes[0].name}`);
    expect(fixture.debugElement.query(By.css('a')).nativeElement.textContent).toBe(`${heroes[0].id} ${heroes[0].name}`);
})

值得一说的是,debugElement 是可以指回到 componentInstance 的

console.log('are they equal?', fixture.debugElement.componentInstance === fixture.componentInstance); // true

15. 测试 component 的时候 mock 服务

让我们先看一下 component 的内容:

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

import { Hero } from '../hero';
import { HeroService } from '../hero.service';

@Component({
  selector: 'app-heroes',
  templateUrl: './heroes.component.html',
  styleUrls: ['./heroes.component.css'],
})
export class HeroesComponent implements OnInit {
  heroes: Hero[];

  constructor(private heroService: HeroService) {}

  ngOnInit() {
    this.getHeroes();
  }

  getHeroes(): void {
    this.heroService.getHeroes().subscribe((heroes) => (this.heroes = heroes));
  }

  add(name: string): void {
    name = name.trim();
    var strength = 11;
    if (!name) {
      return;
    }
    this.heroService.addHero({ name, strength } as Hero).subscribe((hero) => {
      this.heroes.push(hero);
    });
  }

  delete(hero: Hero): void {
    this.heroes = this.heroes.filter((h) => h !== hero);
    this.heroService.deleteHero(hero).subscribe();
  }
}

不难看出来,在 HeroesComponent 组件的构造函数中,需要注入名为 HeroService 的服务。之前没有提供这个服务的注入也没有报错是因为我们使用了 schemas 对错误进行了压制。下面我们就 mock 这个 HeroService 服务,完成更加真实的测试环境。这里提前说一下,虽然我们对 Hero Service 进行 mock 但是 schemas 还是不能移除,原因很简单,那就是还需要用其完成对其它错误进行压制。

我们观察到 component 中使用了 HeroService 中的三个方法打,分别为:getHeroes addHero deleteHero. 所以 mock 这个服务本质上就是 mock 一个拥有这三个方法的普通的 JavaScript 对象。

模拟普通对象我们使用的是 jasmine 这个内置对象给的 createSpyObj 方法,如下代码所示:

// 创建
mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);  // 这样就模拟了三个方法
// 配置
TestBed.configureTestingModule({
    declarations: [HeroesComponent, HeroComponent],
    providers: [{
        provide: HeroService, useValue: mockHeroService,
    }],
    schemas: [NO_ERRORS_SCHEMA],
});

mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']); 只能保证 mockHeroService 中有这三个方法,但是却没有指定这些方法的返回值,所以我们需要使用 mockHeroService.getHeroes.and.returnValue(of(HEROES)); 这样的方式指定 mock 方法的返回值是什么。 完整的测试代码如下所示:

import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
import { By } from "@angular/platform-browser";

describe('HeroComponents', () => {
    let HEROES;
    let mockHeroService;
    let fixture: ComponentFixture<HeroesComponent>;

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        TestBed.configureTestingModule({
            declarations: [HeroesComponent, HeroComponent],
            providers: [{
                provide: HeroService, useValue: mockHeroService,
            }],
            schemas: [NO_ERRORS_SCHEMA],
        });

        fixture = TestBed.createComponent(HeroesComponent);
    })

    it('should set heroes correctly from the service', () => {
        mockHeroService.getHeroes.and.returnValue(of(HEROES));
        fixture.detectChanges();
        expect(fixture.componentInstance.heroes.length).toBe(3);
    })

    it('should create one li for each hero', () => {
        mockHeroService.getHeroes.and.returnValue(of(HEROES));
        fixture.detectChanges();
        expect(fixture.debugElement.queryAll(By.css('li')).length).toBe(3);
    })
})

让我们来简单总结一下:

  1. TestBed -- 这个是测试 component 的基础
  2. Fixtures -- 子组件
  3. DebugElements -- 除了 nativeElement 之外测试视图层的另外一个对象
  4. Child directives -- 子组件上的指令也需要模拟,如果能够模拟那么我们就无需在使用 schemas 来压制错误了。而这对应的则是 Deep integration tests.

16. 使用 By.directive 找到子组件

站在 Angular 的测试角度,子组件可以看成是父组件的一种 directive, 因此我们可以使用 By.directive 方法获取子组件。这可能看上去有些奇怪,但是事实就是这样的。

it('should render each hero as a HeroComponent', () => {
    mockHeroService.getHeroes.and.returnValue(of(HEROES));
    fixture.detectChanges();
    const heroComponentDEs = fixture.debugElement.queryAll(By.directive(HeroComponent));
    expect(heroComponentDEs.length).toEqual(3);
    expect((heroComponentDEs[0].componentInstance as HeroComponent).hero.name).toBe('SpiderDude');
})

注意,上述的测试和查询 li 元素的个数这种做法还不一样。上述代码中,我们断言的是:子组件 HeroComponent 渲染了三次,因为 HEROES 的长度为 3

for 循环和断言 -- loop and assertion

如下代码所示,对于列表的测试我们可以将 for 循环和断言结合起来:

it('should render each hero as a HeroComponent', () => {
    mockHeroService.getHeroes.and.returnValue(of(HEROES));
    fixture.detectChanges();
    const heroComponentDEs = fixture.debugElement.queryAll(By.directive(HeroComponent));
    expect(heroComponentDEs.length).toEqual(3);

    for (let i = 0; i < HEROES.length; i++) {
        expect((<HeroComponent>heroComponentDEs[i].componentInstance).hero.name).toBe(HEROES[i].name);
    }
})

17. 测试网络通信服务 -- Service integration tests

现在将注意力转到子组件 HeroComponent 中,使用 touch src/app/hero.service.spec.ts 创建测试文件。

先观察测试对象 src/app/hero.service.ts 中的内容:

import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';

import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';

import { Hero } from './hero';
import { MessageService } from './message.service';

const httpOptions = {
  headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};

@Injectable()
export class HeroService {

  private heroesUrl = 'api/heroes';  // URL to web api

  constructor(
    private http: HttpClient,
    private messageService: MessageService) { }

  /** GET heroes from the server */
  getHeroes (): Observable<Hero[]> {
    return this.http.get<Hero[]>(this.heroesUrl)
      .pipe(
        tap(heroes => this.log(`fetched heroes`)),
        catchError(this.handleError('getHeroes', []))
      );
  }

  /** GET hero by id. Return `undefined` when id not found */
  getHeroNo404<Data>(id: number): Observable<Hero> {
    const url = `${this.heroesUrl}/?id=${id}`;
    return this.http.get<Hero[]>(url)
      .pipe(
        map(heroes => heroes[0]), // returns a {0|1} element array
        tap(h => {
          const outcome = h ? `fetched` : `did not find`;
          this.log(`${outcome} hero id=${id}`);
        }),
        catchError(this.handleError<Hero>(`getHero id=${id}`))
      );
  }

  /** GET hero by id. Will 404 if id not found */
  getHero(id: number): Observable<Hero> {
    const url = `${this.heroesUrl}/${id}`;
    return this.http.get<Hero>(url).pipe(
      tap(_ => this.log(`fetched hero id=${id}`)),
      catchError(this.handleError<Hero>(`getHero id=${id}`))
    );
  }

  /* GET heroes whose name contains search term */
  searchHeroes(term: string): Observable<Hero[]> {
    if (!term.trim()) {
      // if not search term, return empty hero array.
      return of([]);
    }
    return this.http.get<Hero[]>(`api/heroes/?name=${term}`).pipe(
      tap(_ => this.log(`found heroes matching "${term}"`)),
      catchError(this.handleError<Hero[]>('searchHeroes', []))
    );
  }

  //////// Save methods //////////

  /** POST: add a new hero to the server */
  addHero (hero: Hero): Observable<Hero> {
    return this.http.post<Hero>(this.heroesUrl, hero, httpOptions).pipe(
      tap((hero: Hero) => this.log(`added hero w/ id=${hero.id}`)),
      catchError(this.handleError<Hero>('addHero'))
    );
  }

  /** DELETE: delete the hero from the server */
  deleteHero (hero: Hero | number): Observable<Hero> {
    const id = typeof hero === 'number' ? hero : hero.id;
    const url = `${this.heroesUrl}/${id}`;

    return this.http.delete<Hero>(url, httpOptions).pipe(
      tap(_ => this.log(`deleted hero id=${id}`)),
      catchError(this.handleError<Hero>('deleteHero'))
    );
  }

  /** PUT: update the hero on the server */
  updateHero (hero: Hero): Observable<any> {
    return this.http.put(this.heroesUrl, hero, httpOptions).pipe(
      tap(_ => this.log(`updated hero id=${hero.id}`)),
      catchError(this.handleError<any>('updateHero'))
    );
  }

  /**
   * Handle Http operation that failed.
   * Let the app continue.
   * @param operation - name of the operation that failed
   * @param result - optional value to return as the observable result
   */
  private handleError<T> (operation = 'operation', result?: T) {
    return (error: any): Observable<T> => {

      // TODO: send the error to remote logging infrastructure
      console.error(error); // log to console instead

      // TODO: better job of transforming error for user consumption
      this.log(`${operation} failed: ${error.message}`);

      // Let the app keep running by returning an empty result.
      return of(result as T);
    };
  }

  /** Log a HeroService message with the MessageService */
  private log(message: string) {
    this.messageService.add('HeroService: ' + message);
  }
}

这个自定义的服务的构造函数为:

  constructor(
    private http: HttpClient,
    private messageService: MessageService) { }

为了测试它,我们必须对 HttpClient 和 MessageService 进行 mock. 对于这两个,我们采用分而治之的策略,对于 HttpClient 我们使用 Angular 框架中提供的现成的服务;对于 后者我们使用 jasmine 中的 createSpyObj 进行模拟。

根据上面的分析我们可以得到一个基本的测试内容:

import { TestBed } from "@angular/core/testing";
import { HeroService } from "./hero.service";
import { MessageService } from "./message.service";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";

describe('HeroService', () => {
    let mockMessageService;
    let service;
    let httpTestingController: HttpTestingController;

    beforeEach(() => {
        mockMessageService = jasmine.createSpyObj(['add']);

        TestBed.configureTestingModule({
            imports: [HttpClientTestingModule],
            providers: [
                HeroService,
                { provide: MessageService, useValue: mockMessageService },
            ]
        })

        // 使用 TestBed.get 方法获取 HttpTestingController 和 HeroService 的实例。
        httpTestingController = TestBed.get(HttpTestingController);
        service = TestBed.get(HeroService);
    })

    describe('getHero', () => {
        it('should call get with the correct URL', () => {
            service.getHero(4).subscribe(); // service.getHero(4).subscribe() 发起一个 HTTP GET 请求,模拟获取 ID 为 4 的英雄。

            const req = httpTestingController.expectOne('api/heroes/4'); // 使用 httpTestingController.expectOne('api/heroes/4') 捕获发出的请求,并验证请求的 URL 是否正确。
            req.flush({ id: 4, name: 'SuperDude', strength: 100 }); // 使用 req.flush 方法模拟服务器响应,返回一个英雄对象。
            httpTestingController.verify(); // 使用 httpTestingController.verify 方法验证没有未处理的请求,确保所有预期的请求都已经被处理。
        });
    })
})

上面的代码是一个 Angular 服务的单元测试示例,具体来说是针对 HeroService 的测试。下面是对每一行代码的详细解释:

  1. 导入依赖
import { TestBed } from "@angular/core/testing";
import { HeroService } from "./hero.service";
import { MessageService } from "./message.service";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
  • TestBed 是 Angular 测试的核心工具,用于配置和初始化测试环境。
  • HeroService 是我们要测试的服务。
  • MessageServiceHeroService 可能依赖的另一个服务,这里我们使用 Jasmine 的 createSpyObj 方法来创建一个它的模拟对象。
  • HttpClientTestingModuleHttpTestingController 用于模拟 HTTP 请求和响应。
  1. 测试配置
describe('HeroService', () => {
    let mockMessageService;
    let service;
    let httpTestingController: HttpTestingController;
  • 使用 describe 函数定义一个测试套件,用于测试 HeroService
  • mockMessageService 用于存储 MessageService 的模拟对象。
  • service 用于存储 HeroService 的实例。
  • httpTestingController 用于模拟 HTTP 请求和响应。
  1. 初始化测试环境
beforeEach(() => {
    mockMessageService = jasmine.createSpyObj(['add']);

    TestBed.configureTestingModule({
        imports: [HttpClientTestingModule],
        providers: [
            HeroService,
            { provide: MessageService, useValue: mockMessageService },
        ]
    })

    httpTestingController = TestBed.get(HttpTestingController);
    service = TestBed.get(HeroService);
})
  • 在每个测试之前,使用 beforeEach 函数初始化测试环境。
  • 使用 Jasmine 的 createSpyObj 方法创建一个 MessageService 的模拟对象,并指定要模拟的方法(这里是 add 方法)。
  • 使用 TestBed.configureTestingModule 方法配置测试模块,导入 HttpClientTestingModule 以模拟 HTTP 请求,并提供 HeroService 和模拟的 MessageService
  • 使用 TestBed.get 方法获取 HttpTestingControllerHeroService 的实例。
  1. 定义测试
describe('getHero', () => {
    it('should call get with the correct URL', () => {
        service.getHero(4).subscribe();

        const req = httpTestingController.expectOne('api/heroes/4');
        req.flush({ id: 4, name: 'SuperDude', strength: 100 });
        httpTestingController.verify();
    });
})
  • 使用 describe 函数定义一个子测试套件,用于测试 HeroServicegetHero 方法。
  • 使用 it 函数定义一个测试用例,描述测试的预期行为。
  • 调用 service.getHero(4).subscribe() 发起一个 HTTP GET 请求,模拟获取 ID 为 4 的英雄。
  • 使用 httpTestingController.expectOne('api/heroes/4') 捕获发出的请求,并验证请求的 URL 是否正确。
  • 使用 req.flush 方法模拟服务器响应,返回一个英雄对象。
  • 使用 httpTestingController.verify 方法验证没有未处理的请求,确保所有预期的请求都已经被处理。

18. 测试 component template 的点击事件

使用 touch src/app/heroes/heroes.component.deep.spec.ts 创建新的测试文件。

然后观察 HeroComponent 上面的点击事件的回调函数:

// @ts-nocheck
import { Component, Input, Output, EventEmitter } from "@angular/core";
import { Hero } from "../hero";

@Component({
  selector: "app-hero",
  templateUrl: "./hero.component.html",
  styleUrls: ["./hero.component.css"],
})
export class HeroComponent {
  @Input() hero: Hero;
  @Output() delete = new EventEmitter();

  onDeleteClick($event): void {
    $event.stopPropagation();
    this.delete.next();
  }
}

这里着重强调一下 $event.stopPropagation();

最后搭建测试的基本框架:

import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
import { By } from "@angular/platform-browser";

describe('HeroComponents', () => {
    let HEROES;
    let mockHeroService;
    let fixture: ComponentFixture<HeroesComponent>;

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        TestBed.configureTestingModule({
            declarations: [HeroesComponent, HeroComponent],
            providers: [{
                provide: HeroService, useValue: mockHeroService,
            }],
            schemas: [NO_ERRORS_SCHEMA],
        });

        fixture = TestBed.createComponent(HeroesComponent);
    })

    it(`should call heroService.deleteHero when the Hero Component's delete button is clicked`, () => {
        mockHeroService.getHeroes.and.returnValue(of(HEROES));

        fixture.detectChanges();

        const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
    })
})

接下来看我们如何触发 HeroComponent template 上面的点击事件:

it(`should call heroService.deleteHero when the Hero Component's delete button is clicked`, () => {
    mockHeroService.getHeroes.and.returnValue(of(HEROES));

    fixture.detectChanges();

    const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
    spyOn(fixture.componentInstance, 'delete'); // 将 delete 方法转换为 spy

    heroComponents[0].query(By.css('button')).triggerEventHandler('click', { stopPropagation: () => { } });

    // expect(fixture.componentInstance.delete).toHaveBeenCalled();
    expect(fixture.componentInstance.delete).toHaveBeenCalledWith(HEROES[0]);
})

测试原理为:找到子组件上面的 button 按钮然后使用 triggerEventHandler 的方式触发 click 事件,此时子组件的 onDeleteClick 中会调用 this.delete.next(); 通过 <app-hero [hero]="hero" (delete)="delete(hero)"></app-hero> 调用 HeroesComponent 上面的 delete 方法。于是我们只需要检查 HeroesComponent 上面的 delete 方法有没有被调用以及调用的时候的入参是什么就可以了。

注意点 1: triggerEventHandler('click', { stopPropagation: () => { } }); 传递的第二个参数实际上是在模拟自动生成的事件对象 event$ 因为 onDeleteClick($event) 需要此形参。

注意点2:我们需要 spyOn(fixture.componentInstance, 'delete'); 将 delete 方法转换为 spy 类型的,这样后面的 expect 才会生效,倘若没有这一步,则会报错: Error: <toHaveBeenCalledWith> : Expected a spy, but got Function.

上面是通过 triggerEventHandler 在视图层发起点击事件,我们现在切换到 class 层来触发点击事件。

it(`should call heroService.deleteHero when the Hero Component's delete button is clicked`, () => {
    mockHeroService.getHeroes.and.returnValue(of(HEROES));

    fixture.detectChanges();

    const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
    spyOn(fixture.componentInstance, 'delete'); // 将 delete 方法转换为 spy

    // heroComponents[0].query(By.css('button')).triggerEventHandler('click', { stopPropagation: () => { } });
    (<HeroComponent>heroComponents[0].componentInstance).delete.emit(undefined);
    (<HeroComponent>heroComponents[0].componentInstance).delete.next(undefined);

    // expect(fixture.componentInstance.delete).toHaveBeenCalled();
    expect(fixture.componentInstance.delete).toHaveBeenCalledWith(HEROES[0]);
})

不论用的是 emit 还是 next 都可以成功的通过测试。那么这样做有什么意义呢?通过 view 或者 class 触发 click 事件的不同之处或者说后者有什么优势呢?

后者的优势在于你不用 mock 并传递一个 event$ 了。

除了上面的做法,你还可以直接使用子组件的实例触发 click 的回调函数链: heroComponents[0].triggerEventHandler('delete', null);

现在 deep.spec.ts 的内容为:

import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
import { By } from "@angular/platform-browser";

describe('HeroComponents', () => {
    let HEROES;
    let mockHeroService;
    let fixture: ComponentFixture<HeroesComponent>;

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        TestBed.configureTestingModule({
            declarations: [HeroesComponent, HeroComponent],
            providers: [{
                provide: HeroService, useValue: mockHeroService,
            }],
            schemas: [NO_ERRORS_SCHEMA],
        });

        fixture = TestBed.createComponent(HeroesComponent);
    })

    it(`should call heroService.deleteHero when the Hero Component's delete button is clicked`, () => {
        mockHeroService.getHeroes.and.returnValue(of(HEROES));

        fixture.detectChanges();

        const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
        spyOn(fixture.componentInstance, 'delete'); // 将 delete 方法转换为 spy

        // heroComponents[0].query(By.css('button')).triggerEventHandler('click', { stopPropagation: () => { } });
        // (<HeroComponent>heroComponents[0].componentInstance).delete.emit(undefined);
        // (<HeroComponent>heroComponents[0].componentInstance).delete.next(undefined);
        heroComponents[0].triggerEventHandler('delete', null);

        // expect(fixture.componentInstance.delete).toHaveBeenCalled();
        expect(fixture.componentInstance.delete).toHaveBeenCalledWith(HEROES[0]);
    })
})

19. 测试 component template 点击事件触发的网络数据通信结果

在本小节中,我们的测试流程为:点击添加按钮,然后会在页面上多出来一个 HeroComponent 子组件,并且其中的内容满足添加的时候传递的参数。我们仍然在 heroes.component.deep.spec.ts 中完成测试:

it('should add a new hero to the hero list when the add button is clicked', () => {
    // 模拟 heroService 服务的 getHeroes 方法的返回值
    mockHeroService.getHeroes.and.returnValue(of(HEROES));
    let originLen = HEROES.length;
    // 初始化组件
    fixture.detectChanges();
    const name = "Mr. Ice";
    // 模拟 heroService 服务的 addHero 方法的返回值
    mockHeroService.addHero.and.returnValue(of({ id: 5, name, strength: 4 }));
    // 找到添加按钮和输入框,在输入框中填入值之后点击添加按钮
    const inputElement = fixture.debugElement.query(By.css('input')).nativeElement;
    const addButton = fixture.debugElement.queryAll(By.css('button'))[0];

    inputElement.value = name;
    addButton.triggerEventHandler('click', null);
    // 添加之后强制刷新此组件
    fixture.detectChanges();
    // 检查视图,进行断言
    const heroText = fixture.debugElement.query(By.css('ul')).nativeElement.textContent;
    expect(heroText).toContain(name);
    // 通过子组件实例化的个数进行判断
    const len = fixture.debugElement.queryAll(By.directive(HeroComponent)).length;
    expect(len).toBe(++originLen); // 这里不能用 HEROES.length + 1 因为 HEROES 已经变化了
})

上面的代码就是测试此流程的测试用例。

补充一个测试原则:永远不要测试所使用的框架,并一直假定其没有任何缺陷,只测试自己写的代码!

20. 对路由进行测试

之前的内容中,我们总是使用 schemas 对路由产生的错误进行压制,也就是遇到了注入 routerLink 这类的指令,就算不认识页不会报错。在本节中,着重解决此问题,看看我们是如何在不使用 schemas 的前提之下 mock 相关的路由指令及相关内容的。

本节分成两个部分,第一个部分解决服务 ActivatedRoute, 因为只有这个服务被注入到 component 中的时候服务中才可以使用其实例获取路由上携带的数据:const id = +this.route.snapshot.paramMap.get('id');

第二个部分解决和路由跳转相关的指令,如 routerLink 等。

关于 ActivatedRoute 的 mock

首先,我们使用 touch src/app/hero-detail/hero-detail.component.spec.ts 创建测试文件。

然后这之前完全相同,我们总是先观察我们要测试的对象,在这里,组件 HeroDetailComponent 的内容为:

import { Component, OnInit, Input } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';

import { Hero } from '../hero';
import { HeroService } from '../hero.service';

@Component({
  selector: 'app-hero-detail',
  templateUrl: './hero-detail.component.html',
  styleUrls: ['./hero-detail.component.css'],
})
export class HeroDetailComponent implements OnInit {
  @Input() hero: Hero;

  constructor(
    private route: ActivatedRoute,
    private heroService: HeroService,
    private location: Location
  ) {}

  ngOnInit(): void {
    this.getHero();
  }

  getHero(): void {
    const id = +this.route.snapshot.paramMap.get('id');
    this.heroService.getHero(id).subscribe((hero) => (this.hero = hero));
  }

  goBack(): void {
    this.location.back();
  }

  save(): void {
    this.heroService.updateHero(this.hero).subscribe(() => this.goBack());
  }
}

不难看出来,在此组件初始化的时候需要注入三个对象,它们是 ActivatedRoute HeroService Location. 我们本节测试的对象是 ActivatedRoute, 对于其它的两个服务实例我们通过 jasmine 的 createSpyObj 方法进行模拟即可。更加具体一些,HeroService 上面需要模拟的方法有 getHero 和 updateHero, 而 Location 服务中用到的方法则为 back.

根据上述信息,我们的测试代码初步可以写成如下的形式:

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroDetailComponent } from "./hero-detail.component";
import { ActivatedRoute } from "@angular/router";
import { HeroService } from "../hero.service";

describe('test on detail hero component', () => {
    let fixture: ComponentFixture<HeroDetailComponent>;
    let mockActivatedRoute, mockHeroService, mockLocation;

    beforeEach(()=>{
        mockActivatedRoute = {
            snapShot: {
                paramMap: {
                    get: () => '3'
                }
            }
        };

        mockHeroService = jasmine.createSpyObj(['getHero', 'updateHero']);

        mockLocation = jasmine.createSpyObj(['back']);

        TestBed.configureTestingModule({
            declarations: [HeroDetailComponent],
            providers: [
                {provide: ActivatedRoute, useValue: mockActivatedRoute},
                {provide: HeroService, useValue: mockHeroService},
                {provide: Location, useValue: mockLocation},
            ]
        })

        fixture = TestBed.createComponent(HeroDetailComponent);
    })
})

HeroDetailComponent 组件的构造函数中就已经调用了 heroService 上面的 getHero 方法。

  ngOnInit(): void {
    this.getHero();
  }

  getHero(): void {
    const id = +this.route.snapshot.paramMap.get('id');
    this.heroService.getHero(id).subscribe((hero) => (this.hero = hero));
  }

因此,我们必须在调用 fixture.detectChanges(); 的前面规定这个方法的返回值。因此,将上面的代码补充为:

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroDetailComponent } from "./hero-detail.component";
import { ActivatedRoute } from "@angular/router";
import { HeroService } from "../hero.service";
import { of } from "rxjs";

describe('test on detail hero component', () => {
    let fixture: ComponentFixture<HeroDetailComponent>;
    let mockActivatedRoute, mockHeroService, mockLocation;

    beforeEach(() => {
        mockActivatedRoute = {
            snapshot: {
                paramMap: {
                    get: () => '3'
                }
            }
        };

        mockHeroService = jasmine.createSpyObj(['getHero', 'updateHero']);

        mockLocation = jasmine.createSpyObj(['back']);

        TestBed.configureTestingModule({
            declarations: [HeroDetailComponent],
            providers: [
                { provide: ActivatedRoute, useValue: mockActivatedRoute },
                { provide: HeroService, useValue: mockHeroService },
                { provide: Location, useValue: mockLocation },
            ]
        })

        fixture = TestBed.createComponent(HeroDetailComponent);

        mockHeroService.getHero.and.returnValue(of({ id: 3, name: 'SuperDude', strength: 100 }));

    })

    it('test before formal testing', () => {
        fixture.detectChanges();

        expect(fixture.nativeElement.querySelector('h2').textContent).toContain('SUPERDUDE');
    })
})

然后测试没有通过,原因很简单,在于 template 上面,见下面的 html 文件内容:

<div *ngIf="hero">
  <h2>{{ hero.name | uppercase }} Details</h2>
  <div><span>id: </span>{{hero.id}}</div>
  <div>
    <label>name:
      <input [(ngModel)]="hero.name" placeholder="name"/>
    </label>
  </div>
  <button (click)="goBack()">go back</button>
  <button (click)="save()">save</button>
</div>

由于我们去掉了 schemas 所以,当测试框架不认识 <input [(ngModel)]="hero.name" placeholder="name"/> 中的 ngModule 相关的指令的时候就开始报错了!

image.png 对于 Form 相关的指令的处理,我们采用的策略是直接引入,也就是像下面一样。

import { FormsModule } from "@angular/forms";
...
TestBed.configureTestingModule({
    imports: [FormsModule],
    declarations: [HeroDetailComponent],
    providers: [
        { provide: ActivatedRoute, useValue: mockActivatedRoute },
        { provide: HeroService, useValue: mockHeroService },
        { provide: Location, useValue: mockLocation },
    ]
})
...

整体代码为:

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroDetailComponent } from "./hero-detail.component";
import { ActivatedRoute } from "@angular/router";
import { HeroService } from "../hero.service";
import { of } from "rxjs";
import { FormsModule } from "@angular/forms";

describe('test on detail hero component', () => {
    let fixture: ComponentFixture<HeroDetailComponent>;
    let mockActivatedRoute, mockHeroService, mockLocation;

    beforeEach(() => {
        mockActivatedRoute = {
            snapshot: {
                paramMap: {
                    get: () => '3'
                }
            }
        };

        mockHeroService = jasmine.createSpyObj(['getHero', 'updateHero']);

        mockLocation = jasmine.createSpyObj(['back']);

        TestBed.configureTestingModule({
            imports: [FormsModule],
            declarations: [HeroDetailComponent],
            providers: [
                { provide: ActivatedRoute, useValue: mockActivatedRoute },
                { provide: HeroService, useValue: mockHeroService },
                { provide: Location, useValue: mockLocation },
            ]
        })

        fixture = TestBed.createComponent(HeroDetailComponent);

        mockHeroService.getHero.and.returnValue(of({ id: 3, name: 'SuperDude', strength: 100 }));

    })

    it('test before formal testing', () => {
        fixture.detectChanges();

        expect(fixture.nativeElement.querySelector('h2').textContent).toContain('SUPERDUDE');
    })
})

小结一下,我们对 ActivatedRouter 的模拟既不会用到 jasmine.createSpyObj 也没有其它备用的方法,而是直接使用一个实现了特殊接口的 plainObject 就可以了:

    mockActivatedRoute = {
        snapshot: {
            paramMap: {
                get: () => '3'
            }
        }
    };

注意这里的 snapshot 的第二个 s 不是大写的!

关于路由相关指令的 mock

我们使用覆盖的方式自造路由指令。首先回到 heroes.component.deep.spec.ts 中,然后增加测试代码,如下所示:

import { RouterLinkDirectiveStub } from './routerLinkDirectiveStub';
import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
import { By } from "@angular/platform-browser";

describe('HeroComponents', () => {
    let HEROES;
    let mockHeroService;
    let fixture: ComponentFixture<HeroesComponent>;

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        TestBed.configureTestingModule({
            declarations: [
                HeroesComponent,
                HeroComponent,
                RouterLinkDirectiveStub,
            ],
            providers: [{
                provide: HeroService, useValue: mockHeroService,
            }],
            // schemas: [NO_ERRORS_SCHEMA],
        });

        fixture = TestBed.createComponent(HeroesComponent);
    })

    it('should have the correct route for the first hero', () => {
        // 模拟HeroService的getHeroes方法,使其返回一个Observable对象,该对象包含HEROES数组。  
        mockHeroService.getHeroes.and.returnValue(of(HEROES));

        // 触发Angular的变更检测机制,以便更新视图。  
        fixture.detectChanges();

        // 查询所有的HeroComponent实例。  
        const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));

        // 获取第一个HeroComponent实例中的RouterLinkDirectiveStub实例。  
        // 这是通过查询RouterLinkDirectiveStub指令,并从其注入器中获取实例来实现的。  
        let routerLink = heroComponents[0].query(By.directive(RouterLinkDirectiveStub)).injector.get(RouterLinkDirectiveStub);

        // 模拟点击第一个HeroComponent中的链接(<a>标签)。  
        heroComponents[0].query(By.css('a')).triggerEventHandler('click', null);

        // 断言:期望RouterLinkDirectiveStub的navigatedTo属性值为'/detail/1',  
        // 这表示当点击第一个英雄时,应该导航到'/detail/1'这个路由。  
        expect(routerLink.navigatedTo).toBe('/detail/1');
    })
})

上面的代码中,我们关闭了作弊器 schemas: [NO_ERRORS_SCHEMA], 然后在 declarations 中添加了自建指令 RouterLinkDirectiveStub 其中 RouterLinkDirectiveStub 的代码如下所示:

import { Directive, Input } from "@angular/core";

@Directive({
    selector: '[routerLink]',
    host: { '(click)': 'onClick()' }
})
export class RouterLinkDirectiveStub {
    @Input('routerLink') linkParams: any;

    navigatedTo: any = null;

    onClick() {
        this.navigatedTo = this.linkParams;
    }
}

这个指令的作用为在绑定的元素被点击的时候将 routerLink 绑定的值存储在内部属性 navigatedTo 中。

最为关键的是下面的这行代码:

let routerLink = heroComponents[0].query(By.directive(RouterLinkDirectiveStub)).injector.get(RouterLinkDirectiveStub);

这句代码很长,可将其分成两个部分来看,其中前半部分 heroComponents[0].query(By.directive(RouterLinkDirectiveStub)) 的含义在于通过 By.directive 方法找到子组件中的 a 标签,因为只有 a 标签上带了名为 routerLink 的指令:

<a routerLink="/detail/{{hero.id}}">
  <span class="badge">{{hero.id}}</span> {{hero.name}}
</a>
<button class="delete" (click)="onDeleteClick($event)">x</button>

后半部分是找到 a 标签对象之后从其属性 .injector 上面获取所有的指令集合,然后使用 .get() 方法从这个集合中获取某个特定的指令,在这里我们获取的就是 RouterLinkDirectiveStub. 这里注意一下,我们通过 RouterLinkDirectiveStub 指令找到标签和最终通过 RouterLinkDirectiveStub 找到想要的指令是两码事。

最后我们在 view 层找到 a 标签并发起点击事件,这个时候发生了跳转,我们断言的结论是:routerLink 中存储的路由和我们期待的路由地址相同!

现在的代码写成:

import { RouterLinkDirectiveStub } from './routerLinkDirectiveStub';
import { HeroesComponent } from "./heroes.component";
import { HeroService } from "../hero.service";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroComponent } from "../hero/hero.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { of } from "rxjs";
import { By } from "@angular/platform-browser";

describe('HeroComponents', () => {
    let HEROES;
    let mockHeroService;
    let fixture: ComponentFixture<HeroesComponent>;



    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        TestBed.configureTestingModule({
            declarations: [
                HeroesComponent,
                HeroComponent,
                RouterLinkDirectiveStub,
            ],
            providers: [{
                provide: HeroService, useValue: mockHeroService,
            }],
            // schemas: [NO_ERRORS_SCHEMA],
        });

        fixture = TestBed.createComponent(HeroesComponent);
    })

    it('should have the correct route for the first hero', () => {
        let _ = 2;
        // 模拟HeroService的getHeroes方法,使其返回一个Observable对象,该对象包含HEROES数组。  
        mockHeroService.getHeroes.and.returnValue(of(HEROES));

        // 触发Angular的变更检测机制,以便更新视图。  
        fixture.detectChanges();

        // 查询所有的HeroComponent实例。  
        const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));

        // 获取第一个HeroComponent实例中的RouterLinkDirectiveStub实例。  
        // 这是通过查询RouterLinkDirectiveStub指令,并从其注入器中获取实例来实现的。  
        let routerLink = heroComponents[_].query(By.directive(RouterLinkDirectiveStub)).injector.get(RouterLinkDirectiveStub);

        // 模拟点击第一个HeroComponent中的链接(<a>标签)。  
        heroComponents[_].query(By.css('a')).triggerEventHandler('click', null);

        // 断言:期望RouterLinkDirectiveStub的navigatedTo属性值为'/detail/1',  
        // 这表示当点击第一个英雄时,应该导航到'/detail/1'这个路由。  
        expect(routerLink.navigatedTo).toBe(`/detail/${HEROES[_].id}`);
    })
})

21. 对异步代码进行测试

在这个小节中介绍大概三种方法完成异步代码的测试,让我们回到 hero-detail 中,将 component 的代码修改成下面的样子:

// @ts-nocheck
import { Component, OnInit, Input } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';

import { Hero } from '../hero';
import { HeroService } from '../hero.service';

@Component({
  selector: 'app-hero-detail',
  templateUrl: './hero-detail.component.html',
  styleUrls: ['./hero-detail.component.css'],
})
export class HeroDetailComponent implements OnInit {
  @Input() hero: Hero;

  constructor(
    private route: ActivatedRoute,
    private heroService: HeroService,
    private location: Location
  ) { }

  ngOnInit(): void {
    this.getHero();
  }

  getHero(): void {
    const id = +this.route.snapshot.paramMap.get('id');
    this.heroService.getHero(id).subscribe((hero) => (this.hero = hero));
  }

  goBack(): void {
    this.location.back();
  }

  // save(): void {
  //   var p = new Promise((resolve) => {
  //     this.heroService.updateHero(this.hero).subscribe(() => this.goBack());
  //     resolve();
  //   });
  // }

  save(): void {
    debounce(() => {
      this.heroService.updateHero(this.hero)
        .subscribe(() => this.goBack());
    }, 250, false)();
  }
}

function debounce(func, wait, immediate) {
  var timeout;
  return function () {
    var context = this,
      args = arguments;
    var later = function () {
      timeout = null;
      if (!immediate) func.apply(context, args);
    };
    var callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    if (callNow) func.apply(context, args);
  };
}

修改之后的代码对于其上面的保存方法加上了节流,加上节流之后,save 方法就变成了异步执行的代码,那么对于现在的 save 又该如何测试呢?

1. 使用 done

虽然标题是 使用 done,但是 done 的作用仅仅是为了将测试流程进行下去,和我们的测试异步代码的技巧没有关系。

我们的技巧其实是,节流的间隔是 250ms 那我就在这里等 250ms 之后再调用 expect 进行断言不就不用担心在 测试的同步代码执行完毕之后 expect 断言还没有执行的问题了吗。使用 done 只是告诉测试框架你可以完成了。

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HeroDetailComponent } from "./hero-detail.component";
import { ActivatedRoute } from "@angular/router";
import { HeroService } from "../hero.service";
import { of } from "rxjs";
import { FormsModule } from "@angular/forms";

describe('test on detail hero component', () => {
  let fixture: ComponentFixture<HeroDetailComponent>;
  let mockActivatedRoute, mockHeroService, mockLocation;

  beforeEach(() => {
    mockActivatedRoute = {
      snapshot: {
        paramMap: {
          get: () => '3'
        }
      }
    };

    mockHeroService = jasmine.createSpyObj(['getHero', 'updateHero']);

    mockLocation = jasmine.createSpyObj(['back']);

    TestBed.configureTestingModule({
      imports: [FormsModule],
      declarations: [HeroDetailComponent],
      providers: [
        { provide: ActivatedRoute, useValue: mockActivatedRoute },
        { provide: HeroService, useValue: mockHeroService },
        { provide: Location, useValue: mockLocation },
      ]
    })

    fixture = TestBed.createComponent(HeroDetailComponent);

    mockHeroService.getHero.and.returnValue(of({ id: 3, name: 'SuperDude', strength: 100 }));

  })

  it('test before formal testing', () => {
    fixture.detectChanges();

    expect(fixture.nativeElement.querySelector('h2').textContent).toContain('SUPERDUDE');
  })

  it('should call updateHero when save is called', (done) => {
    mockHeroService.updateHero.and.returnValue(of({}));

    fixture.detectChanges();

    fixture.componentInstance.save();

    setTimeout(() => {
      expect(mockHeroService.updateHero).toHaveBeenCalled();
      done();
    }, 300)
  })
})

这里注意一下,如果去掉 done 那么测试程序就会一直跑下去,不会停止,也得不到什么结果。

2. 使用 fakeAsync

上面的 setTimeout 方法有什么问题吗?最大的问题就是它会让我们的测试过程异常的耗时,因为它是真等呀!

因此在 @angular/core/testing 中提供了名为 fakeAsync 的方法,使用这个方法也可以完成对异步代码的测试。如下所示:

// import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";

  it('should call updateHero when save is called', fakeAsync(() => {
    mockHeroService.updateHero.and.returnValue(of({}));

    fixture.detectChanges();

    fixture.componentInstance.save();
    tick(250);

    expect(mockHeroService.updateHero).toHaveBeenCalled();

  }))

这里的 tick(250) 就相当于在这里等了 250ms.

3. 使用 fixture.whenStable

上面的两个方法都必须明确的知道需要等待多久,假如我不知道要等多久呢,250ms 还是 30ms. 那么这个时候就可以使用 fixture 上面的 whenStable 方法了。单从这个名字就可以看出来,里面的回调会等到对应的组件稳定下来才执行,所谓稳定下来指的就是所有的宏任务和微任务走完,因此这种做法也可以用来测试异步代码。

  it('should call updateHero when save is called', () => {
    mockHeroService.updateHero.and.returnValue(of({}));

    fixture.detectChanges();

    fixture.componentInstance.save();

    fixture.whenStable().then(() => {
      expect(mockHeroService.updateHero).toHaveBeenCalled();
    })
  })

我们尝试将 save 改成 Promise 类型的,

  save(): void {
    var p = new Promise((resolve) => {
      this.heroService.updateHero(this.hero).subscribe(() => this.goBack());
      resolve();
    });
  }

测试也是可以通过的!

小结一下:本节介绍了三种异步代码测试的方法。

22. 代码测试覆盖率

在 package.json 中增加如下的脚本:

"gencode": "ng test --code-coverage"

执行之后,会在控制台打印出简短的覆盖率信息:

image.png

以及详细的覆盖率说明文件:

image.png

最后

我们应该怎样选择合适的测试类型?

默认情况下我们使用的是 isolated 测试类型;

只有在必要的时候才使用 integration 类型,并且尽可能的使用 shallow 而不是 deep;

只有在极少数情况下才会使用 deep integration test.

实践与练习

最后的最后,让我们将本文学到的东西总结一下,粗略的回顾一下,也是收获满满呢!

1. 学会如何测试管道

分两步骤:先实例化再测试此实例 transform 方法的返回值。

import { StrengthPipe } from './strength.pipe';

describe('StrengthPipe', () => {
    it('should display waek if strength is 5', () => {
        let pipe = new StrengthPipe();

        expect(pipe.transform(5)).toEqual('5 (week)');
    })
})

2. 学会如何测试一个服务

测试一个服务的过程甚至比测试一个管道还要简单。测试管道的时候测试的是固定接口(transform), 而测试服务的时候测试的是其上的各种方法。这并不难理解,因为管道实现了特定的接口。

import { MessageService } from "./message.service";

describe('MessageService', () => {
    let service: MessageService;

    beforeEach(() => {
        service = new MessageService();
    })

    it('should have no messages to start', () => {
        expect(service.messages.length).toBe(0);
    })

    it('should add a message when add is called', () => {
        service.add('message 1');

        expect(service.messages.length).toBe(1);
    })
})

3. 学会如何 mock 一个 service

注意,这里不是要测试某个服务,而是在测试以 component 为代表的其它部分的时候需要对 service 进行 mock; mock service 的理由也很充分:它不是重点;真实的服务可能是耗时的;此服务暂时还没有被开发出来,等。

我们使用 jasmine 上面的 createSpyObj mock 服务实例(而不是类),如下所示:

describe('HeroComponents', () => {
    let mockHeroService;

    beforeEach(() => {
        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
    })
})

方法名中的 Spy 有说法的,如果你 new 一个真的 Service 实例,还需要额外对某个方法进行处理:

spyOn(serviceInstance, 'delete');

4. 学会如何粗糙的测试一个 component

所谓粗糙的测试一个组件,指的是使用 new 一个实例的方法创建此组件实例,同时注入其所需要的构造函数,这并不是测试推荐的方法。

import { HeroesComponent } from "./heroes.component";

describe('HeroComponents', () => {
    let component: HeroesComponent;
    let HEROES;
    let mockHeroService;

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        component = new HeroesComponent(mockHeroService);
    })

    describe('delete', () => {
        it('should remove the indicated hero from the heroes list', () => {
            component.heroes = HEROES;
            component.delete(HEROES[2]);
            expect(component.heroes.length).toBe(2);
        })
    })
})

我们把 component 当成一个普通 class 来测试,这固然能够说明一些问题,但是 component 的视图层完全不会被测试,也就是此 component 在页面的表现完全不可控。

5. 学会指定 mock 服务中方法的返回值

我们使用 mockHeroService.deleteHero.and.returnValue(of(true)) 的方式指定 mock 的 object 中的方法的返回值,需要注意的是,在每个 it 块中只能指定一次。

6. xit 以及常见的断言

如果你将 it 块改成 xit 块,那么此部分测试会出现在测试报告中,但是会被标记为未测。

image.png

7. 进行浅的集成测试的时候如何避免报错

我们在测试 component 中使用到了 TestBed 上面的,名为 configureTestingModule 的方法,并在其配置项中,使用键值对 schemas: [NO_ERRORS_SCHEMA], 抑制此组件测试过程中由于编译器不认识某些语法导致的错误。常用于快速测试过程中,但是这样做是有风险的,因为它可能让你忽略真正的错误。

import { TestBed } from "@angular/core/testing";

TestBed.configureTestingModule({
    declarations: [HeroesComponent, HeroComponent],
    providers: [{
        provide: HeroService, useValue: mockHeroService,
    }],
    schemas: [NO_ERRORS_SCHEMA],
});

8. 深度集成测试的时候如何配置 module 的编译环境以及更好的创建组件实例的方法

真正的测试中,不会使用 new 一个 component 的方式获取组件实例。取而代之的方法是我们使用 TestBed 上面提供的名为 createComponent 的方法去创建待测组件实例。此方法的返回值我们一般使用名为 fixture 的变量储存,如下所示:

    let HEROES;
    let mockHeroService;
    let fixture: ComponentFixture<HeroesComponent>;

    beforeEach(() => {
        HEROES = [
            { id: 1, name: 'SpiderDude', strength: 8 },
            { id: 2, name: 'Wonderful Woman', strength: 24 },
            { id: 3, name: 'SuperDude', strength: 55 },
        ];

        mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);

        TestBed.configureTestingModule({
            declarations: [
                HeroesComponent,
                HeroComponent,
                RouterLinkDirectiveStub,
            ],
            providers: [{
                provide: HeroService, useValue: mockHeroService,
            }],
            // schemas: [NO_ERRORS_SCHEMA],
        });

        fixture = TestBed.createComponent(HeroesComponent);
    })

fixture 这个单词的含义就是 固定装置,私以为比 handler 的语义好一些。注意我们在写 fixture = TestBed.createComponent(HeroesComponent); 这句代码的时候,并没有向 HeroesComponent 传递其所需要的构造函数,而是将 mockHeroService 放在了 providers 数组中。

9. 如何创建一个假的子组件

如果我们决定不用 schemas: [NO_ERROR_SCHEMA] 抑制找不到子组件对应的 selector 产生的报错,转而 mock 一个子组件的话,我们完全可以在 describe 块内部,任何 it 块外部,使用 @Component 生成一个同 selector 的子组件。

    @Component({
        selector: 'app-hero',
        template: '<div></div>',
    })
    class FakeHeroComponent {
        @Input() hero: Hero;
        // @Output()delete = new EventEmitter();
    }

以及:

import { HeroesComponent } from "./heroes.component";
import { Component, Input } from "@angular/core";
import { Hero } from "../hero";
import { HeroService } from "../hero.service";
import { TestBed } from "@angular/core/testing"

describe('HeroComponents', () => {
    let HEROES;
    let mockHeroService;
    let fixture;

    @Component({
        selector: 'app-hero',
        template: '<div></div>',
    })
    class FakeHeroComponent {
        @Input() hero: Hero;
    }

    beforeEach(() => {
        TestBed.configureTestingModule({
            declarations: [HeroesComponent, FakeHeroComponent],
            providers: [{
                provide: HeroService, useValue: mockHeroService,
            }]
        });
    })

    describe('delete', () => {
    })
})

这样做的话父组件的 template 在被渲染的时候就不会因为解析不了 <app-hero></app-hero> 而报错了。

10. 创建完组件实例之后如何初始化

使用 TestBed.createComponent(*) 的方式创建组件实例之后,组件的生命周期函数 ngOnInit 是不会被执行的。因此我们必须手动触发,但不是直接调用同名函数,而是使用下面的方式间接调用:

fixture.detectChanges();

11. 如何创建组件的 fixture 以及如何通过 fixture 找到组件实例和视图层最外层 dom

使用 TestBed.createComponent(SomeComponent) 创建 fixture, 然后使用其上面的 componentInstance 属性表示组件实例,使用其上的 debugElement 属性表示最外层dom

12. debugElement 和 By.* 的联合使用

如下所示:

fixture.debugElement.queryAll(By.directive(HeroComponent));

13. 使用 By.directive 方法找到子组件,以及原理

代码如上所示,其原理比较奇特:在测试环境中,父组件可以将其 template 中的子组件视为 directive, 正是因为此,所以可以使用 By.directive(ChildComponent) 的方式找到子组件。

14. 循环和断言的联合使用

如下所示,这种适合检测列表的渲染是否正确:

    it('should render each hero as a HeroComponent', () => {
        mockHeroService.getHeroes.and.returnValue(of(HEROES));
        fixture.detectChanges();
        const heroComponentDEs = fixture.debugElement.queryAll(By.directive(HeroComponent));
        expect(heroComponentDEs.length).toEqual(3);

        for (let i = 0; i < HEROES.length; i++) {
            expect((<HeroComponent>heroComponentDEs[i].componentInstance).hero.name).toBe(HEROES[i].name);
        }
    })

15. 测试网络通信服务类

测试网络通信服务的时候,其核心在于对 HTTPClient 的模拟。

我们使用 HttpClientTestingModule 和 HttpTestingController 来 mock 网络通信相关过程。基本过程分为:

  1. 引入网络 mock 模块
  2. 声明被测 service 实例和 controller
  3. 注入 HttpClientTestingModule
  4. 得到 controller 实例和被测服务实例
  5. 待测实例发起网络请求
  6. 使用 controller 实例结合断言进行测试
import { TestBed } from "@angular/core/testing";
import { HeroService } from "./hero.service";
import { MessageService } from "./message.service";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; // step 1

describe('HeroService', () => {
    let mockMessageService;
    let service; // step 2
    let httpTestingController: HttpTestingController; // step 2

    beforeEach(() => {
        mockMessageService = jasmine.createSpyObj(['add']);

        TestBed.configureTestingModule({
            imports: [HttpClientTestingModule], // step 3
            providers: [
                HeroService,
                { provide: MessageService, useValue: mockMessageService },
            ]
        })

        // 使用 TestBed.get 方法获取 HttpTestingController 和 HeroService 的实例。
        httpTestingController = TestBed.get(HttpTestingController); // step 4
        service = TestBed.get(HeroService); // step 4
    })

    describe('getHero', () => { // step 5, 6
        it('should call get with the correct URL', () => {
            service.getHero(4).subscribe(); // service.getHero(4).subscribe() 发起一个 HTTP GET 请求,模拟获取 ID 为 4 的英雄。

            const req = httpTestingController.expectOne('api/heroes/4'); // 使用 httpTestingController.expectOne('api/heroes/4') 捕获发出的请求,并验证请求的 URL 是否正确。
            req.flush({ id: 4, name: 'SuperDude', strength: 100 }); // 使用 req.flush 方法模拟服务器响应,返回一个英雄对象。
            httpTestingController.verify(); // 使用 httpTestingController.verify 方法验证没有未处理的请求,确保所有预期的请求都已经被处理。
        });
    })
})

16. 使用 TestBed.get 获取对应的实例

// 使用 TestBed.get 方法获取 HttpTestingController 和 HeroService 的实例。
httpTestingController = TestBed.get(HttpTestingController);
service = TestBed.get(HeroService);

17. 从 view 或者 class 层触发点击事件并检测点击事件的回调有没有执行

由于 fixture 可以提供 class 层和 view 层的顶层对象;因此不论是从 view 层发起的点击事件还是从 class 层发起的点击事件,其源头都是 fixture. 不同之处在于使用的 api 不同罢了。

1. 从 view 层发起事件

走的路线为:debugElement -> query -> triggerEventHandler

2. 从 class 层发起事件

相当于是简单的示例上面的方法的调用。

it(`should call heroService.deleteHero when the Hero Component's delete button is clicked`, () => {
    mockHeroService.getHeroes.and.returnValue(of(HEROES));

    fixture.detectChanges();

    const heroComponents = fixture.debugElement.queryAll(By.directive(HeroComponent));
    spyOn(fixture.componentInstance, 'delete'); // 将 delete 方法转换为 spy

    // heroComponents[0].query(By.css('button')).triggerEventHandler('click', { stopPropagation: () => { } });
    (<HeroComponent>heroComponents[0].componentInstance).delete.emit(undefined);
    (<HeroComponent>heroComponents[0].componentInstance).delete.next(undefined);

    // expect(fixture.componentInstance.delete).toHaveBeenCalled();
    expect(fixture.componentInstance.delete).toHaveBeenCalledWith(HEROES[0]);
})

18. 对 ActivatedRoute 服务的 mock

相对简单,分下面几个步骤完成即可:

  1. 从 @angular/router 中引入真正的 ActivatedRoute 作为类型约束。
  2. 定义 mockActivatedRoute 全局变量,并赋予其值,实现下面格式的接口:
interface ActivatedRoute {  
  snapshot: {  
    paramMap: {  
      get(param: string): string;  
    };  
  };  
} 
  1. 将其注入到 TestBed.configureTestingModule 中去。
    TestBed.configureTestingModule({
      imports: [*],
      declarations: [*],
      providers: [
        ...,
        { provide: HeroService, useValue: mockHeroService },
        ...,
      ]
    })

全部代码如下所示:

import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { HeroDetailComponent } from "./hero-detail.component";
import { ActivatedRoute } from "@angular/router";
import { HeroService } from "../hero.service";
import { of } from "rxjs";
import { FormsModule } from "@angular/forms";

describe('test on detail hero component', () => {
  let fixture: ComponentFixture<HeroDetailComponent>;
  let mockActivatedRoute: ActivatedRoute;
  let mockHeroService, mockLocation;

  beforeEach(() => {
    mockActivatedRoute = {
      snapshot: {
        paramMap: {
          get: () => '3',
        }
      }
    };

    mockHeroService = jasmine.createSpyObj(['getHero', 'updateHero']);

    mockLocation = jasmine.createSpyObj(['back']);

    TestBed.configureTestingModule({
      imports: [FormsModule],
      declarations: [HeroDetailComponent],
      providers: [
        { provide: ActivatedRoute, useValue: mockActivatedRoute },
        { provide: HeroService, useValue: mockHeroService },
        { provide: Location, useValue: mockLocation },
      ]
    })

    fixture = TestBed.createComponent(HeroDetailComponent);
    mockHeroService.getHero.and.returnValue(of({ id: 3, name: 'SuperDude', strength: 100 }));
  })
})

19. 对 routerLink 等指令的 mock

这个比起 ActivatedRoute 的 mock 就复杂多了,原理在于覆盖。我们自己将 routerLink 对应的模块实现一边,然后用自己实现的模块对 @angular/router 的内置 RouterModule 模块进行覆盖即可。首先创建一个名为 routerLinkDirectiveStub 的文件。

import { Directive, Input } from "@angular/core";

@Directive({
    selector: '[routerLink]',
    host: { '(click)': 'onClick()' }
})
export class RouterLinkDirectiveStub {
    @Input('routerLink') linkParams: any;

    navigatedTo: any = null;

    onClick() {
        this.navigatedTo = this.linkParams;
    }
}

然后在 TestBed.configureTestingModule 的 declarations 中进行声明即可!

TestBed.configureTestingModule({
    declarations: [
        ...,
        RouterLinkDirectiveStub,
    ],
    providers: [{
        // provide: HeroService, useValue: mockHeroService,
    }],
    // schemas: [NO_ERRORS_SCHEMA],
});

20. 测试异步代码的三种方式

  1. setTimeout + done 方式
  2. fakeAsync + tick 方式
  3. fixture.whenStable 方式

21. 得到测试覆盖率报告

"gencode": "ng test --code-coverage"
yarn gencode

结果图示:

image.png

image.png