Angular单元测试:如何使用marble testing(rxjs/testing)来测试这个状态管理服务

Angular Unit Test: how to use marble testing (rxjs/testing) to test this state management service

在我的angular项目中,我有一个服务,用于状态管理以在组件之间共享一些数据,如下所示:

@Injectable({ providedIn: "root"})
export class NameStateService {

    private _filteredNames$: Subject<Name[]> = new Subject();
    private _filteredNamesObs$: Observable<Name[]>;

    constructor() {
        this._filteredNamesObs$ = this._filteredNames$.asObservable();
    }

    public updateFilteredNames(val: Name[]): void {
        this._filteredNames$.next(val);
    }

    public get filteredNames$(): Observable<BillingAccount[]> {
        return this._filteredNamesObs$;
    }
}

状态管理基于 subject 和 observable,这是 rxjs 世界中的典型用法。

并且对于此服务的单元测试,我想使用 rxjs/testing 模块支持的大理石测试功能。解决方法如下:

describe("Name state service ", () => {
    let nameStateService: NameStateService;
    let scheduler: TestScheduler;

    beforeEach(() => {
        TestBed.configureTestingModule({
            providers: [
                NameStateService
            ]
        });
        nameStateService = TestBed.get(NameStateService);
        scheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
    });

    it("should be created", () => {
        expect(nameStateService).toBeTruthy();
    });

    it("should return a valid names observables", () => {
        scheduler.run(({ cold, hot, expectObservable }) => {
            const mockNames: Name[] = [{
                title: "title1",
                group: "group1"
            }]; 
            nameStateService.updateFilteredNames(mockNames);
            expectObservable(nameStateService.filteredNames$).toBe("-b", {b: mockNames});
        });
    });
})

但是第二个单元测试失败并出现错误:Error: Expected $.length = 0 to equal 1. 所以这意味着 nameStateService.filteredNames$,这个 observable 在它的流中没有值。

这里有什么问题?

您的设置可能很难使用 RxJS 弹珠进行测试。

第一个问题是您在订阅之前发送了一些内容。 expectObservable() 同步订阅传递的可观察对象(在您的情况下 nameStateService.filteredNames$)。但是,没有数据发送到那里,因为您已经通过调用 nameStateService.updateFilteredNames(mockNames).

发送了数据

你可以考虑让这两行改变位置,但记住这是同步执行环境,所以这样做

expectObservable(nameStateService.filteredNames$).toBe("-b", {b: mockNames});
nameStateService.updateFilteredNames(mockNames);

也无济于事,因为 expectObservable() 会订阅 nameStateService.filteredNames$,然后它会读取所有值,但是因为没有值,因为您在这之后的一行中发送它们, actual 数组将为空。

为避免这种情况,您应该模拟 nameStateService.filteredNames$ 可观察对象。为此,您可以做两件事,但这两件事都有自己的问题。因此,您设置的第二个问题是您可以使 coldhot 可观察并使用它们而不是 filteredNames$ 可观察。

可以这样实现:

nameStateService.filteredNames$ = hot('-b', { a: mockNames });

但是 TS2540 出现此错误:无法分配给 'filteredNames$' 因为它是只读的 属性。 因为你没有 setter 对于 filteredNames$。如果您要添加 setter,那么这将破坏您将 this._filteredNamesObs$ 作为从 this._filteredNames$ Subject.[=38= 创建的私有 属性 的合同]

另一种方法是使用 Jasmine 间谍来模拟 this._filteredNames$(这是第三个问题),但这种设置也有问题。嘲笑什么?整个nameStateService?在这种情况下,您必须为服务的每个特定 属性 和功能创建间谍。还是模拟nameStateService._filteredNames$属性?或者更好,nameStateService.filteredNames$?但是嘲笑他们会导致其他人的行为不同,因为并非所有人都被嘲笑。

所以,我建议根本不要使用 TestScheduler,而是像这样编写测试:

it('should return a valid names observables', () => {
  const values: Name[][] = [];
  expect(values).toEqual([]);
  nameStateService.updateFilteredNames([{ group: 'G', title: 'Before subscribe' }]);
  expect(values).toEqual([]);

  nameStateService.filteredNames$.subscribe({
    next(names) {
      values.push(names);
    }
  });
  expect(values).toEqual([]);

  nameStateService.updateFilteredNames([{ group: 'G', title: 'After subscribe' }]);
  expect(values).toEqual([[{ group: 'G', title: 'After subscribe' }]]);
});

但是,可能存在另一种解决方案(如果您真的非常想使用弹珠),您只需要非常小心并知道自己在做什么。您可以将 _filteredNames$ 更改为 ReplaySubject.

private _filteredNames$: Subject<Name[]> = new ReplaySubject();

这会导致任何后续订阅者甚至在订阅它之前就获得所有发送到流中的值。您只需要删除一个字符(- 传递给 toBe 方法的符号,您通过的测试将如下所示:

it('should return a valid names observables', () => {
  scheduler.run(({ cold, hot, expectObservable }) => {
    const mockNames: Name[] = [{
      title: 'title1',
      group: 'group1'
    }];
    nameStateService.updateFilteredNames(mockNames);
    expectObservable(nameStateService.filteredNames$).toBe('b', {b: mockNames});
  });
});