本文介绍如何对 Angular directive 进行单元测试,其中有一些非常坑的点我都踩过了,希望能够帮助到你!
1.测试 Directive 的思路:
- 首先明确测试目标是
Directive,因此除了Directive本身,其他部分(如Components)都可以进行Mock。 - 在测试中,最有可能需要
Mock的是Component,因为Directive需要依附于Component才能生效。 - 测试的基本思路是:先
Mock一个使用了待测Directive的Component,然后配置编译环境,最后获取Mock Component的实例。 - 最后,从视图(
View)或类(Class)层面验证Directive是否真的生效。
2. 待测指令内容
如下所示,下面的代码展示了两个指令,第一个指令的作用是对绑定的组件施加鼠标悬浮之后,其 z-index 的值变成 0, 等鼠标拿开之后又恢复成之前的值的效果。第二个指令的作用是鼠标悬浮或者离开之后其亮度 brightness 发生了变化。
// zindex.directive.ts
import { Directive, ElementRef, HostListener, Input } from "@angular/core"
@Directive({
selector: "[appZIndex]",
})
export class ZIndexDirective {
originZIndex: string;
element: any;
constructor(private elementRef: ElementRef) {
this.element = this.elementRef.nativeElement;
}
ngAfterViewInit() {
this.originZIndex = getComputedStyle(this.element).zIndex;
}
@HostListener("mouseenter") onMouseEnter() {
this.element.style.zIndex = '0';
}
@HostListener("mouseleave") onMouseLeave() {
this.element.style.zIndex = this.originZIndex;
}
}
// brightness.directive.ts
import { AfterViewInit, Directive, ElementRef, HostListener, Input } from "@angular/core"
// 接收参的数类型
interface Options {
originalBrightness?: string
}
@Directive({
selector: "[appBrightness]"
})
export class BrightnessDirective implements AfterViewInit {
// 接收参数
@Input("appBrightness") appBrightness: Options = {}
// 要操作的 DOM 节点
element: any
originalBrightness: string;
// 获取要操作的 DOM 节点
constructor(private elementRef: ElementRef) {
this.element = this.elementRef.nativeElement;
}
// 组件模板初始完成后设置元素的背景颜色
ngAfterViewInit() {
this.originalBrightness = getComputedStyle(this.element).filter;
}
// 为元素添加鼠标移入事件
@HostListener("mouseenter") enter() {
this.element.style.filter = this.appBrightness.originalBrightness ?? "brightness(0.7)";
}
// 为元素添加鼠标移出事件
@HostListener("mouseleave") leave() {
this.element.style.filter = this.originalBrightness ?? "brightness(1)";
}
}
3. 测试指令时候的坑
3.1 如何入手
入手测试指令,或者说测试指令的载体是 mock 的 component,也就是说我们需要 mock 一个 component 然后在其 template 中使用待测的指令。然后通过此 mock 的 component 的行为来检测待测指令是否生效。
因此在测试文件中,在 describe 之外,我们需要通过如下的方式 mock 一个 component 出来:
@Component({
selector: 'app-test',
template: `
<div appZIndex appBrightness class="z-2">ABCDEFGHI</div>
`,
styles: [`
.z-2 {
z-index: 2;
}
`]
})
class MockComponent {
}
3.2 如何配置指令测试的环境
正如上面所说,我们是以组件作为载体来测试指令的。因此为了能够成功的将 mock 的组件渲染出来,我们需要提供对应的编译环境。这里我们使用 TestBed 上面的 configureTestingModule 方法来配置所需的环境。
这里需要注意的有两点:
- 我们只需要配置 declarations 就可以了,因为 declarations 中声明的都是【本 module 自己的组成内容】。
- 我们需要在每次测试开始前,也就是在 beforeEach 生命周期函数中重新配置,这一点非常的重要!否则会影响到后续的测试能否正常进行。
如下所示是配置内容:
import { ZIndexDirective } from "./zindex.directive";
import { BrightnessDirective } from "./brightness.directive";
...
...
let fixture: ComponentFixture<MockComponent>;
let component: MockComponent;
beforeEach(() => {
// 未知原因,初始化应该在每一次测试前重新进行!
TestBed.configureTestingModule({
declarations: [MockComponent, ZIndexDirective, BrightnessDirective]
});
fixture = TestBed.createComponent(MockComponent);
component = fixture.componentInstance;
fixture.detectChanges();
})
3.3 如何在 mock 的组件上模拟鼠标事件
在 mock 的组件上模拟鼠标事件需要分 3 步走,第一步是找到这个元素,第二步是构建 fake mouseEvent,第三步是使用这个元素 dispatch 这个 fake mouse event.
- 找到这个元素,从 fixture 出发,有两个路线,或一开始就使用
fixture.nativeElement.querySelector或通过fixture.debugElement.query(By.*(*).nativeElement)的方式。由于我们在测试指令所以By.*(*)可以换成By.directive(ZIndexDirective)也就是通过指令来查询。 - 然后我们制作 fake mouseEvent,这个很简单,如下所示:
- 最后通过
element.dispatch(mouseEvent)将其发射出去即可。
const element = fixture.debugElement.query(By.directive(ZIndexDirective)).nativeElement;
const mouseEnterEvent = new MouseEvent('mouseenter', {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(mouseEnterEvent);
或者,
const element = fixture.debugElement.query(By.directive(ZIndexDirective)).nativeElement;
const mouseEnterEvent = new MouseEvent('mouseenter', {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(mouseEnterEvent);
3.4 如何确保 mock component 成功的接收到代码触发的假的鼠标事件
在 dispatch(mouseEvent) 之后,我们需要使用 fixture.detectChanges() 来检测变动。注意此时我们无需使用异步检测方法(fakeAsync setTimeout tick 等)。然后检测是在 view 层进行的。
it('Should has the right value of z-index when mouse leaves', (() => {
const element = fixture.debugElement.query(By.directive(ZIndexDirective)).nativeElement;
const mouseEnterEvent = new MouseEvent('mouseenter', {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(mouseEnterEvent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.z-2').style.zIndex).toBe('0');
const mouseLeaveEvent = new MouseEvent('mouseleave', {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(mouseLeaveEvent);
fixture.detectChanges();
expect(getComputedStyle(fixture.nativeElement.querySelector('.z-2')).zIndex).toBe('2');
}))
3.5 测试 css 值的两种方法
一般来说,当我们获得 element 之后,我们可以通过 element.style.zIndex 或者通过 getComputed(element).zIndex 的方法获取对应的样式属性。但是这里有一个问题,那就是这两种方式获取的结果可能是不一样的(尽管含义可能是一样的),例如,前者的值为 '' 的时候,后者可能是 none. 因此在测试的时候需要根据 directive 中的内容选取合适的获取 style 的方式。
4. 设计测试用例
我们将测试用例设计成如下几种: 以下是使用列表方式展示的测试用例:
-
渲染测试
- 描述: 验证组件是否成功渲染。
- 测试用例:
Should render success - 预期结果: 组件实例应该为真值。
-
初始 z-index 值测试
- 描述: 验证元素初始时的 z-index 值是否正确。
- 测试用例:
Should has the right initial value of z-index - 预期结果:
.z-2类的 z-index 应为 '2'。
-
鼠标进入时的 z-index 值测试
- 描述: 验证鼠标进入时元素的 z-index 值是否正确变化。
- 测试用例:
Should has the right value of z-index when mouse enters - 预期结果: 鼠标进入后
.z-2类的 z-index 应变为 '0'。
-
鼠标离开时的 z-index 值测试
- 描述: 验证鼠标离开时元素的 z-index 值是否恢复。
- 测试用例:
Should has the right value of z-index when mouse leaves - 预期结果: 鼠标离开后
.z-2类的 z-index 应恢复为 '2'。
-
鼠标进入时的亮度值测试
- 描述: 验证鼠标进入时元素的亮度值是否正确变化。
- 测试用例:
Should has the right value of brightness when mouse enters - 预期结果: 鼠标进入后
.z-2类的亮度应设置为 'brightness(0.7)'。
-
鼠标离开时的亮度值测试
- 描述: 验证鼠标离开时元素的亮度值是否恢复。
- 测试用例:
Should has the right value of brightness when mouse leaves - 预期结果: 鼠标离开后
.z-2类的亮度应恢复为 'none'。
5. 完整的测试代码
完整的测试文件中的代码如下所示:
// directive.spec.ts
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { Component } from "@angular/core";
import { By } from "@angular/platform-browser";
import { ZIndexDirective } from "./zindex.directive";
import { BrightnessDirective } from "./brightness.directive";
@Component({
selector: 'app-test',
template: `
<div appZIndex appBrightness class="z-2">ABCDEFGHI</div>
`,
styles: [`
.z-2 {
z-index: 2;
}
`]
})
class MockComponent {
}
describe('Test z-index directive', () => {
let fixture: ComponentFixture<MockComponent>;
let component: MockComponent;
beforeEach(() => {
// 未知原因,初始化应该在每一次测试前重新进行!
TestBed.configureTestingModule({
declarations: [MockComponent, ZIndexDirective, BrightnessDirective]
});
fixture = TestBed.createComponent(MockComponent);
component = fixture.componentInstance;
fixture.detectChanges();
})
it('Should render success', () => {
expect(component).toBeTruthy();
})
it('Should has the right initial value of z-index', () => {
const _a = fixture.nativeElement.querySelector('.z-2');
expect(getComputedStyle(_a).zIndex).toBe('2');
})
it('Should has the right value of z-index when mouse enters', () => {
const element = fixture.debugElement.query(By.directive(ZIndexDirective)).nativeElement;
const mouseEnterEvent = new MouseEvent('mouseenter', {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(mouseEnterEvent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.z-2').style.zIndex).toBe('0');
})
it('Should has the right value of z-index when mouse leaves', (() => {
const element = fixture.debugElement.query(By.directive(ZIndexDirective)).nativeElement;
const mouseEnterEvent = new MouseEvent('mouseenter', {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(mouseEnterEvent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.z-2').style.zIndex).toBe('0');
const mouseLeaveEvent = new MouseEvent('mouseleave', {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(mouseLeaveEvent);
fixture.detectChanges();
expect(getComputedStyle(fixture.nativeElement.querySelector('.z-2')).zIndex).toBe('2');
}))
it('Should has the right value of brightness when mouse enters', () => {
const element = fixture.debugElement.query(By.directive(ZIndexDirective)).nativeElement;
const mouseEnterEvent = new MouseEvent('mouseenter', {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(mouseEnterEvent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.z-2').style.filter).toBe('brightness(0.7)');
})
it('Should has the right value of brightness when mouse leaves', (() => {
const element = fixture.debugElement.query(By.directive(ZIndexDirective)).nativeElement;
const mouseEnterEvent = new MouseEvent('mouseenter', {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(mouseEnterEvent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.z-2').style.zIndex).toBe('0');
const mouseLeaveEvent = new MouseEvent('mouseleave', {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(mouseLeaveEvent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.z-2').style.filter).toBe('none');
}))
afterEach(() => {
// fixture = null;
})
})