FORMA

测试

Angular CLI 默认使用 Karma + Jasmine 运行单元测试;也可按团队规范迁移 JestVitest(需自行配置,非 CLI 默认)。

核心概念

Angular 的测试体系围绕 TestBed 展开:它会创建一个隔离的、临时的「测试用 Angular 模块」,把待测组件、依赖的服务/管道/指令都注册进去,然后生成一个 ComponentFixture(组件的测试宿主),提供访问组件实例、DOM 节点、触发变更检测的能力。理解 TestBed 的生命周期是写好 Angular 单测的基础。

默认单元测试(TestBed)

ts
import { TestBed } from "@angular/core/testing";
import { CounterComponent } from "./counter.component";

describe("CounterComponent", () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [CounterComponent],
    }).compileComponents();
  });

  it("点击后计数增加", () => {
    const fixture = TestBed.createComponent(CounterComponent);
    fixture.detectChanges();
    const button: HTMLButtonElement = fixture.nativeElement.querySelector("button")!;
    button.click();
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain("1");
  });
});
  • TestBed:创建隔离的 Angular 测试模块。
  • fixture.detectChanges():触发变更检测,更新 DOM。每次修改了会影响视图的数据后都要重新调用,否则断言读到的是旧 DOM。

服务的单元测试

服务通常不依赖 DOM,可以直接 new 或通过 TestBed.inject 获取,测试更轻量:

ts
describe("CartService", () => {
  let service: CartService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(CartService);
  });

  it("add 后 total 正确计算", () => {
    service.add({ id: "1", price: 10 } as CartItem);
    service.add({ id: "2", price: 20 } as CartItem);
    expect(service.total()).toBe(30);
  });
});

模拟依赖

ts
TestBed.configureTestingModule({
  imports: [UserListComponent],
  providers: [{ provide: UserApi, useValue: { list: () => of([{ id: 1, name: "Test" }]) } }],
});

对于依赖 HttpClient 的服务,推荐使用官方提供的 HttpClientTestingModule(或新版 provideHttpClientTesting)拦截真实请求:

ts
import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing";
import { provideHttpClient } from "@angular/common/http";

TestBed.configureTestingModule({
  providers: [provideHttpClient(), provideHttpClientTesting()],
});

const httpMock = TestBed.inject(HttpTestingController);

it("请求了正确的接口", () => {
  service.getUser(1).subscribe();
  const req = httpMock.expectOne("/api/users/1");
  req.flush({ id: 1, name: "Test" });
  httpMock.verify(); // 确保没有未处理的请求
});

异步与信号测试

ts
import { fakeAsync, tick, waitForAsync } from "@angular/core/testing";

it("debounce 后才发起搜索", fakeAsync(() => {
  component.onInput("abc");
  tick(300); // 快进虚拟时钟 300ms,触发 debounceTime
  expect(component.results().length).toBeGreaterThan(0);
}));

it("异步初始化完成后渲染数据", waitForAsync(async () => {
  fixture.detectChanges();
  await fixture.whenStable();
  expect(fixture.nativeElement.textContent).toContain("加载完成");
}));

对基于 Signal 的组件,测试通常更简单:直接调用 signal.set()detectChanges(),无需处理 Zone/Observable 的异步细节。

端到端测试

CLI 可选用 PlaywrightCypress 脚手架(ng e2e 相关方案以当前 Angular 测试指南 为准):

bash
ng add @playwright/test    # 或社区/官方推荐的当前方案
npx playwright test
ts
// e2e 示例(Playwright)
test("登录后跳转到首页", async ({ page }) => {
  await page.goto("/login");
  await page.fill('input[name="email"]', "a@b.com");
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL("/home");
});

建议

  • 行为与 DOM 结果,而非私有字段。
  • 异步使用 fakeAsync + tickwaitForAsync / async 工具。

最佳实践

  • 优先测「用户能看到/操作的行为」(点击按钮后文案变化),少测组件私有实现细节(内部变量、私有方法)。
  • HTTP 依赖统一走 HttpTestingController 模拟,不要在单测里发真实网络请求。
  • 组件测试关注渲染结果与交互结果,业务逻辑复杂的部分尽量抽到独立的、更容易测的 Service/纯函数中。
  • CI 中固定 Node/浏览器(Headless Chrome)版本,避免「本地通过、CI 挂」的环境差异问题。
  • 逐步为核心业务组件补齐测试,而非追求一次性 100% 覆盖率,优先覆盖高风险路径(支付、登录、权限判断)。

常见坑

现象常见原因处理
断言读到旧的 DOM 内容数据变化后忘记调用 fixture.detectChanges()每次影响视图的操作后都调用
fakeAsync 测试报「还有未清理的计时器」组件内计时器未被 tick 完全消费或未清理tick(足够时长),或在测试后清理订阅
HTTP 测试报「预期恰好一次请求」失败组件触发了多次或零次请求检查触发条件,必要时用 expectOne 前先确认请求次数
测试之间状态串扰服务是 providedIn: 'root' 单例,测试间未重置每个 it 前用 beforeEach 重新 configureTestingModule
CI 上 Karma 报浏览器无法启动缺少 Headless Chrome 或相关依赖使用官方 CI 镜像或配置 ChromeHeadlessCI launcher

延伸阅读

参考文献

以下链接在编写时均可正常访问:

资料说明
Angular 测试指南官方
TestBedAPI
Jasmine 文档断言库
HttpClientTestingModuleHTTP 模拟
Playwright端到端测试

Series

angular

14 / 14

状态管理