Angular + Jasmine测试:服务中BehaviorSubject转Observable加载验证问题
我有一个可注入的UserService,用于通过HTTP获取用户角色和权限。为确保HTTP请求仅执行一次,在服务内部创建了BehaviorSubject,对外暴露公共Observable,供组件订阅以检查权限是否加载完成。
相关代码
user.service.ts
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class UserService { private _loaded = new BehaviorSubject<boolean>(false); public $loaded = this._loaded.asObservable(); userDetailsUrl = "/serviceurl"; constructor(private _http: HttpClient) { this.getUserDetails(); } getUserDetails() { this._http.get(this.userDetailsUrl).subscribe({ next: (data) => { // set permissions... this._loaded.next(true); } }); } // getters for roles and permissions... }
some.component.ts
@Component({...}) export class SomeComponent implements OnInit { loadedRoles: Subscription; constructor(private _user: UserService) {} ngOnInit() { this.loadedRoles = this._user.$loaded.subscribe({ next: (data) => // get roles and permissions to restrict component functionality... }); } // ... }
测试问题
在测试文件中,我期望创建服务后调用getUserDetails来测试$loaded的订阅逻辑,但「should be loaded」测试失败。控制台显示订阅先收到false,再收到true,而测试直接断言数据为truthy,导致失败。
user.service.spec.ts
describe('SessionService', () => { let service: SessionService; let httpClient: HttpClient; let httpTestingController: HttpTestingController; const userDetailsUrl = "/serviceurl"; const data1 = { "roles": ["TESTER"] }; let firstReq; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule] }); service = TestBed.inject(SessionService); httpClient = TestBed.inject(HttpClient); httpTestingController = TestBed.inject(HttpTestingController); firstReq = httpTestingController.expectOne({ 'url': userDetailsUrl, 'method': 'get' }); })); afterEach(() => { httpTestingController.verify(); }); it('should create and get user details over HTTP', () => { expect(service).toBeTruthy(); expect(firstReq.request.method).toEqual('GET'); }); it('should be loaded', () => { service.$loaded.subscribe({ next: (data) => { console.log("post load", data); expect(data).toBeTruthy(); // since it passes true to the subscription }, error: fail }); service.getUserDetails(); httpTestingController.expectOne({ 'url': userDetailsUrl, 'method': 'get' }).flush(data1); }); // ... }
输出日志
Browser application bundle generation complete. LOG: 'post load', false Chrome 105.0.0.0 (Windows 10): Executed 1 of 2 SUCCESS (0 secs / 0.022 secs) LOG: 'post load', true Chrome 105.0.0.0 (Windows 10): Executed 1 of 2 SUCCESS (0 secs / 0.022 secs) Chrome 105.0.0.0 (Windows 10) UserService should be loaded FAILED Error: Expected false to be truthy. at <Jasmine> at Object.next (src/app/services/user.service.spec.ts:50:22) at ConsumerObserver.next (node_modules/rxjs/dist/esm/internal/Subscriber.js:91:1) at SafeSubscriber._next (node_modules/rxjs/dist/esm/internal/Subscriber.js:60:1) Chrome 105.0.0.0 (Windows 10): Executed 2 of 2 (1 FAILED) (0 secs / 0.032 secs) Chrome 105.0.0.0 (Windows 10) UserService should be loaded FAILED Error: Expected false to be truthy. at <Jasmine> at Object.next (src/app/services/user.service.spec.ts:50:22) at ConsumerObserver.next (node_modules/rxjs/dist/esm/internal/Subscriber.js:91:1)
请问我忽略了什么问题?
问题出在BehaviorSubject的核心特性上:它会在订阅时立即发送当前的最新值。你的_loaded初始值是false,所以订阅$loaded时会先收到这个false,而测试对每一次收到的值都做expect(data).toBeTruthy()断言,第一次的false直接触发了失败。
另外还有一个隐藏问题:在beforeEach里注入SessionService(应该是UserService的笔误)时,服务构造函数会自动调用getUserDetails()发起请求,测试里再次调用getUserDetails()会发起第二次不必要的HTTP请求。
可以通过以下几种方式修复测试:
方法1:过滤初始的false值
用RxJS的filter操作符只处理值为true的情况:
it('should be loaded', () => { service.$loaded.pipe(filter(value => value)).subscribe({ next: (data) => { expect(data).toBeTruthy(); }, error: fail }); // beforeEach中已发起请求,直接flush即可,无需重复调用getUserDetails firstReq.flush(data1); });
方法2:等待最终的true值(async/await方式)
用firstValueFrom将Observable转为Promise,等待true值到来:
import { firstValueFrom } from 'rxjs'; import { filter } from 'rxjs/operators'; // ... it('should be loaded', async () => { // 等待$loaded发出true const loadedValue = await firstValueFrom(service.$loaded.pipe(filter(v => v))); firstReq.flush(data1); expect(loadedValue).toBeTruthy(); });
方法3:用done回调控制测试完成时机
通过Jasmine的done函数,确保只有收到true时才标记测试完成:
it('should be loaded', (done) => { service.$loaded.subscribe({ next: (data) => { if (data) { expect(data).toBeTruthy(); done(); // 仅收到true时完成测试 } }, error: (err) => { fail(err); done(); } }); firstReq.flush(data1); });
最后要修正测试里的笔误:describe('SessionService'改为describe('UserService',let service: SessionService;改为let service: UserService;,避免命名混淆。
内容的提问来源于stack exchange,提问作者Carter Smith

