Search code examples
rxjsngrxngrx-effectsjasmine-marblesrxjs-marbles

Testing NGRX effect with delay


I want to test an effect that works as follows:

  1. Effect starts if LoadEntriesSucces action was dispatched
  2. It waits for 5 seconds
  3. After 5 seconds passes http request is send
  4. When response arrives, new action is dispatched (depending, whether response was succes or error).

Effect's code looks like this:

  @Effect()
  continuePollingEntries$ = this.actions$.pipe(
    ofType(SubnetBrowserApiActions.SubnetBrowserApiActionTypes.LoadEntriesSucces),
    delay(5000),
    switchMap(() => {
      return this.subnetBrowserService.getSubnetEntries().pipe(
        map((entries) => {
          return new SubnetBrowserApiActions.LoadEntriesSucces({ entries });
        }),
        catchError((error) => {
          return of(new SubnetBrowserApiActions.LoadEntriesFailure({ error }));
        }),
      );
    }),
  );

What I want to test is whether an effect is dispatched after 5 seconds:

it('should dispatch action after 5 seconds', () => {
  const entries: SubnetEntry[] = [{
    type: 'type',
    userText: 'userText',
    ipAddress: '0.0.0.0'
  }];

  const action = new SubnetBrowserApiActions.LoadEntriesSucces({entries});
  const completion = new SubnetBrowserApiActions.LoadEntriesSucces({entries});

  actions$ = hot('-a', { a: action });
  const response = cold('-a', {a: entries});
  const expected = cold('- 5s b ', { b: completion });

  subnetBrowserService.getSubnetEntries = () => (response);

  expect(effects.continuePollingEntries$).toBeObservable(expected);
});

However this test does not work for me. Output from test looks like this:

Expected $.length = 0 to equal 3.
Expected $[0] = undefined to equal Object({ frame: 20, notification: Notification({ kind: 'N', value: undefined, error: undefined, hasValue: true }) }).
Expected $[1] = undefined to equal Object({ frame: 30, notification: Notification({ kind: 'N', value: undefined, error: undefined, hasValue: true }) }).
Expected $[2] = undefined to equal Object({ frame: 50, notification: Notification({ kind: 'N', value: LoadEntriesSucces({ payload: Object({ entries: [ Object({ type: 'type', userText: 'userText', ipAddress: '0.0.0.0' }) ] }), type: '[Subnet Browser API] Load Entries Succes' }), error: undefined, hasValue: true }) }).

What should I do to make this test work?


Solution

  • Like mentioned in another answer, one way to test that effect would be by using the TestScheduler but it can be done in a simpler way.

    We can test our asynchronous RxJS code synchronously and deterministically by virtualizing time using the TestScheduler. ASCII marble diagrams provide a visual way for us to represent the behavior of an Observable. We can use them to assert that a particular Observable behaves as expected, as well as to create hot and cold Observables we can use as mocks.

    For example, let's unit test the following effect:

    effectWithDelay$ = createEffect(() => {
      return this.actions$.pipe(
        ofType(fromFooActions.doSomething),
        delay(5000),
        switchMap(({ payload }) => {
          const { someData } = payload;
    
          return this.fooService.someMethod(someData).pipe(
            map(() => {
              return fromFooActions.doSomethingSuccess();
            }),
            catchError(() => {
              return of(fromFooActions.doSomethinfError());
            }),
          );
        }),
      );
    });
    

    The effect just waits 5 seconds after an initial action, and calls a service which would then dispatch a success or error action. The code to unit test that effect would be the following:

    import { TestBed } from "@angular/core/testing";
    
    import { provideMockActions } from "@ngrx/effects/testing";
    
    import { Observable } from "rxjs";
    import { TestScheduler } from "rxjs/testing";
    
    import { FooEffects } from "./foo.effects";
    import { FooService } from "../services/foo.service";
    import * as fromFooActions from "../actions/foo.actions";
    
    // ...
    
    describe("FooEffects", () => {
      let actions$: Observable<unknown>;
    
      let testScheduler: TestScheduler; // <-- instance of the test scheduler
    
      let effects: FooEffects;
      let fooServiceMock: jasmine.SpyObj<FooService>;
    
      beforeEach(() => {
        // Initialize the TestScheduler instance passing a function to
        // compare if two objects are equal
        testScheduler = new TestScheduler((actual, expected) => {
          expect(actual).toEqual(expected);
        });
    
        TestBed.configureTestingModule({
          imports: [],
          providers: [
            FooEffects,
            provideMockActions(() => actions$),
    
            // Mock the service so that we can test if it was called
            // and if the right data was sent
            {
              provide: FooService,
              useValue: jasmine.createSpyObj("FooService", {
                someMethod: jasmine.createSpy(),
              }),
            },
          ],
        });
    
        effects = TestBed.inject(FooEffects);
        fooServiceMock = TestBed.inject(FooService);
      });
    
      describe("effectWithDelay$", () => {
        it("should dispatch doSomethingSuccess after 5 seconds if success", () => {
          const someDataMock = { someData: Math.random() * 100 };
    
          const initialAction = fromFooActions.doSomething(someDataMock);
          const expectedAction = fromFooActions.doSomethingSuccess();
        
          testScheduler.run((helpers) => {
    
            // When the code inside this callback is being executed, any operator 
            // that uses timers/AsyncScheduler (like delay, debounceTime, etc) will
            // **automatically** use the TestScheduler instead, so that we have 
            // "virtual time". You do not need to pass the TestScheduler to them, 
            // like in the past.
            // https://rxjs-dev.firebaseapp.com/guide/testing/marble-testing
    
            const { hot, cold, expectObservable } = helpers;
    
            // Actions // -a-
            // Service //    -b|
            // Results // 5s --c
    
            // Actions
            actions$ = hot("-a-", { a: initialAction });
    
            // Service
            fooServiceMock.someMethod.and.returnValue(cold("-b|", { b: null }));
    
            // Results
            expectObservable(effects.effectWithDelay$).toBe("5s --c", {
              c: expectedAction,
            });
          });
    
          // This needs to be outside of the run() callback
          // since it's executed synchronously :O
          expect(fooServiceMock.someMethod).toHaveBeenCalled();
          expect(fooServiceMock.someMethod).toHaveBeenCalledTimes(1);
          expect(fooServiceMock.someMethod).toHaveBeenCalledWith(someDataMock.someData);
        });
      });
    });
    
    

    Please notice that in the code I'm using expectObservable to test the effect using the "virtual time" from the TestScheduler instance.