你是否曾因编写 .spec.ts
文件时出现大量重复代码而感到烦恼?我将向你展示如何用更少的重复代码实现单元测试。
重现问题
我们从一段示例代码开始:
ts
// app.component.ts
import { Component } from "@angular/core";
import { RouterOutlet } from "@angular/router";
import { UserService } from "./core/services/user.service";
@Component({
selector: "app-root",
imports: [RouterOutlet],
templateUrl: "./app.component.html",
styleUrl: "./app.component.scss",
})
export class AppComponent {
title = "angular-playground";
constructor(private userService: UserService) {
this.isAdmin();
}
isAdmin() {
return this.userService.isAdmin();
}
}
ts
// user.service.ts
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class UserService {
constructor(private httpClient: HttpClient) {}
isAdmin() {
return false;
}
}
ts
// app.component.spec.ts
import { TestBed } from "@angular/core/testing";
import { AppComponent } from "./app.component";
describe("AppComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it("should create the app", () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});
我们在 AppComponent
中使用了 UserService
,并在构造函数中调用了 isAdmin()
方法。而 UserService
又依赖于 HttpClient
。这个 spec 文件(app.component.spec.ts
)是 Angular 自动生成的,未经过任何修改。
运行单元测试时,你会遇到类似以下错误:
NullInjectorError: R3InjectorError(DynamicTestModule)[UserService -> HttpClient -> HttpClient]:
NullInjectorError: No provider for HttpClient!
只要存在一个无法自动初始化的依赖项,这种错误就会频繁发生。
解决方法有很多种,当然,在这个例子中,你可以通过在测试文件中导入 HttpClientTestingModule
来解决这个问题,看起来很简单。但我不想这样做。为了演示目的,我会假设一个更复杂或更现实的情况。但最终,我的解决方案相比导入 HttpClientTestingModule
的方式仍然更加简洁。
我假设你想对 UserService
进行 spy 或 mock,并在测试中使用它。在这种情况下,代码可以如下:
ts
import { TestBed } from "@angular/core/testing";
import { AppComponent } from "./app.component";
import { UserService } from "./core/services/user.service";
describe("AppComponent", () => {
let userServiceSpy: jasmine.SpyObj<UserService>;
beforeEach(() => {
userServiceSpy = jasmine.createSpyObj(["isAdmin"]);
});
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [{ provide: UserService, useValue: userServiceSpy }],
imports: [AppComponent],
}).compileComponents();
});
it("should create the app", () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it("should isAdmin return true", () => {
const fixture = TestBed.createComponent(AppComponent);
userServiceSpy.isAdmin.and.returnValue(true);
expect(fixture.componentInstance.isAdmin()).toEqual(true);
});
});
你可以添加更多依赖于 UserService
API 不同返回值的测试用例,例如:
ts
it("should isAdmin return false", () => {
const fixture = TestBed.createComponent(AppComponent);
userServiceSpy.isAdmin.and.returnValue(false);
expect(fixture.componentInstance.isAdmin()).toEqual(false);
});
问题是:每次你在其他测试文件 mock UserService
时,都需要重复类似的初始化代码,例如
ts
let userServiceSpy: jasmine.SpyObj<UserService>;
beforeEach(() => {
userServiceSpy = jasmine.createSpyObj(["isAdmin"]);
});
[{ provide: UserService, useValue: userServiceSpy }];
即使你不打算使用这个 spy,只要测试因为新加入的服务依赖项而抛出错误,你也必须加上上面最后一行 { provide: UserService, useValue: jasmine.createSpyObj(["isAdmin"]) }
才能让组件初始化成功。
这确实有点烦人。
解决方案
首先,创建 src/test.ts
文件,因为在最新的 Angular 项目中,默认并没有这个文件。如果你已经存在该文件,请根据下面的代码进行修改以适配你的项目。
ts
// src/test.ts
import { NgModule } from "@angular/core";
import { getTestBed } from "@angular/core/testing";
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting,
} from "@angular/platform-browser-dynamic/testing";
import { UnitTestCommonModule } from "./testing/common.module";
@NgModule({
providers: [],
imports: [BrowserDynamicTestingModule],
})
class UnitTestRequiredModule {}
getTestBed().initTestEnvironment(
[UnitTestRequiredModule, UnitTestCommonModule],
platformBrowserDynamicTesting()
);
ts
// src/testing/common.module
import { NgModule } from "@angular/core";
import { provideUserServiceSpy } from "./user-service-spy";
@NgModule({
providers: [provideUserServiceSpy()],
imports: [],
})
export class UnitTestCommonModule {}
ts
// src/testing/user-service-spy.ts
import { UserService } from "../app/core/services/user.service";
export const userServiceSpy = jasmine.createSpyObj<UserService>(["isAdmin"]);
export const provideUserServiceSpy = () => ({
provide: UserService,
useValue: userServiceSpy,
});
我们将 userServiceSpy
提供给 UnitTestCommonModule
,并在 initTestEnvironment
时导入该模块。流程大致为: test.ts --> initTestEnvironment --> UnitTestCommonModule --> userServiceSpy
其次,在 angular.json
文件中注册 src/test.ts
,以便 Angular 能识别它。旧版本的 Angular 项目可能已经包含该配置。
json
{
//...
"projects": {
"angular-playground": {
"architect": {
//...
"test": {
//...
"options": {
"main": "src/test.ts"
//...
}
}
}
}
}
}
同时,将 src/test.ts
添加到 tsconfig.spec.json
的 include
字段中:
json
{
//...
"include": ["src/test.ts" /*...*/]
}
完成上述操作后,初始的 spec 文件无需任何修改即可正常运行 。即使你想使用 mock,也只需要引用 userServiceSpy
即可。例如:
ts
import { TestBed } from "@angular/core/testing";
import { AppComponent } from "./app.component";
import { userServiceSpy } from "../testing/user-service-spy";
describe("AppComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it("should create the app", () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it("should isAdmin return true", () => {
const fixture = TestBed.createComponent(AppComponent);
userServiceSpy.isAdmin.and.returnValue(true);
expect(fixture.componentInstance.isAdmin()).toEqual(true);
});
});
对于其他依赖 UserService
的测试文件,你也无需再手动创建 mock。
至于最初提到的在 spec 文件中导入 HttpClientTestingModule
的做法,你可以将其添加进 UnitTestCommonModule
中。这样之后,所有测试文件都不再需要单独导入 HttpClientTestingModule
。
最后说明
以上更改已在 @angular/[email protected]
环境下验证通过。
未来,由于我们某种程度上依赖 Angular 当前的测试执行机制,该方案可能需要根据 Angular 的更新进行调整。