如何在Angular中写单元测试

1,889 阅读9分钟

前言

Angular其实有自己推荐的单元测试的工具:Karma + Jasmine,当创建Angular应用后,在package.json文件中已经添加了Karma和Jasmine的依赖性,同时Angular还提供了一些自己的测试工具类,比如TestBed,更多详情请参照(Angular - 测试)

karma介绍

  • Karma是由Google团队开发的一套前端测试运行框架,是个的自动化测试管理工具,它不同于测试框架(Mocha,Jasmine,QUnit等),他是运行在这些测试框架之上。Karma是一直作为一个Test Runner而存在的。该工具可用于测试所有主流Web浏览器,也可集成到 CI (Continuous integration)工具,也可和其他代码编辑器一起使用,可监控文件的变化自动执行测试

  • karma的基本运行过程:

  • Karma启动一个web服务器,生成包含js源代码和js测试脚本的页面;
  • 运行浏览器加载页面,并显示测试的结果;
  • 如果开启检测,则当文件有修改时,执行继续执行以上过程。
  • karma库的介绍
    1. karma:Karma核心组件
    2. karma-chrome-launcher:Chrome发射器,测试会在Chrome上执行
    3. karma-coverage-istanbul-reporter:coverage报告
    4. karma-jasmine:Jasmine核心组件
    5. karma-jasmine-html-reporter:Html测试报告

在src目录下会看到名为:karma.conf.js、test.ts的两个文件。

karma.conf.js:Karma的配置文件,其中需要重点关注的配置有:

  • frameworks:使用的测试框架,这里使用Jasmine

  • port:测试使用的端口

  • autoWatch:是否自动监测测试代码的改变,自动执行测试

  • plugins:测试使用到的插件,与package.json文件保持一致

  • browsers:测试运行使用的浏览器,这里使用Chrome,如果你需要使用其他浏览器,需要通过npm安装浏览器发射器,并在plugins和这里设置,例如使用Safari

test.ts:测试入口文件,其中初始化了测试环境以及指定所有测试文件

Jasmine介绍

Jasmine:用来编写Javascript测试的的框架,它不依赖于任何其它的javascript框架。

Angular提供的测试工具类

Angular 测试工具类帮助我们创建编写单元测试的环境,主要包括 TestBed 类和各种助手方法,都位于 @angular/core/testing 名称空间下。

TestBed 类是一个很重要的概念,他会创建一个测试模块,模拟一个正常的 Angular 模块的行为。我们可以通过 configureTestingModule 方法配置这个测试模块。

此外,我们可以使用 ComponentFixture 与组件及其元素进行交互;还可以使用 DebugElement 实现跨平台测试。

单元测试文件的基本组成

单元测试的结构通常由两部分组成:

  • 测试用例(Test Spec):一段实际的单元测试代码。
  • 测试集合(Test Suite):测试集合是测试用例的逻辑分组。例如,为某一个功能特征,创建一个测试集合,包含了所有相关的测试用例。

describe 方法定义了一个测试集合,第一个参数描述了集合的名称,第二个参数是一个箭头函数,包含了实际的测试用例。每一个测试用例,都是使用 it 方法定义的。

beforeEach/afterEach会在describe中的每个it的方法参数的调用前/后都会执行一次。粗暴的理解就是说在describe内执行一次或多次,在it内执行一次。 一个describe中可以有多个beforeEach

beforeAll/afterAll会在describe中的全部it的方法参数的调用前/后只执行一次。粗暴的理解就是说在describe内只执行一次。

beforexxx 一般是用来做一些数据的初始化
afterxxx 一般是自行调用从而清除代码,对于使用LocalStorage、SessionStorage保持的数据亦是如此

简单的示例

在app目录下,还会找到一个名为app.component.spec.ts的文件,这就是一个Jasmine的测试,内容如下:

import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';//测试入口,参数为测试名、方法

describe('AppComponent', () => {  //每个测试用的Setup 

beforeEach(async(() => { 
TestBed.configureTestingModule({ declarations: [AppComponent]}).compileComponents();}));  //测试用例

it('should create the app', async(() => {    
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance; 
expect(app).toBeTruthy();}));  

it(`should have as title 'demo'`, async(() => { 
const fixture = TestBed.createComponent(AppComponent); 
const app = fixture.debugElement.componentInstance; //断言,期望值是否满足要求  
expect(app.title).toEqual('demo');  }));

it('should render title in a h1 tag', async(() => { 
const fixture = TestBed.createComponent(AppComponent);   
fixture.detectChanges();   
const compiled = fixture.debugElement.nativeElement;    //通过querySelector获取页面元素    expect(compiled.querySelector('h1').textContent).toContain('Welcome to test-demo!');  }));  //每个测试用例的TearDown  afterEach(function() {    //清除测试数据  });});

在文件的开头,是一个 beforeEach 初始化函数,完成测试前的准备工作。在 beforeEach 方法中,调用了 TestBed 类的 configureTestingModule 方法配置组件所在的模块。configureTestingModule 方法接收一个对象参数,而对象参数的值与模块的 @NgModule 装饰器的值相同。在这里,我们传入的是 declarations 数组,包含了 AppComponent 组件。完成模块配置后,调用 compileComponents 完成测试模块的构建。一旦构建成功,AppComponent 组件就属于该测试模块了。

在第一个单元测试用例中,使用 createComponent 方法创建了一个 AppComponent 组件装置类的对象。该对象是一个 ComponentFixture 类的实例,泛型的类型值是 AppComponent.ComponentFixture 类专门用于组件的调试和测试, 在组件装置类的对象创建成功后,可以通过 componentInstance 属性,获得 AppComponent 组件类的实例,最后,我们使用 toBeTruthy 匹配函数,验证 AppComponent 实例是否有效。

因为我们已经获得了AppComponent 实例,就可以对他的公共属性和方法进行测试。在单元测试用例中,我们使用 toEqual 方法验证 title 属性对值。

我们先调用了 ComponentFixture 类的 detectChanges 方法,目的是触发组件的变化检测机制,强制更新绑定的数据。然后,再使用组件的 nativeElement 属性,查找绑定数据的 DOM 元素,最后,验证 DOM 元素的 textContent 是否与绑定的数据相同

在Jasmine中常用的工具接口如下:

interface Matchers<T> {
   /**
    * toBe: 真实的值和期待的值的 '===' 比较
    * @param expected - 期望值
    * @param expectationFailOutput
    * @example
    * expect(thing).toBe(realThing); 
    */
   toBe(expected: any, expectationFailOutput?: any): Promise<void>;
   /**
    * toEqual: 支持 object 的深度对比, (功能 > toBe )
    * @param expected - 期望值
    * @param expectationFailOutput
    * @example
    * expect(bigObject).toEqual({ "foo": ['bar', 'baz'] });
    */
   toEqual(expected: any, expectationFailOutput?: any): Promise<void>;
   /**
    * toMatch: 通过正则表达式比较
    * @param expected - 期望值
    * @param expectationFailOutput
    * @example
    * expect("my string").toMatch(/string$/);
    * expect("other string").toMatch("her");
    */
   toMatch(expected: string | RegExp | Promise<string | RegExp>, expectationFailOutput?: any): Promise<void>;
   /**
    * toBeDefined: 判断是否定义,非 `undefined`
    * @example
    * var a = undefined;
    * var b = '';
    * var c = null;
    * expect(a).toBeDefined(); // Error
    * expect(b).toBeDefined(); // Ok
    * expect(c).toBeDefined(); // Ok
    */
   toBeDefined(expectationFailOutput?: any): Promise<void>;
   /**
    * toBeUndefined: 值为 `undefined`
    * 与 toBeDefined 相反
    */
   toBeUndefined(expectationFailOutput?: any): Promise<void>;
   /**
    * toBeNull: 值为 `null`
    */
   toBeNull(expectationFailOutput?: any): Promise<void>;
   /**
    * toBeNaN: 值为 `NaN`
    */
   toBeNaN(): Promise<void>;
   /**
    * toBeTruthy: 是否是真实有效的值(非 空字符串,undefined,null)
    */
   toBeTruthy(expectationFailOutput?: any): Promise<void>;
   /**
    * toBeFalsy: 判断是否是false
    * @example
    * expect(result).toBeFalsy();
    */
   toBeFalsy(expectationFailOutput?: any): Promise<void>;
   /**
    * toHaveBeenCalled: 判断函数是否被调用
    * @example
    */
   toHaveBeenCalled(): Promise<void>;
   /**
    * toHaveBeenCalledWith: 函数被调用时的参数
    * @example
    */
   toHaveBeenCalledWith(...params: any[]): Promise<void>;
   /**
    * toHaveBeenCalledTimes: 函数被调用的次数
    * @example
    */
   toHaveBeenCalledTimes(expected: number | Promise<number>): Promise<void>;
   /**
    * toContain: 判断是否含有指定值
    * @example
    * expect(array).toContain(anElement);
    * expect(string).toContain(substring);
    */
   toContain(expected: any, expectationFailOutput?: any): Promise<void>;
   /**
    * toBeLessThan: 小于
    * @example
    * var num = 2;
    * expect(num).toBeLessThan(2); // Error: Expected 2 to be less than 2.
    * expect(num).toBeLessThan(3); // Ok
    */
   toBeLessThan(expected: number | Promise<number>, expectationFailOutput?: any): Promise<void>;
   /**
    * toBeLessThanOrEqual: 小于等于
    */
   toBeLessThanOrEqual(expected: number | Promise<number>, expectationFailOutput?: any): Promise<void>;
   /**
    * toBeGreaterThan: 大于
    */
   toBeGreaterThan(expected: number | Promise<number>, expectationFailOutput?: any): Promise<void>;
   /**
    * toBeGreaterThanOrEqual: 大于等于
    */
   toBeGreaterThanOrEqual(expected: number | Promise<number>, expectationFailOutput?: any): Promise<void>;
   /**
    * toBeCloseTo: 判断是否相似
    * @expected 预期值
    * @precision 精度
    * @example
    * var num = 1.01
    * expect(num).toBeCloseTo(1); // OK
    * expect(num).toBeCloseTo(1, 1); // OK
    * expect(num).toBeCloseTo(1, 2); // Error: Expected 1.01 to be close to 1, 2.
    */
   toBeCloseTo(expected: number | Promise<number>, precision?: any, expectationFailOutput?: any): Promise<void>;
   toThrow(expected?: any): Promise<void>;
   toThrowError(message?: string | RegExp | Promise<string | RegExp>): Promise<void>;
   toThrowError(expected?: new (...args: any[]) => Error | Promise<new (...args: any[]) => Error>, message?: string | RegExp | Promise<string | RegExp>): Promise<void>;
 }

如何在Jasmine中做监测

  • spyOn(obj, 'method') //监听obj对象下的method方法,设置监听之后我们可以通过以下三个方法做相应的验证
toHaveBeenCalled:如果调用了spy,则 toHaveBeenCalled 将通过调用。
toHaveBeenCalledWith: 如果参数列表与spy调用时的参数相匹配,则返回true。
toHaveBeenCalledTimes:如果spy被调用了指定的次数,则 toHaveBeenCalledTimes 将通过调用。
  • createSpy('dummy') // 创建一个虚拟的监听对象,同样可以用上述三种方法做相应的校验

做监测时的注意事项

  • spyon只是检测,并没有真正的调用某些属性或者方法
  • 顺序很重要,要先监测然后在执行对应的方法调用,才能真正的监测到方法的调用
  • 如果检验的函数是个private的,我们可以将对应obj改写成 obj as any,再去进行方法调用

测试异步内容(observable,promise)

async

async 无任何参数与返回值,所有包裹代码块里的测试代码,可以通过调用 whenStable() 让所有待处理异步行为都完成后再进行回调;最后,再进行断言操作。

it('should be get user info (async)', async(() => {
    // call component.query();
    fixture.whenStable().then(() => {
        fixture.detectChanges();
        expect(1).toBe(1);
    });
}));

fakeAsync执行异步操作

fakeAsync不需要回调,就可以进行断言判断,当我们在测试中使用到fakeAsync来做异步操作,那么有两个配套的方法来执行对应的异步操作,分别是 flushMicrotasks and tick,tick(100)表示异步操作在100秒内会被执行,异步宏任务的时候选择使用tick,当用到微任务的时候选择使用flushMicrotasks

it('should be get user info (async)', fakeAsync(() => {
    // call component.query();
    tick();
    fixture.detectChanges();
    expect(1).toBe(1);
}));

jasmine自带的done方法

调用beforeEach, it和afterEach时,函数可以包含一个可选参数done,当spec执行完毕之后,调用done通知Jasmine异步操作已执行完毕

   it("takes a long time", function(done) {
        setTimeout(function() {
         done();
          }, 9000);
       }, 10000);

jasmine.DEFAULT_TIMEOUT_INTERVAL

设置全局的默认超时时间,可以设置jasmine.DEFAULT_TIMEOUT_INTERVAL的值,当异步执行时间超过设置的执行超时时间js将会报错。

运行单元测试文件

在项目所在的路径下,打开一个命令行,运行测试命令 ng test,默认情况下会跑整个项目所有的测试用例,如果你想单独跑某个用例,你可以替换it为fit;如果你想跑整个单独的文件你可以替换describe为fdescribe.