Angular 指令单元测试

141 阅读6分钟

本文介绍如何对 Angular directive 进行单元测试,其中有一些非常坑的点我都踩过了,希望能够帮助到你!

1.测试 Directive 的思路:

  1. 首先明确测试目标是 Directive,因此除了 Directive 本身,其他部分(如 Components)都可以进行 Mock
  2. 在测试中,最有可能需要 Mock 的是 Component,因为 Directive 需要依附于 Component 才能生效。
  3. 测试的基本思路是:先 Mock 一个使用了待测 DirectiveComponent,然后配置编译环境,最后获取 Mock Component 的实例。
  4. 最后,从视图(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 方法来配置所需的环境。

这里需要注意的有两点:

  1. 我们只需要配置 declarations 就可以了,因为 declarations 中声明的都是【本 module 自己的组成内容】
  2. 我们需要在每次测试开始前,也就是在 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.

  1. 找到这个元素,从 fixture 出发,有两个路线,或一开始就使用 fixture.nativeElement.querySelector 或通过 fixture.debugElement.query(By.*(*).nativeElement) 的方式。由于我们在测试指令所以 By.*(*) 可以换成 By.directive(ZIndexDirective) 也就是通过指令来查询。
  2. 然后我们制作 fake mouseEvent,这个很简单,如下所示:
  3. 最后通过 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. 设计测试用例

我们将测试用例设计成如下几种: 以下是使用列表方式展示的测试用例:

  1. 渲染测试

    • 描述: 验证组件是否成功渲染。
    • 测试用例: Should render success
    • 预期结果: 组件实例应该为真值。
  2. 初始 z-index 值测试

    • 描述: 验证元素初始时的 z-index 值是否正确。
    • 测试用例: Should has the right initial value of z-index
    • 预期结果: .z-2 类的 z-index 应为 '2'。
  3. 鼠标进入时的 z-index 值测试

    • 描述: 验证鼠标进入时元素的 z-index 值是否正确变化。
    • 测试用例: Should has the right value of z-index when mouse enters
    • 预期结果: 鼠标进入后 .z-2 类的 z-index 应变为 '0'。
  4. 鼠标离开时的 z-index 值测试

    • 描述: 验证鼠标离开时元素的 z-index 值是否恢复。
    • 测试用例: Should has the right value of z-index when mouse leaves
    • 预期结果: 鼠标离开后 .z-2 类的 z-index 应恢复为 '2'。
  5. 鼠标进入时的亮度值测试

    • 描述: 验证鼠标进入时元素的亮度值是否正确变化。
    • 测试用例: Should has the right value of brightness when mouse enters
    • 预期结果: 鼠标进入后 .z-2 类的亮度应设置为 'brightness(0.7)'。
  6. 鼠标离开时的亮度值测试

    • 描述: 验证鼠标离开时元素的亮度值是否恢复。
    • 测试用例: 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;
  })
})