how to mock ngrx selector in a component

2020-08-09 10:04发布

问题:

In a component, we use a ngrx selector to retrieve different parts of the state.

public isListLoading$ = this.store.select(fromStore.getLoading);
public users$ = this.store.select(fromStore.getUsers);

the fromStore.method is created using ngrx createSelector method. For example:

export const getState = createFeatureSelector<UsersState>('users');
export const getLoading = createSelector(
  getState,
  (state: UsersState) => state.loading
);

I use these observables in the template:

<div class="loader" *ngIf="isLoading$ | async"></div>
<ul class="userList">
    <li class="userItem" *ngFor="let user of $users | async">{{user.name}}</li>
</div>

I would like to write a test where i could do something like:

store.select.and.returnValue(someSubject)

to be able to change subject value and test the template of the component agains these values.

The fact is we struggle to find a proper way to test that. How to write my "andReturn" method since the select method is called two times in my component, with two different methods (MemoizedSelector) as arguments?

We don't want to use real selector and so mocking a state then using real selector seems not to be a proper unit test way (tests wouldn't be isolated and would use real methods to test a component behavior).

回答1:

I ran into the same challenge and solved it once and for all by wrapping my selectors in services, so my components just used the service to get their data rather than directly going through the store. I found this cleaned up my code, made my tests implementation-agnostic, and made mocking much easier:

mockUserService = {
  get users$() { return of(mockUsers); },
  get otherUserRelatedData$() { return of(otherMockData); }
}

TestBed.configureTestingModule({
  providers: [{ provide: UserService, useValue: mockUserService }]
});

Before I did that however, I had to solve the issue in your question.

The solution for you will depend on where you are saving the data. If you are saving it in the constructor like:

constructor(private store: Store) {
  this.users$ = store.select(getUsers);
}

Then you will need to recreate the test component every time you want to change the value returned by the store. To do that, make a function along these lines:

const createComponent = (): MyComponent => {
  fixture = TestBed.createComponent(MyComponent);
  component = fixture.componentInstance;
  fixture.detectChanges();
  return component;
};

And then call that after you change the value of what your store spy returns:

describe('test', () => {
  it('should get users from the store', () => {
    const users: User[] = [{username: 'BlackHoleGalaxy'}]; 
    store.select.and.returnValue(of(users));
    const cmp = createComponent();
    // proceed with assertions
  });
});

Alternatively, if you are setting the value in ngOnInit:

constructor(private store: Store) {}
ngOnInit() {
  this.users$ = this.store.select(getUsers);
}

Things are a bit easier, as you can create the component once and just recall ngOnInit every time you want to change the return value from the store:

describe('test', () => {
  it('should get users from the store', () => {
    const users: User[] = [{username: 'BlackHoleGalaxy'}]; 
    store.select.and.returnValue(of(users));
    component.ngOnInit();
    // proceed with assertions
  });
});


回答2:

I also ran into this problem and using services to wrap the selectors is no option for me, too. Especially not only for testing purposes and because I use the store to replace services.

Therefore I came up with the following (also not perfect) solution:

I use a different 'Store' for each component and each different aspect. In your example I would define the following Stores&States:

export class UserStore extends Store<UserState> {}

export class LoadingStatusStore extends Store<LoadingStatusState> {}

And inject them in the User-Component:

constructor( private userStore: UserStore, private LoadingStatusStore: 
LoadingStatusStore ) {}

Mock them inside the User-Component-Test-Class:

TestBed.configureTestingModule({
  imports: [...],
  declarations: [...],
  providers: [
    { provide: UserStore, useClass: MockStore },
    { provide: LoadingStatusStore, useClass: MockStore }
  ]
}).compileComponents();

Inject them into the beforeEach() or it() test method:

beforeEach(
  inject(
    [UserStore, LoadingStatusStore],
      (
        userStore: MockStore<UserState>,
        loadingStatusStore: MockStore<LoadingStatusState>
      ) => {...}

Then you can use them to spy on the different pipe methods:

const userPipeSpy = spyOn(userStore, 'pipe').and.returnValue(of(user));
const loadingStatusPipeSpy = spyOn(loadingStatusStore, 'pipe')
  .and.returnValue(of(false));

The drawback of this method is that you still can't test more than one part of a state of a store in one test-method. But for now this works as a workaround for me.



回答3:

I created a helper like that:

class MockStore {
        constructor(public selectors: any[]) {
        }

        select(calledSelector) {
          const filteredSelectors = this.selectors.filter(s => s.selector === calledSelector);
          if (filteredSelectors.length < 1) {
            throw new Error('Some selector has not been mocked');
          }
          return cold('a', {a: filteredSelectors[0].value});
        }
 }

And now my tests look like this:

  const mock = new MockStore([
    {
      selector: selectEditMode,
      value: initialState.editMode
    },
    {
      selector: selectLoading,
      value: initialState.isLoading
    }
  ]);

  it('should be initialized', function () {
    const store = jasmine.createSpyObj('store', ['dispatch', 'select']);
    store.select.and.callFake(selector => mock.select(selector));

    const comp = new MyComponent(store);

    comp.ngOnInit();

    expect(comp.editMode$).toBeObservable(cold('a', {a: false}));
    expect(comp.isLoading$).toBeObservable(cold('a', {a: false}));
  });


回答4:

You could use something like that:

spyOn(store, 'select').and.callFake(selectFake);

function pipeFake(op1: OperatorFunction<UsersState, any>): Observable<any> {
  if (op1.toString() === fromStore.getLoading.toString()) {
    return of(true);
  }

  if (op1.toString() === fromStore.getUsers.toString()) {
    return of(fakeUsers);
  }

  return of({});
}


回答5:

Moving your selectors into a service will not eliminate the need to mock selectors, if you are going to test selectors themselves. ngrx now has its own way of mocking and it is described here: https://ngrx.io/guide/store/testing