尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Angular单元测试中spy的核心原理与实战指南

Angular单元测试中spy的核心原理与实战指南 1. 为什么 Angular 单元测试里非用 spy 不可——从一个真实报错说起你刚写完一个 Angular 组件调用UserService.getUserById(123)获取用户数据ngOnInit里订阅了 Observable。跑ng test时控制台突然炸出一串红字ERROR: HttpErrorResponse with status 0 Error: Cannot assign to read only property status of object [object Object]这不是代码写错了是测试环境里根本没有 HTTP 服务在跑。Angular 的HttpClient默认依赖真实的HttpBackend而 Karma/Jasmine 测试运行在纯内存环境中没有网络栈、没有后端 API、甚至没有XMLHttpRequest对象。这时候你如果直接调用真实服务测试根本跑不起来——它会卡在请求发起阶段或者抛出不可预测的底层错误。这就是 spy 存在的根本原因它不是“模拟”而是“接管”。Jasmine 的spyOn()不是假装某个方法存在而是把原方法的执行权临时劫持替换成你可控的返回值、行为甚至副作用。它不关心getUserById内部怎么发请求、怎么处理响应体只关心“当这个方法被调用时我能不能断言它被调用了能不能让它返回我想要的数据能不能让它抛出我预设的错误”——这正是单元测试的核心诉求隔离被测单元控制外部依赖验证逻辑而非集成。很多初学者误以为TestBed.inject(UserService)拿到的就是“假服务”其实不然。TestBed.inject()返回的是真实服务实例只是它的依赖比如HttpClient可能被provide替换掉了。但如果你没对UserService本身做任何干预它内部调用的http.get()依然会走真实链路。而 spy 是作用在方法级别的即时拦截哪怕UserService是真实实例只要你在beforeEach里对它的某个方法spyOn(service, getUserById)那后续所有对该方法的调用都会被重定向。我见过太多团队踩坑有人用jasmine.createSpyObj创建整个服务对象结果发现spyObj.getUserById.and.returnValue(of(...))看似正常但实际组件里调用的却是真实服务的方法因为TestBed.configureTestingModule里没把spyObj提供给UserServicetoken也有人在it块里才spyOn()导致fixture.detectChanges()已经触发了组件初始化真实方法早已执行完毕。这些都不是 spy 本身的问题而是没理解它在 Angular 测试生命周期里的位置——它必须在fixture.detectChanges()之前完成声明并且要确保被 spy 的对象是组件实际注入的那个实例。Angular 1.7.3 升级带来的变化更凸显了这一点新版本对OnPush变更检测、异步管道和inject()函数的支持更严格意味着组件对依赖的调用时机更精确对 spy 的声明时机要求也更高。你不能再指望“大概在 beforeEach 里搞一下就行”必须明确知道TestBed.createComponent()创建组件实例后fixture.componentInstance拿到的是谁TestBed.inject()拿到的是谁它们是不是同一个引用。这才是 spy 能起效的前提。2. spy 的三种核心形态与选型逻辑什么场景该用哪一种Jasmine 提供了三套 spy 工具它们不是并列关系而是层层递进的解决方案。选错一种轻则测试冗余重则逻辑失效。我按使用频率和适用深度排序给你拆解清楚。2.1 最基础spyOn(object, methodName)—— 直接劫持实例方法这是最常用、最直观的方式适用于你已经拥有目标对象实例的场景。比如组件里注入了UserService你想监控它getUserById的调用let userService: UserService; let component: UserDetailComponent; beforeEach(() { TestBed.configureTestingModule({ providers: [UserService], declarations: [UserDetailComponent] }); fixture TestBed.createComponent(UserDetailComponent); component fixture.componentInstance; userService TestBed.inject(UserService); // 注意这里拿到的是真实实例 }); it(should call getUserById with correct id, () { const spy spyOn(userService, getUserById); // 劫持真实实例的方法 component.userId 123; fixture.detectChanges(); // 触发 ngOnInit内部会调用 userService.getUserById(123) expect(spy).toHaveBeenCalledWith(123); // 断言被调用 });关键点在于userService必须是组件实际使用的那个实例。Angular 的 DI 容器默认是单例所以TestBed.inject(UserService)和组件构造函数里注入的UserService是同一个对象。spyOn()就是在这个对象上覆盖了getUserById属性将其指向一个 Jasmine 内部管理的 spy 函数。提示spyOn()返回的 spy 对象本身就是一个函数你可以链式调用.and.returnValue()、.and.callFake()等。但要注意一旦你调用了.and.returnValue()这个 spy 就不再执行原始方法逻辑而是直接返回你指定的值。如果你需要“既执行原始逻辑又记录调用”得用.and.callThrough()。2.2 更灵活jasmine.createSpyObjT(name, methodNames)—— 创建带预置 spy 的模拟对象当你不想或不能使用真实服务实例时比如服务有复杂副作用、依赖未 mock、或者你只想测试接口契约就该用createSpyObj。它生成一个空壳对象其指定方法全是 spyit(should display user name when user is loaded, () { const mockUserService jasmine.createSpyObjUserService(UserService, [getUserById]); mockUserService.getUserById.and.returnValue(of({ id: 123, name: Alice })); TestBed.configureTestingModule({ providers: [ { provide: UserService, useValue: mockUserService } // 关键用 mock 替换真实服务 ], declarations: [UserDetailComponent] }); fixture TestBed.createComponent(UserDetailComponent); component fixture.componentInstance; component.userId 123; fixture.detectChanges(); expect(mockUserService.getUserById).toHaveBeenCalledWith(123); expect(fixture.nativeElement.querySelector(h1).textContent).toBe(Alice); });这里mockUserService是全新创建的对象getUserById方法从一开始就是 spy。TestBed.configureTestingModule中的useValue配置确保组件拿到的是这个 mock而不是真实UserService。这种方式彻底隔离了外部依赖测试纯粹聚焦于组件逻辑。注意createSpyObj的第一个参数是name仅用于错误信息显示如Expected UserService.getUserById to have been called第二个参数methodNames是字符串数组必须和真实服务的方法名完全一致否则and.returnValue()会静默失败。2.3 最彻底TestBed.overrideProvider(token, { useValue: mock })spyOn—— 组合拳应对复杂依赖链当服务本身依赖其他服务而你只想 mock 其中某一层时单一spyOn或createSpyObj就不够了。比如UserService依赖HttpClient而你只想 mockUserService的getUserById但保留HttpClient的真实行为比如用HttpTestingController模拟 HTTP 请求。这时你需要overrideProvider配合spyOnit(should handle error from getUserById, () { const mockUserService jasmine.createSpyObjUserService(UserService, [getUserById]); mockUserService.getUserById.and.throwError(Network Error); TestBed.configureTestingModule({ imports: [HttpClientTestingModule], // 启用 HTTP 测试模块 providers: [ { provide: UserService, useValue: mockUserService } ] }); // ... 创建组件、设置 userId ... fixture.detectChanges(); // 此时组件会调用 mockUserService.getUserById()抛出错误触发错误处理逻辑 expect(component.errorMessage).toBe(Network Error); });这里HttpClientTestingModule提供了HttpTestingController但它只影响HttpClient的行为。UserService被我们用useValue替换成了 mock所以getUserById的错误抛出完全由我们控制和HttpClient无关。这种组合让你能精准控制依赖树的任意一层是处理嵌套依赖的黄金方案。3. 实操全流程从零搭建一个带 spy 的 Angular 单元测试我们以一个典型的ProductListComponent为例它通过ProductService获取商品列表并支持搜索过滤。完整测试需覆盖正常加载、搜索过滤、错误处理三个场景。下面是我实际项目中使用的标准流程每一步都附带原理说明和避坑点。3.1 第一步初始化测试模块与组件实例describe(ProductListComponent, () { let component: ProductListComponent; let fixture: ComponentFixtureProductListComponent; let productService: ProductService; // 声明变量便于后续 spyOn let mockProductService: jasmine.SpyObjProductService; // 声明 mock 变量 beforeEach(async () { // 1. 创建 spy 对象预置方法 mockProductService jasmine.createSpyObjProductService( ProductService, [getProducts, searchProducts] ); await TestBed.configureTestingModule({ imports: [CommonModule, ReactiveFormsModule], // 导入必要模块 declarations: [ProductListComponent], providers: [ // 2. 用 mock 替换真实服务 { provide: ProductService, useValue: mockProductService } ] }).compileComponents(); }); beforeEach(() { // 3. 创建组件实例 fixture TestBed.createComponent(ProductListComponent); component fixture.componentInstance; // 4. 获取 mock 实例方便在 it 块中操作 productService TestBed.inject(ProductService) as jasmine.SpyObjProductService; }); });这里的关键细节createSpyObj必须在TestBed.configureTestingModule之前执行因为useValue需要一个已创建的对象。providers数组里的useValue配置是让 DI 容器在解析ProductServicetoken 时返回我们创建的mockProductService而不是去实例化真实类。TestBed.inject()在beforeEach里调用确保每次测试前都拿到最新的 mock 实例虽然createSpyObj每次都新建但inject()是安全的。3.2 第二步测试正常加载流程spy 控制返回值it(should load products on init, () { // 1. 配置 spy 返回 Observable const mockProducts [ { id: 1, name: Laptop, price: 999 }, { id: 2, name: Mouse, price: 25 } ]; mockProductService.getProducts.and.returnValue(of(mockProducts)); // 2. 触发变更检测执行 ngOnInit fixture.detectChanges(); // 3. 断言方法被调用数据被赋值 expect(mockProductService.getProducts).toHaveBeenCalledTimes(1); expect(component.products.length).toBe(2); expect(component.products[0].name).toBe(Laptop); // 4. 断言 DOM 渲染 const productElements fixture.nativeElement.querySelectorAll(.product-item); expect(productElements.length).toBe(2); expect(productElements[0].textContent).toContain(Laptop); });原理of(mockProducts)创建一个立即发出mockProducts的 ObservablegetProducts方法调用后组件订阅它asyncpipe 自动处理订阅和销毁。spy的returnValue让我们完全掌控返回数据无需启动 HTTP 服务器。实操心得永远先expect().toHaveBeenCalledTimes(1)再检查数据。因为如果getProducts根本没被调用component.products可能还是undefined后续断言会报错掩盖了真正的问题——方法没被触发而不是数据不对。3.3 第三步测试搜索过滤功能spy 模拟不同返回it(should filter products by search term, () { const allProducts [ { id: 1, name: Laptop, price: 999 }, { id: 2, name: Mouse, price: 25 }, { id: 3, name: Keyboard, price: 75 } ]; const filteredProducts allProducts.filter(p p.name.toLowerCase().includes(lap)); // 1. 配置 getProducts 返回全部数据 mockProductService.getProducts.and.returnValue(of(allProducts)); // 2. 配置 searchProducts 返回过滤后数据 mockProductService.searchProducts.and.returnValue(of(filteredProducts)); fixture.detectChanges(); // 3. 模拟用户输入搜索词 const searchInput fixture.nativeElement.querySelector(input); searchInput.value lap; searchInput.dispatchEvent(new Event(input)); // 触发 input 事件 // 4. 断言searchProducts 被调用数据被更新 expect(mockProductService.searchProducts).toHaveBeenCalledWith(lap); expect(component.products.length).toBe(1); expect(component.products[0].name).toBe(Laptop); });这里searchProducts是另一个方法我们同样用returnValue控制其输出。dispatchEvent模拟用户真实操作比直接修改component.searchTerm更符合端到端测试思想。注意new Event(input)在较新浏览器中有效但 Angular 的FormsModule有时需要input事件的detail属性。更稳妥的做法是searchInput.dispatchEvent(new InputEvent(input))或者用fixture.debugElement.query(By.css(input)).triggerEventHandler(input, { target: { value: lap } });。3.4 第四步测试错误处理spy 抛出错误it(should show error message when loading fails, () { const error new Error(Failed to fetch products); mockProductService.getProducts.and.throwError(error); fixture.detectChanges(); // 等待异步错误被处理因为 Observable 错误会异步触发 fixture.whenStable().then(() { expect(component.errorMessage).toBe(Failed to fetch products); // 断言 DOM 显示错误 const errorElement fixture.nativeElement.querySelector(.error-message); expect(errorElement.textContent).toContain(Failed to fetch products); }); });throwError是 RxJS 的操作符但spy的throwError方法是 Jasmine 提供的快捷方式它会让 spy 函数同步抛出错误。由于组件中getProducts()返回的是 Observable错误会在订阅的catchError回调中被处理这个过程是异步的所以必须用fixture.whenStable()等待 Promise 解析。提示whenStable()等待所有挂起的异步操作完成包括 Promise、setTimeout、Observable 订阅等。它是 Angular 测试中处理异步的基石比fakeAsynctick()更通用尤其适合 Observable 场景。4. 高阶技巧与避坑指南那些文档里不会写的实战经验4.1 如何 spy 一个箭头函数或匿名函数——答案是不能但有替代方案Angular 组件中常有类似this.userService.getUserById(id).subscribe(...)的写法subscribe的回调是箭头函数。你无法spyOn一个箭头函数因为它没有名字也不属于某个对象属性。但你可以 spysubscribe方法本身// 错误无法 spy 箭头函数 // spyOn(() {}, handleSuccess); // TypeError: Cannot spy on a function that does not exist // 正确spy Observable 的 subscribe 方法 const mockObservable of({ id: 1, name: Test }); const subscribeSpy spyOn(mockObservable, subscribe); component.ngOnInit(); expect(subscribeSpy).toHaveBeenCalled();更实用的方案是不要 spy 回调而是 spy 数据源。getUserById返回 Observable你控制它的返回值of/throwError自然就能控制subscribe的next和error分支。这才是符合 RxJS 思维的测试方式。4.2callFake与callThrough的本质区别何时该用哪一个and.callFake(fn)完全替换原方法执行你提供的fn。fn可以访问传入参数返回任意值甚至调用其他服务。适用于需要复杂逻辑的 mock比如根据参数返回不同数据mockProductService.getProducts.and.callFake((id?: number) { if (id) { return of(products.find(p p.id id)); } return of(products); });and.callThrough()执行原始方法逻辑同时记录调用。适用于你信任原始方法只想监控它是否被调用、传了什么参数但不改变其行为。比如测试一个纯计算服务const calculator new Calculator(); spyOn(calculator, add).and.callThrough(); const result calculator.add(2, 3); expect(calculator.add).toHaveBeenCalledWith(2, 3); expect(result).toBe(5); // 原始逻辑仍生效实操心得callFake是“重写”callThrough是“监听”。在 Angular 测试中callThrough很少用因为真实服务往往有副作用HTTP 请求、DOM 操作你通常不想让它真执行。callFake是主力returnValue是callFake的语法糖。4.3 如何 spy 类的静态方法——spyOn不行得用jest或改写设计Jasmine 的spyOn只能 spy 对象的属性方法不能 spy 类的静态方法如Date.now()、Math.random()。Angular 项目若用 Jest可用jest.spyOn(Date, now)。但 Jasmine 用户怎么办两种方案依赖注入改造把静态方法包装成可注入的服务。例如Injectable() export class DateTimeService { now(): number { return Date.now(); } } // 测试中 spy DateTimeService.now全局替换慎用在beforeEach中临时覆盖beforeEach(() { (global as any).Date class extends Date { static now() { return 1620000000000; // 固定时间戳 } }; });后者破坏全局环境可能导致其他测试失败仅限万不得已时使用。4.4spyOnPropertyspy getter/setter 的唯一正解Angular 组件常有Input()属性或计算属性get fullName() { return this.firstName this.lastName; }。spyOn无法 spy getter必须用spyOnPropertyit(should compute full name correctly, () { const component new UserDetailComponent(); component.firstName John; component.lastName Doe; // spy getter spyOnProperty(component, fullName, get).and.returnValue(John Doe); expect(component.fullName).toBe(John Doe); });spyOnProperty的第三个参数get或set指定要 spy 的访问器类型。这是测试计算属性和Inputsetter 的标准做法。4.5 常见问题速查表问题现象根本原因解决方案Expected spy getUserById to have been called但实际调用了spyOn在fixture.detectChanges()之后才执行确保spyOn或returnValue配置在detectChanges()之前Cannot read property subscribe of undefinedgetProducts返回undefined因为returnValue没设置或拼写错误检查mockProductService.getProducts.and.returnValue(...)是否执行方法名是否和真实服务一致测试通过但组件 UI 没更新asyncpipe 或ChangeDetectorRef.detectChanges()未触发在expect前加fixture.detectChanges()或用fixture.whenStable()等待异步完成Error: Expected 0 stack framesthrowError抛出的错误未被catchError处理导致测试崩溃确保组件中getProducts().pipe(catchError(...))存在或用try/catch包裹订阅逻辑spy在多个it块中状态残留spy的returnValue/callFake是持久的未重置在每个it块开头用mockService.method.calls.reset()或重新returnValue最后一个小技巧在beforeEach里给所有 spy 设置默认返回值避免每个it都重复写beforeEach(() { mockProductService.getProducts.and.returnValue(of([])); mockProductService.searchProducts.and.returnValue(of([])); });这样只有需要特殊返回值的it块才覆盖它大幅提升测试可读性。5. 从单元测试到自动化测试spy 如何融入 CI/CD 流水线单元测试的价值不在本地跑通而在持续集成中成为质量门禁。一个配置不当的 spy可能让测试在本地通过却在 CI 环境失败。以下是我在多个项目中沉淀的 CI 友好实践。5.1 环境一致性Karma 配置的三个关键参数Angular CLI 默认的karma.conf.js通常够用但 CI 环境如 Jenkins、GitLab CI常因资源限制失败。必须调整module.exports function(config) { config.set({ // 1. 增加超时避免因 CI 资源紧张导致测试中断 browserNoActivityTimeout: 60000, // 2. 使用 ChromeHeadless而非 PhantomJS已废弃 browsers: [ChromeHeadless], // 3. 启用 sourceMap便于 CI 日志定位失败行号 reporters: [progress, kjhtml], preprocessors: { src/**/*.ts: [webpack, sourcemap] } }); };browserNoActivityTimeout设为 60 秒给异步 spy如whenStable()充足时间。ChromeHeadless是现代 CI 的标配sourcemap让失败日志显示真实 TypeScript 行号而非编译后的 JS。5.2 测试覆盖率报告如何让 spy 不拉低覆盖率spyOn本身不增加业务代码行数但returnValue的配置代码会被计入测试文件覆盖率。CI 工具如 Istanbul默认统计所有.spec.ts文件。解决方案在tsconfig.spec.json中排除node_modules和dist但不要排除.spec.ts因为那是测试入口。使用--code-coverage时添加--exclude参数忽略测试文件ng test --code-coverage --excludesrc/**/*.spec.ts更推荐在angular.json中配置test: { options: { codeCoverage: true, codeCoverageExclude: [ src/**/*.spec.ts, src/test.ts ] } }这样覆盖率只统计业务代码spy配置不影响指标。5.3 并行测试与 spy 冲突如何避免it块间污染Karma 默认串行执行it块但ng test --watch或某些 CI 配置可能启用并行。spy是共享对象一个it块修改了mockService.method.and.returnValue(x)另一个it块可能读到x而非预期的y。根治方案强制串行Karma 配置singleRun: trueCI 默认开发时用--watch但接受串行。每个it独立配置如前所述在it开头重置或重新设置returnValue。使用TestBed.resetTestingModule()在afterEach中调用彻底清空 DI 容器但性能开销大仅在复杂场景用。我选择第二种因为它清晰、可控、无副作用。一个it块的 spy 配置绝不影响下一个这是编写可靠测试的铁律。5.4 与 E2E 测试的边界spy 该在哪里停手spy是单元测试的利器但绝不该出现在 E2E 测试如 Protractor、Cypress中。E2E 测试的目标是验证真实端到端流程包括网络请求、数据库交互、第三方服务。如果你在 Cypress 中cy.intercept(/api/products, { fixture: products.json })那是在 mock HTTP 层这是合理的。但如果你试图spyOnAngular 服务方法就违背了 E2E 的初衷——它应该测试“当用户点击搜索按钮页面是否显示正确结果”而不是“ProductService.searchProducts是否被调用”。我的团队规范单元测试用spy隔离一切外部依赖E2E 测试用cy.intercept或真实后端测试环境只 mock 不可控的第三方如支付网关。两者分工明确spy的舞台永远只在单元测试的疆域内。我在实际项目中发现一个配置精良的spy测试套件能在 CI 中稳定运行 200 个用例平均每个测试 120ms失败时日志精准定位到spy的哪一行配置错误。这背后没有玄学只有对 Angular 生命周期的敬畏对 Jasmine API 的熟稔以及无数次console.log调试留下的肌肉记忆。写测试不是为了凑覆盖率数字而是为了在重构时那一声清脆的PASS能让你安心合上笔记本——因为你知道所有被spy守护的逻辑依然坚如磐石。
返回列表