我的 Angular 总结:创建一个通用测试模块,简化单元测试

你是否曾因编写 .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.jsoninclude 字段中:

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/core@19.2.14 环境下验证通过。

未来,由于我们某种程度上依赖 Angular 当前的测试执行机制,该方案可能需要根据 Angular 的更新进行调整。

相关推荐
yume_sibai几秒前
Less Less基础
前端·css·less
小小小小宇1 分钟前
重提Vue3 的 Diff 算法
前端
清岚_lxn1 分钟前
前端js通过a标签直接预览pdf文件,弹出下载页面问题
前端·javascript·pdf
不爱说话郭德纲15 分钟前
别再花冤枉钱!手把手教你免费生成iOS证书(.p12) + 打包IPA(超详细)
前端·ios·app
代码的余温16 分钟前
Vue多请求并行处理实战指南
前端·javascript·vue.js
JohnYan29 分钟前
工作笔记 - NATS的Nkey认证
javascript·后端·rabbitmq
余杭子曰1 小时前
组件设计模式:聪明组件还是傻瓜组件?
前端
杨超越luckly1 小时前
HTML应用指南:利用GET请求获取全国小米之家门店位置信息
前端·arcgis·html·数据可视化·shp
海绵宝龙1 小时前
axios封装对比
开发语言·前端·javascript
Data_Adventure1 小时前
setDragImage
前端·javascript