Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to test an NGRX effect that uses a store selector?

I have an @Effect that uses a MemoizedSelector to grab an item from the redux store and mergeMap it with the payload of an Action. The effect works just fine, however setting up the Jest tests for this has proven difficult as I cannot seem to mock the return value of the selector due to select being a declared function that's imported (from '@ngrx/store') and used in the effect and the selector itself being an imported function as well. I'm grasping at straws now.

How can I write a Unit Test to test an NGRX effect that makes use of a store selector?
"@ngrx/store": "^7.4.0",
"rxjs": "^6.2.2"

I have tried the following kinds of solutions:

  1. using
provideMockStore({
  initialState
})

provideMockStore comes in from '@ngrx/store/testing'; where initial state was both my actual initialState and a state that contains the exact structure/item I'm trying to select

  1. using different types of MockStore's from various SO questions/answers as well as different blog posts approaches

    • Mock ngrx store selectors in unit tests (Angular)
    • https://blog.angularindepth.com/how-i-test-my-ngrx-selectors-c50b1dc556bc
    • https://christianlydemann.com/the-complete-guide-to-ngrx-testing/
    • https://medium.com/@adrianfaciu/testing-ngrx-effects-3682cb5d760e
  2. attempting to mock the selector using <selector>.projector(<my-mock-object>) (straw-grasping here, I'm pretty sure this would be used in isolated testing of the selector not the Effect)

The Effect itself:

@Effect()
  getReviewsSuccess$ = this.actions$.pipe(
    ofType<ProductActions.GetReviewsSuccess>(
      ProductActions.ProductActionTypes.GET_REVIEWS_SUCCESS
    ),
    mergeMap(() => this.reduxStore.pipe(select(selectProduct))),
    map(({ product_id }) => product_id),
    map(product_id => new ProductActions.GetReviewsMeta({
      product_id,
    }))
  );

The Spec:

......
  let effects: ProductEffects;
  let facade: Facade;
  let actions$: Observable<any>;
  let store$: Observable<State>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule,
        // ^ I've also tried using StoreModule.forRoot(...) here to configure 
        // it in similar fashion to the module where this effect lives
      ],
      providers: [
        ProductEffects,
        provideMockActions(() => actions$),
        {
          provide: Facade,
          useValue: facadeServiceMock,
        },
        ResponseService,
        provideMockStore({
          initialState
        })
        // ^ also tried setting up the test with different variations of initialState
      ],
    });
......

it('should return a GetReviewsMeta on successful GetReviewsSuccess', () => {
    const reviews = {...reviewListMock};
    const { product_id } = {...productMockFull};

    const action = new ProductActions.GetReviewsSuccess({
      reviews
    });

    const outcome = new ProductActions.GetReviewsMeta({
      product_id
    });

    actions$ = hot('-a', { a: action }); 

    // store$ = cold('-c', { c: product_id });  
    // not sure what, if anything I need to do here to mock select(selectProduct)  

    const expected = cold('-b', { b: outcome });  
    expect(effects.getReviewsSuccess$).toBeObservable(expected);
  });

The Selector selectProduct:

export const getProduct = ({product}: fromProducts.State) => product;

export const getProductState = createFeatureSelector<
    fromProducts.State
>('product');

export const selectProduct = createSelector(
  getProductState,
  getProduct,
);

I expect the test to pass but instead I keep getting the following error

● Product Effects › should return a GetReviewsMeta on successful GetReviewsSuccess

    expect(received).toBeNotifications(expected)

    Expected notifications to be:
      [{"frame": 10, "notification": {"error": undefined, "hasValue": true, "kind": "N", "value": {"payload": {"product_id": 2521}, "type": "[Reviews] Get Reviews Meta"}}}]
    But got:
      [{"frame": 10, "notification": {"error": [TypeError: Cannot read property 'product_id' of undefined], "hasValue": false, "kind": "E", "value": undefined}}]

Clearly the MemoizedSelector (selectProduct) doesn't know what the Product Object is that should be in the store (but doesn't seem to be whether I inject an initialState that has it or not) and can't get the product_id of the Product because I didn't set this up correctly in the beforeEach or in the spec itself...

like image 361
Andrew B Avatar asked May 13 '19 22:05

Andrew B


People also ask

How do I test my NgRx store?

Testing NgRx facade using override/MockStore I recommend you use MockStore (with/without overrideSelector) to mocking the store state. If you want to involve the selectors in the test (integtration testing them), you should use MockStore : You set the state using the setState method from MockStore .

How do NgRx selectors work?

Selectors are basically the NgRx change detection mechanism. When a state is updated, NgRx doesn't do any comparison between old state and the new state to figure out what observables to trigger. It simply sends a new state through all top level selectors.


1 Answers

We got this covered in the ngrx.io docs. Note that the syntax is for NgRx 8 but the same ideas count for NgRx 7.

addBookToCollectionSuccess$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(CollectionApiActions.addBookSuccess),
        withLatestFrom(this.store.pipe(select(fromBooks.getCollectionBookIds))),
        tap(([, bookCollection]) => {
          if (bookCollection.length === 1) {
            window.alert('Congrats on adding your first book!');
          } else {
            window.alert('You have added book number ' + bookCollection.length);
          }
        })
      ),
    { dispatch: false }
  );
it('should alert number of books after adding the second book', () => {
      store.setState({
        books: {
          collection: {
            loaded: true,
            loading: false,
            ids: ['1', '2'],
          },
        },
      } as fromBooks.State);

      const action = CollectionApiActions.addBookSuccess({ book: book1 });
      const expected = cold('-c', { c: action });
      actions$ = hot('-a', { a: action });
      expect(effects.addBookToCollectionSuccess$).toBeObservable(expected);
      expect(window.alert).toHaveBeenCalledWith('You have added book number 2');
    });
  });

Make sure that your state has the same structure as in the redux devtools.

NgRx 8 also provides a way to mock selectors, so it isn't needed to set up the whole state tree for a single test - https://next.ngrx.io/guide/store/testing#using-mock-selectors.

describe('Auth Guard', () => {
  let guard: AuthGuard;
  let store: MockStore<fromAuth.State>;
  let loggedIn: MemoizedSelector<fromAuth.State, boolean>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [AuthGuard, provideMockStore()],
    });

    store = TestBed.get(Store);
    guard = TestBed.get(AuthGuard);

    loggedIn = store.overrideSelector(fromAuth.getLoggedIn, false);
  });

  it('should return false if the user state is not logged in', () => {
    const expected = cold('(a|)', { a: false });

    expect(guard.canActivate()).toBeObservable(expected);
  });

  it('should return true if the user state is logged in', () => {
    const expected = cold('(a|)', { a: true });

    loggedIn.setResult(true);

    expect(guard.canActivate()).toBeObservable(expected);
  });
});
like image 134
timdeschryver Avatar answered Oct 09 '22 22:10

timdeschryver