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

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

相关推荐
zach23 分钟前
前端落地实战:Next.js 项目 Nginx + PM2 生产部署全流程(附踩坑总结)
前端·pm2·next.js
overmind32 分钟前
oeasy教h5前端001 修改的乐趣_直接修改在线网页
前端
只睡四小时37 分钟前
JS 手写 D-pad 空间导航:电视端焦点引擎实战
android·开发语言·前端·javascript·ecmascript·hls·空间导航
恋猫de小郭40 分钟前
Android 原生的 Compose A2UI 也来了,你还抱着 XML 养老吗?
android·前端·flutter
庄园特聘拆椅狂魔41 分钟前
从连连看到粒子消散——前端小游戏与视觉特效的技术选型
前端
JudithHuang1 小时前
React 常用 Hooks
前端·react.js·前端框架
sycmancia2 小时前
Qml——Window元素使用
前端
每天都好困2 小时前
03 AR 投影与误差修正:把地图上的点贴到视频画面上
前端
每天都好困2 小时前
04 地图图层封装:像管理 Photoshop 图层一样管理地图元素
前端
去伪存真2 小时前
构建你的第一个 DevOps AI Agent:自动化捕获、分析并修复 CI 故障
前端·agent