Angular 中的单元测试 - 要不要使用 TestBed?
我最近开始为一位新客户提供咨询服务(请不要透露客户姓名)。在开发新功能并编写单元测试的过程中,我注意到几个问题。首先,编写测试比预想的要困难得多(稍后我会详细说明),其次,测试运行器运行速度非常慢。
当我开始深入研究测试时,我注意到我的单元测试与之前在应用程序中其他部分编写的测试存在差异。我发现我使用了TestBed来创建测试。而应用程序的其他地方都没有使用 TestBed。这让我感到非常奇怪,因为我过去一直使用 TestBed,而且性能也从未出现过问题。
这促使我进一步研究这个话题,看看Angular社区里是否还有其他人没有使用TestBed。我没找到太多相关的文章,但找到了一期Angular Show播客节目, Joe Eames和Shai Reznik在节目中就TestBed的优缺点展开了非常精彩的讨论。我不会剧透,但我必须承认,对于一个每天都使用Angular的人来说,这是我第一次听到不使用TestBed的理由(而且还是一个很有说服力的理由)。
尽管我当时还心存疑虑,但还是决定在这个项目中尝试一下,看看效果如何。结果,这种方法带来的性能提升让我大吃一惊。这促使我去探究其中的原因……最终,也正是这个问题促成了这篇博客文章的诞生。
表现
当你从组件规范文件中移除 TestBed 时,它实际上不再测试 DOM,而只测试组件类本身。起初我觉得这有点不妥,但仔细思考后,我意识到真正的单元测试应该只测试一个代码单元。组件的 HTML 模板与其组件类之间的交互实际上变成了集成测试,测试的是两者之间的集成。
让我再详细解释一下。当你使用 Angular CLI 生成一个新组件时,ng g c my-feature它会渲染以下文件:
my-feature.component.htmlmy-feature.component.scssmy-feature.component.tsmy-feature.component.spec.ts
打开my-feature.component.spec.ts文件后,我们会看到以下内容:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MyFeatureComponent } from './my-feature.component';
describe('MyFeatureComponent', () => {
let component: MyFeatureComponent;
let fixture: ComponentFixture<MyFeatureComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ MyFeatureComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MyFeatureComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
本质上,每次测试之前都会创建一个新的 MyFeatureComponent 类实例和 DOM。这个例子很简单,但在拥有数百个组件的应用程序中,每次测试都生成 DOM 会非常耗费资源。
没有测试平台
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MyFeatureComponent } from './my-feature.component';
describe('MyFeatureComponent', () => {
let component: MyFeatureComponent;
beforeEach(() => {
component = new MyFeatureComponent()
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
每次测试前只需创建一个新的MyFeatureComponent类实例,它就会创建类实例,而不会访问 DOM 本身。
依赖关系呢?
假设我们的组件现在有两个依赖项,一个依赖于 A UserService,另一个依赖于 B。MyFeatureService我们该如何编写需要提供依赖项的测试呢?
带测试平台
@angular/core/testing';
import { MyFeatureComponent } from './my-feature.component';
import { UserService, MyFeatureService } from 'src/app/services';
describe('MyFeatureComponent', () => {
let component: MyFeatureComponent;
let fixture: ComponentFixture<MyFeatureComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ MyFeatureComponent ],
providers: [UserService, MyFeatureService]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MyFeatureComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
没有测试平台
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MyFeatureComponent } from './my-feature.component';
import { UserService, MyFeatureService } from 'src/app/services';
describe('MyFeatureComponent', () => {
let component: MyFeatureComponent;
const userService = new UserService();
const myFeatureService = new MyFeatureService();
beforeEach(() => {
component = new MyFeatureComponent(userService, myFeatureService);
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
*** 注意:使用此方法时,添加到新的 Component 类实例中的依赖项的顺序必须正确。
如果我的依赖项本身也有依赖项怎么办?
我知道您在查看之前的例子时可能也想到了这一点,因为大多数依赖项本身也存在其他依赖项。例如,一个服务通常会依赖于HttpClient某个组件来向 API 发出网络请求。当这种情况发生时(几乎总是如此),我们通常会使用模拟对象或伪造对象。
带测试平台
@angular/core/testing';
import { MyFeatureComponent } from './my-feature.component';
import { UserService, MyFeatureService } from 'src/app/services';
class FakeMyFeatureService {
}
class FakeUserService {
}
describe('MyFeatureComponent', () => {
let component: MyFeatureComponent;
let fixture: ComponentFixture<MyFeatureComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ MyFeatureComponent ],
providers: [
{ provide: UserService, useClass: FakeUserService },
{ provide: MyFeatureService, useClass: FakeMyFeatureService }
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MyFeatureComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
没有测试平台
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MyFeatureComponent } from './my-feature.component';
import { UserService, MyFeatureService } from 'src/app/services';
class FakeMyFeatureService {
}
class FakeUserService {
}
describe('MyFeatureComponent', () => {
let component: MyFeatureComponent;
const userService = new FakeUserService();
const myFeatureService = new FakeMyFeatureService();
beforeEach(() => {
component = new MyFeatureComponent(userService, myFeatureService);
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
*** 注意:您需要对这些依赖项使用间谍软件来实际测试您关心的组件部分。
较少出现故障的测试
如果没有 TestBed,我们就无法直接测试 DOM,这意味着 DOM 的更改将不再导致测试失败。想想看,你在 Angular 应用中创建组件后,是不是经常遇到测试突然失败的情况?这是因为 TestBed 会创建 DOMbeforeEach测试。当添加组件及其依赖项时,其父组件的测试就会失败。
MyParentComponent让我们通过创建一个名为`<parent>` 的父组件来更深入地了解一下。ng g c my-parent
现在我们来看一下这个my-parent.component.spec.ts文件:
带测试平台
@angular/core/testing';
import { MyParentComponent } from './my-parent.component';
describe('MyParentComponent', () => {
let component: MyParentComponent;
let fixture: ComponentFixture<MyParentComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ MyParentComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MyParentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
没有测试平台
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MyParentComponent } from './my-parent.component';
describe('MyParentComponent', () => {
let component: MyParentComponent;
beforeEach(() => {
component = new MyParentComponent();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
现在让我们将其MyFeatureComponent作为子元素添加到模板中MyParentComponent。
<my-parent>
<my-feature />
</my-parent>
在这个例子中,my-parent.component.spec.ts所有测试都失败了,因为它缺少对其MyFeatureComponent提供程序的声明UserService。MyFeatureService下面是我们需要做的,才能让这些测试恢复正常运行并通过。
带测试平台
@angular/core/testing';
import { MyParentComponent } from './my-parent.component';
import { MyFeatureComponent } from './my-feature/my-feature.component';
import { UserService, MyFeatureService } from 'src/app/services';
class FakeMyFeatureService {
}
class FakeUserService {
}
describe('MyParentComponent', () => {
let component: MyParentComponent;
let fixture: ComponentFixture<MyParentComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ MyParentComponent, MyFeatureComponent ],
providers: [
{ provide: UserService, useClass: FakeUserService },
{ provide: MyFeatureService, useClass: FakeMyFeatureService }
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MyParentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
没有测试平台
其他需要考虑的事项
不测试 DOM 的任何部分会带来一些权衡取舍。最大的弊端在于,我们不再测试 DOM 本身,也不再测试它与其组件类之间的集成。大多数情况下,我们并不特别在意按钮点击时是否会调用其组件类中的方法。我们通常信任 Angular 的(点击)事件绑定机制,相信它能正常工作。因此,我们主要关心的是它调用的方法是否真的能按预期运行。然而,由于我们不再测试这种集成,我们就无法保证团队中的其他开发人员不会意外删除该集成,也无法保证重构后,这个特定的按钮仍然会调用这个特定的方法。
我认为这只是一个相对较小的权衡,而且这类测试用端到端测试(e2e测试)会更合适。我还想指出,这并非一种非此即彼的测试方法。在应用程序中,如果您确实需要测试模板与其类之间的集成,仍然可以使用TestBed。只是对于目前使用TestBed的部分,您将无法再享受到上述优势。
注意:本示例中的 Angular 应用运行在 Angular 版本 7 上。Angular 9 及更高版本现在使用 IVY 渲染应用程序,IVY 针对 TestBed 进行了一些性能改进。
结论
从我们这个简单的例子中可以看出,从 Angular 组件的 spec 文件中移除 TestBed 后,我们可以提升测试运行器的性能,并消除一些不稳定因素。当然,测试速度提升的幅度取决于应用程序的大小和构建方式。组件非常庞大的应用程序(这本身就是一个比较明显的代码异味)将从这种方法中获益最多。最终,不使用 TestBed 编写测试的最大好处在于,您可以真正编写单元测试,而单元测试应该更容易编写、更可靠,并且能够提供非常快速的反馈。编写测试获得的反馈越容易、越可靠、越快速,您就越能充分利用单元测试的优势。
文章来源:https://dev.to/angular/unit-testing-in-angular-to-testbed-or-not-to-testbed-3g3b
