我的 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 的更新进行调整。

相关推荐
Dorcas_FE16 分钟前
【tips】动态el-form-item中校验的注意点
前端·javascript·vue.js
小小前端要继续努力19 分钟前
前端新人怎么更快的融入工作
前端
八月ouc20 分钟前
解密JavaScript模块化演进:从IIFE到ES Module,深入理解现代前端工程化基石
javascript·es6·模块化·cmd·commonjs·amd·iife
四岁爱上了她25 分钟前
input输入框焦点的获取和隐藏div,一个自定义的下拉选择
前端·javascript·vue.js
fouryears_234171 小时前
现代 Android 后台应用读取剪贴板最佳实践
android·前端·flutter·dart
boolean的主人1 小时前
mac电脑安装nvm
前端
用户1972959188911 小时前
WKWebView的重定向(objective_c)
前端·ios
烟袅1 小时前
5 分钟把 Coze 智能体嵌入网页:原生 JS + Vite 极简方案
前端·javascript·llm
18你磊哥1 小时前
Django WEB 简单项目创建与结构讲解
前端·python·django·sqlite
KangJX1 小时前
iOS 语音房(拍卖房)开发实践
前端·前端框架·客户端