1

angular提供非常友好的单元测试,特别是对于组件。使用ng g命令即可生成一个高度可以测试的组件。而指令的单元测试样板代码便显得有些简陋了。

面临问题

angular对指令测试的样板代码更接近于service的样板测试代码:

describe('TestDirective', () => {
  it('should create an instance', () => {
    const directive = new TestDirective();
    expect(directive).toBeTruthy();
  });
});

这好像是在说:你应该像测试service一样来测试指令。而指令的作用更多的是对宿主进行一些变更,所以我们在单元测试时需要一个获取到这么一个宿主。

当查阅angular在官方文档预在指令测试上获取更多信息时却提到以下提示信息:
image.png

那么问题来了,在进行指令测试时,是应该遵从ng g生成的样本代码进行指令实例的测试呢?还是应该遵从官方文档构建人造测试组件,由对组件的测试间接完成对指令的测试呢。

分析问题

官方文档只所以提出创建人造测试组件,其原因是:只有构建一个真实的组件,才能够获取到指令要操作的DOM。

比如有如下指令:


@Directive({
  selector: '[appQuestion]'
})
export class QuestionDirective {

  @Input()
  set appQuestion(question: any) {
    this.setQuestion(question);
  }

  constructor(private elementRef: ElementRef) {
  }
}

构造函数中需要的elementRef是个DOM的引用,虽然我们可以手动创建一个ElementRef对象出来,但却无法直接的感受指令对其产生的影响。

解决方案

我们当前两个基本的诉求:

  1. 获取一个可用可观察的ElementRef
  2. 实例化被测指令

指令依赖于ElementRefElementRef的获取方式有多种:

直接创建

可以使用document对象直接创建一个dom对象出来,然后再实例化出一个ElementRef供指令实例化:

  fit('手动创建', () => {
    expect(component).toBeTruthy();
    const nativeElement = document.createElement('div');
    nativeElement.innerHTML = '<h1>Hello World</h1>';
    const elementRef = new ElementRef(nativeElement);
    const directive = new QuestionDirective(elementRef);
    expect(directive).toBeTruthy();
  });

image.png

然后比如我们为指令添加一些方法:

export class QuestionDirective implements OnInit {

  @Input()
  set appQuestion(question: any) {
  }

  constructor(private elementRef: ElementRef) {
  }

  ngOnInit(): void {
    this.elementRef.nativeElement.style.color = 'red';
  }
}

然后便可以进行相应的测试了:

  fit('手动创建', () => {
    expect(component).toBeTruthy();
    const nativeElement = document.createElement('div');
    nativeElement.innerHTML = '<h1>Hello World</h1>';
    const elementRef = new ElementRef(nativeElement);
    const directive = new QuestionDirective(elementRef);
    document.body.append(nativeElement);
    expect(directive).toBeTruthy();

    // 测试ngOnInit方法
    directive.ngOnInit();
    expect(nativeElement.style.color).toEqual('red');
  });

image.png

虽然本方法可以起到测试指令的作用,但直接由document创建的组件明显的脱离的angular,除非我们对angular有相当的了解,否则应该规避这样做。

@ViewChild

angular提供的@ViewChild可以快速的获取到相关的引用,所以我们也可以创建一个测试组件,然后使用@ViewChild来获取到ElementRef

@Component({
  template: `<div #elementRef>Hello World</div>`
})
class MockComponent implements AfterViewInit {
  @ViewChild('elementRef')
  elementRef: ElementRef;

  ngAfterViewInit(): void {
    console.log(this.elementRef);
  }
}

describe('QuestionDirective', () => {
  let fixture: ComponentFixture<MockComponent>;
  let component: MockComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [MockComponent],
      imports: [
        CommonModule
      ]
    })
      .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(MockComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  
  fit('依赖于angular创建', () => {
    
  });
});

此时便可以由被测组件中获取到ElementRef了:

  fit('依赖于angular创建', () => {
    const directive = new QuestionDirective(component.elementRef);

    // 调用onInit方法
    directive.ngOnInit();
    expect(component.elementRef.nativeElement.style.color).toEqual('red');
  });

此种方法看起来的确有些麻烦,但却可以使用ComponentFixture等一系列特有功能,更重要是这更加贴近于组件测试。使指令与组件测试看起来差不多。

此方案虽然可以获取到一个由angular为我们创建的ElementRef,但并没有完全模拟真实的环境,在真实的环境中,我们往往要这么用 ---- <div [appQuestion]="question" #elementRef>Hello World!</div>

下面我们将继续讨论如何获取这个有宿主的指令实例。

综合方案

我们稍微修正一测试组件,使其成为QuestionDirective的宿主:

@Component({
  template: `
    <div [appQuestion]="question" #elementRef>Hello World!</div>`
})
class MockComponent implements AfterViewInit {
  @ViewChild('elementRef')
  elementRef: ElementRef;

  question = {};

  ngAfterViewInit(): void {
    console.log(this.elementRef);
  }
}

然后我们在单元测试中增加测试方法:

fit('综合方式', () => {
  
});

虽然我们未书写一行代码,但是由于为Mock组件绑定了QuestionDirective指令,所以该指令已经生效。

image.png

此时我们便可以像官方的参考文档一样,对Mock组件调用相关方法来完成相应效果的测试:

fit('综合方式', () => {
  expect(fixture.debugElement.query(By.css('#root0 > div'))
    .nativeElement.textContent).toEqual('Hello World!');
});

同时同样可以使用ElementRef来构建指令实例来完成相应功能的测试:

  fit('综合方式', () => {
    expect(fixture.debugElement.query(By.css('#root0 > div')).nativeElement.textContent).toEqual('Hello World!');
    
    const directive = new QuestionDirective(component.elementRef);
    directive.ngOnInit();
    expect(component.elementRef.nativeElement.style.color).toEqual('red');
  });

动态组件(指令)

动态组件的构造依赖于ViewContainerRef。虽然stackoverflow上的一篇文章提出了可以手动写一个测试专用的ViewContainerRef,但并不是一个好主意。因为一旦这样做,假的ViewContainerRef无法提供真实的DOM功能,从而无法在单元测试直观地观察DOM的变化,变成睁眼瞎。

由于ViewContainerRef是个抽像类,所以我们无法向ElementRef一样手动地实例化一个。这时候便要细说下这个@ViewChild的作用了。

其实@ViewChild的作用并不是绑定ElementRef,而是绑定Ref,即引用,参阅官方文档可知这仅只其使用的方法之一:

The following selectors are supported.

  1. Any class with the @Component or @Directive decorator
  2. A template reference variable as a string (e.g. query <my-component #cmp></my-component> with @ViewChild('cmp'))
  3. Any provider defined in the child component tree of the current component (e.g. @ViewChild(SomeService) someService: SomeService)
  4. Any provider defined through a string token (e.g. @ViewChild('someToken') someTokenVal: any)
  5. A TemplateRef (e.g. query <ng-template></ng-template> with @ViewChild(TemplateRef) template;)

由上文可知@ViewChild不仅能够根据字符串得到一个template的引用,还可以获取到组件、指令、provider以及TemplateRef。

image.png

在构建动态组件时,有时候我们需要一个ViewContainerRef,做为装载组件的容器:

  constructor(private elementRef: ElementRef,
              private viewContainerRef: ViewContainerRef) {
  }

此时我们可以在@ViewChild上加入{read: ViewContainerRef}来获取:

@Component({
  template: `
    <ng-template #viewContainerRef></ng-template>
  `
})
class MockComponent implements AfterViewInit {

  @ViewChild('viewContainerRef', {read: ViewContainerRef})
  viewContainerRef: ViewContainerRef;

有了ViewContainerRef,便可以轻松的实例化出指令了:

const directive = new QuestionDirective(component.elementRef, component.viewContainerRef);

依赖服务

如果我们的指令需要依赖于某些服务:

  constructor(private elementRef: ElementRef,
              private viewContainerRef: ViewContainerRef,
              private testService: TestService) {
  }

此时若要实例化指令,则还需要服务相关的实例,除了可以手动实例化服务实例以下,还可以使用TestBed.get()来获取:

const testService = TestBed.inject(TestService);
const directive = new QuestionDirective(component.elementRef,
                  component.viewContainerRef,
                  testService);
注意:angular10中弃用了TestBed.get,改为了TestBed.inject()

总结

angular只所以提供了强大的单元测试功能,是因为它有用。虽然我们的确需要在单元测试上花费更多的功夫,但一旦掌握了单元测试的技能。你将能做到不依赖于后台的前台开发、自定义依赖的前台开发,这使得我们单独开发某一个前台成为了现实,使我们在开发过程中完全的脱离后台、脱离数据库、脱离ng serve,仅仅需要ng t

在整个测试过程中指令与组件结合的非常紧密,由于官方的文档与ng g d xx生成的样本代码并不统一,获取一些组件自动注入的对象又不是那么直接等原因,这均为指令的测试增加了少许难度。

不过一旦我们掌握了单元测试的手法,便会有一种一通百通的感觉。整个过程过后,你将感叹不虚此行。


潘杰
3.1k 声望238 粉丝