返回

Angular单元测试必不可少的必备神器

前端

Angular单元测试中必不可少的工具
在上一篇文章中,我们以Angular官方自带的测试用例为引,介绍了如何在20分钟内将单元测试集成到现有项目中;并提出从公共方法、表单、数据源等逻辑相对简单又比较稳定的代码块入手,开启书写单元测试之路。这一篇文章,主要来回答以下两个问题:

  1. 当我们在code-coverage 的报告中查看测试覆盖率时,看到有些语句块被认为是未覆盖的,我该如何处理呢?
  2. 除了基本功能的单元测试,更高级的比如组件中对DOM的测试、服务与组件之间通信的测试,甚至项目使用到的其他第三方的库的测试,要如何进行呢?

code-coverage 揭示代码覆盖率的缺陷

我们需要说明的是,这个插件其实已经非常棒了,会自动跳过那些angular自动生成的代码,包括全部组件模板(html、css、scss)和metadata,以及如模板编译函数、指令解析、变更检测等辅助性函数,还有国际化i18n相关内容,甚至包括我们自己组件中使用的一些装饰器如@HostListener、@Input等。但是自动过滤的缺漏肯定还是有的,这个需要我们自己来手动填坑。

受害者一号:由服务(Service)与组件(Component)间数据交换而引入的逻辑

如前文所述,我们测试一个组件时,需要为其提供所需依赖项,而这其中很可能涉及到服务,而恰巧这个服务在创建时,又会使用到组件,于是就陷入了死循环。最简单的例子如下:

// app.component.ts
@Component({
  selector: 'my-app',
  template: '<child-component></child-component>',
})
export class AppComponent { }

// child.component.ts
@Component({
  selector: 'child-component',
  template: '<service-component></service-component>',
})
export class ChildComponent { }

// service.component.ts
@Component({
  selector: 'service-component',
  template: '<app-component></app-component>',
})
export class ServiceComponent { }

本来只需要测试ChildComponent这个组件,在它内部用了一个ServiceComponent,这个服务组件又用到了AppComponent,AppComponent恰好又是ChildComponent的父组件,这样就构成了一个死循环。code-coverage也恰好针对这类缺陷给出了最明显的提示:AppComponent未被覆盖。遇到这种问题,最简单粗暴的做法就是:使用一个占位符组件来替换当前组件,如:

// dummy.component.ts
@Component({
  selector: 'dummy-component',
  template: '',
})
export class DummyComponent { }

上面的服务组件用到的AppComponent就可以使用DummyComponent组件来替换了。不过需要强调的是,这种做法的局限性在于,它并不能覆盖AppComponent的逻辑,说白了就是仅仅能让code-coverage的报告不报错,所以使用这种方法时,还是需要注意它会带来不可忽视的风险。

受害者二号:模板里依赖的对象

假设一个组件使用了某个service,并且这个service在组件的html模板中被使用了多次。在测试这个组件的时候,我们需要为它提供依赖项,这个service会被实例化,这样就意味着这个service只被实例化了一次,所以你的模板里依赖该service多次,那么在code-coverage的报告里,这个service就会被视为没有被覆盖的。

现在code-coverage这个插件还不能帮助我们识别并自动忽略这些未覆盖的部分。那我们的解决办法又是什么呢?答案是,像之前一样使用一个占位符组件来替换即可,思路类似上面的AppComponent的处理方法。

比如:

// app.component.ts
@Component({
  selector: 'my-app',
  template: '<child-component></child-component>',
})
export class AppComponent { }

// child.component.ts
@Component({
  selector: 'child-component',
  template: '<service-component></service-component>',
})
export class ChildComponent { }

// service.component.ts
@Component({
  selector: 'service-component',
  template: '<dummy-component></dummy-component>',
})
export class ServiceComponent { }

// dummy.component.ts
@Component({
  selector: 'dummy-component',
  template: '<dummy-component></dummy-component>',
})
export class DummyComponent { }

这个占位符组件之所以还能用在模板里,是因为它使用了递归调用,这样就在code-coverage里给出了这种service被多次实例化的假象。当然,这种方式的弊端也和上面的一样,它不能真正测试AppComponent的逻辑,同时也不能覆盖ServiceComponent的逻辑,所以只能权当是一个权宜之计。

受害者三号:依赖于DOM的操作

这里举个最简单的例子,如下:

// my.component.ts
@Component({
  selector: 'my-component',
  template: '<button (click)="onButtonClick($event)">Click me</button>',
})
export class MyComponent {
  onButtonClick(event: Event) {
    // do something
  }
}

当我们使用karma+jasmine来跑单元测试时,是无法测试这种事件(包括监听各种事件)和DOM的操作的。

因为karma所运行的代码是在浏览器的模拟环境中跑的,不可能像真的浏览器那样,真实地去操作DOM,所以这也就直接导致了不能去测试这种代码。

为了能测试组件中跟DOM有关的代码,我们需要借助其他工具来实现。这里介绍一下angular官方推荐的两个项目:

Protractor的入门门槛比较低,而且也为angular提供了非常好的支持,但它是在selenium上做了二次开发,所以存在二次开发带来的兼容性等问题。与之相比,Cypress是直接在浏览器上进行测试,所以会更加灵活。

另外,还有一个问题不得不提,就是Protractor和Cypress都属于e2e(端到端)测试,都非常耗时。因为他们会真实地去运行你的Angular项目,然后再跟你的项目进行交互操作,所以会非常耗时。所以,对于针对组件的DOM操作的测试,我们应该非常慎重,仅在必要时才使用它们,而且要尽量测试一些关键逻辑。

受害者四号:第三方的库和组件

在Angular中使用到第三方的库和组件非常普遍,现在有很多的开源库,比如lodash、moment.js、rxjs、@angular/pwa等,还有包括Ant Design、Element UI在内的各种组件。那要如何对这些库和组件进行单元测试呢?对于开源库来说,一般都会提供自己的单元测试,所以我们可以直接去查看他们提供的测试用例。这些测试用例通常都是放在npm包的tests目录下,我们可以通过npm来安装这个测试用例。

而针对那些既没有提供测试用例又没有提供单元测试的组件时,我们在单元测试时,只能做到对那些需要用到的组件方法的调用,至于那些可能需要用到的组件内部方法就只能选择忽略。

结语

最终,当我们能够独立测试那些功能复杂的组件后,我们才能更加胸有成竹。