angular提供非常友好的单元测试,特别是对于组件。使用ng g
命令即可生成一个高度可以测试的组件。而指令的单元测试样板代码便显得有些简陋了。
面临问题
angular对指令测试的样板代码更接近于service的样板测试代码:
describe('TestDirective', () => {
it('should create an instance', () => {
const directive = new TestDirective();
expect(directive).toBeTruthy();
});
});
这好像是在说:你应该像测试service一样来测试指令。而指令的作用更多的是对宿主进行一些变更,所以我们在单元测试时需要一个获取到这么一个宿主。
当查阅angular在官方文档预在指令测试上获取更多信息时却提到以下提示信息:
那么问题来了,在进行指令测试时,是应该遵从ng g
生成的样本代码进行指令实例的测试呢?还是应该遵从官方文档构建人造测试组件,由对组件的测试间接完成对指令的测试呢。
分析问题
官方文档只所以提出创建人造测试组件,其原因是:只有构建一个真实的组件,才能够获取到指令要操作的DOM。
比如有如下指令:
@Directive({
selector: '[appQuestion]'
})
export class QuestionDirective {
@Input()
set appQuestion(question: any) {
this.setQuestion(question);
}
constructor(private elementRef: ElementRef) {
}
}
构造函数中需要的elementRef
是个DOM的引用,虽然我们可以手动创建一个ElementRef
对象出来,但却无法直接的感受指令对其产生的影响。
解决方案
我们当前两个基本的诉求:
- 获取一个可用可观察的
ElementRef
。 - 实例化被测指令
指令依赖于ElementRef
,ElementRef
的获取方式有多种:
直接创建
可以使用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();
});
然后比如我们为指令添加一些方法:
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');
});
虽然本方法可以起到测试指令的作用,但直接由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
指令,所以该指令已经生效。
此时我们便可以像官方的参考文档一样,对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.
- Any class with the @Component or @Directive decorator
- A template reference variable as a string (e.g. query <my-component #cmp></my-component> with @ViewChild('cmp'))
- Any provider defined in the child component tree of the current component (e.g. @ViewChild(SomeService) someService: SomeService)
- Any provider defined through a string token (e.g. @ViewChild('someToken') someTokenVal: any)
- A TemplateRef (e.g. query <ng-template></ng-template> with @ViewChild(TemplateRef) template;)
由上文可知@ViewChild不仅能够根据字符串得到一个template的引用,还可以获取到组件、指令、provider以及TemplateRef。
在构建动态组件时,有时候我们需要一个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
生成的样本代码并不统一,获取一些组件自动注入的对象又不是那么直接等原因,这均为指令的测试增加了少许难度。
不过一旦我们掌握了单元测试的手法,便会有一种一通百通的感觉。整个过程过后,你将感叹不虚此行。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。