3.3 测试:用 fixture 驱动变更检测 Angular 测试分两层:服务测试是纯 TypeScript 单元测试,零框架设施;组件测试借助 ComponentFixture,由测试代码手动驱动变更检测。主线上这一节的特殊价值:测试环境里 Zone 触发的自动检查被剥离,detectChanges 就是检查的显式开关——反过来加深对机制的理解。 学习目标 为服务编写单元测试,用依赖注入令牌替换真实依赖。 用 TestBed 创建组件测试,理解 fixture.detectChanges 与 autoDetect 的边界。 测试输入变化驱动的子组件更新(OnPush 组件的测试写法差异)。 编写基于 fakeAsync 的时间控制测试。 一、服务测试:纯函数式的幸福 第 2.
Angular 测试分两层:服务测试是纯 TypeScript 单元测试,零框架设施;组件测试借助 ComponentFixture,由测试代码手动驱动变更检测。主线上这一节的特殊价值:测试环境里 Zone 触发的自动检查被剥离,detectChanges 就是检查的显式开关——反过来加深对机制的理解。
第 2.9 节的购物车仓库,测试不需要渲染任何东西:
import { TestBed } from '@angular/core/testing'; import { CartStore } from './cart.store'; describe('CartStore', () => { let store: CartStore; beforeEach(() => { TestBed.configureTestingModule({}); // 纯服务其实可以完全绕开 TestBed store = TestBed.inject(CartStore); // 但统一走它,未来加依赖不用改结构 }); it('新增商品应出现条目并计数为一', () => { store.add('SKU-1'); expect(store.snapshot).toEqual([{ sku: 'SKU-1', qty: 1 }]); expect(store.count()).toBe(1); }); it('重复添加应数量加一而非新增条目', () => { store.add('SKU-1'); store.add('SKU-1'); expect(store.snapshot.length).toBe(1); expect(store.snapshot[0].qty).toBe(2); }); });
服务测试的价值在 2.7 节已埋过伏笔:响应式表单模型、校验器、状态仓库都是纯逻辑,测试毫秒级、稳定、不涉及界面。Angular 项目的测试金字塔应该大头在这层——这也是把逻辑从组件抽到服务的最硬理由。
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { OrderRowComponent } from './order-row.component'; describe('OrderRowComponent', () => { let fixture: ComponentFixture<OrderRowComponent>; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [OrderRowComponent] }) .compileComponents(); fixture = TestBed.createComponent(OrderRowComponent); }); it('输入变化后界面应显示新金额', () => { const comp = fixture.componentInstance; comp.order = { orderNo: 'A-1', amount: 199 }; // 改数据 // 此刻 DOM 还是旧的——因为还没人触发检查! expect(fixture.nativeElement.querySelector('li').textContent).toContain('199'); // ❌ 会失败 fixture.detectChanges(); // 手动执行一轮检查 expect(fixture.nativeElement.querySelector('li').textContent).toContain('199'); // ✅ }); });
被注释标记的失败断言是理解本节的钥匙:组件测试环境里没有 Zone 兜底,改完数据界面纹丝不动,直到显式调用检查。这从反面证明了前两章反复强调的因果链——数据变化与界面更新之间,靠的是"检查"这个环节,而日常开发里它被 Zone 自动化了。
OnPush 组件的测试差异也顺理成章:普通写法下 fixture.detectChanges() 会强制从根检查,OnPush 的"跳过"行为要测 Input 变化场景才有意义——父组件绑定表达式产出新引用,检查传播到子组件。
import { fakeAsync, tick } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { SearchComponent } from './search.component'; describe('SearchComponent 防抖', () => { it('300ms 内连续输入只发一次查询', fakeAsync(() => { const fixture = TestBed.createComponent(SearchComponent); const comp = fixture.componentInstance; comp.onInput('手'); // 高频输入 comp.onInput('手机'); tick(299); // 时间快进 299ms:仍在防抖窗口内 expect(comp.queryCount).toBe(0); tick(1); // 跨过 300ms 门槛 expect(comp.queryCount).toBe(1); // 只有一次提交 })); });
fakeAsync 把异步时间线拉平成同步断言,不用真的等 300 毫秒,也不用 jasmine 的 done 回调。它同样从测试侧印证主线:防抖操作符挡在"数据到达"与"变更提交"之间,tick 就是把这条时间线手动推过门槛。
⚠️ 坑:fakeAsync 里不能混入真实 promise 的未决任务之外的定时器实现;测试里遇到的间歇性失败,先查是否有测试外的真实异步漏了 tick 或 flush。

背景:团队把订单行组件改造成 OnPush 后,某次迭代父组件引入了原地修改的"便捷函数",测试全绿但线上出现行数据不刷新。决定用测试把契约固化。
操作:编写组件级回归测试:
it('父组件不可变更新后 OnPush 行应刷新', () => { const parent = TestBed.createComponent(OrderPageComponent); parent.detectChanges(); parent.componentInstance.refreshRow('A-1', { amount: 250 }); // 走正规更新方法 parent.detectChanges(); const row = parent.nativeElement.querySelector('app-order-row li'); expect(row.textContent).toContain('250'); });
结果:后来有人把 refreshRow 改回原地修改,该测试立即变红——契约有守卫了。
解读:OnPush 的正确性依赖跨组件的数据纪律,普通类型检查防不住(类型仍是同一个对象类型),组件级集成测试是唯一可靠的守卫层。
变式:把这类测试扩展为"每个 OnPush 组件配一条刷新断言"的团队规范,改造成本一次性付清。
下一节进入工程边界专题:安全。