Search code examples
angularunit-testingrxjskarma-jasminengrx

NgRx Testing - Subscribe callback not updating during test


I'm trying to test a component which edits shopping list items.
When first loaded, the pertinent state values it receives via subscribing to store.select are:

editedIngredient: null,
editedIngredientIndex: -1

With these values, it'll ensure that the class' editMode property is set to false.
During testing, I am trying to update the state once the component has loaded.
What i'm trying to achieve is updating the editedIngredient and editedIngredientIndex properties to a truthy value in my component, hence allowing the editMode prop to be set to true.

When trying the below code, I am able to get the component to render and editMode is initally set to false.
Once the state is updated inside my test however, the store.select subscriber does not update, meaning the test just finishes without editMode ever being set to true.

Component code (ShoppingEditComponent)

ngOnInit() {
  this._subscription = this.store.select('shoppingList').subscribe(stateData => {
    if (stateData.editedIngredientIndex > -1) {
      this.editMode = true; // I want to update my state later so that I set this value
      return;
    }
    this.editMode = false; // I start with this value
  });
}

Test code

let store: MockStore<State>;
const initialState: State = {
  editedIngredient: null,
  editedIngredientIndex: -1
};
const updatedShoppingListState: State = {
  editedIngredient: seedData[0],
  editedIngredientIndex: 0
};

let storeMock;
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [FormsModule],
      declarations: [
        ShoppingEditComponent
      ],
      providers: [
        provideMockStore({ initialState }),
      ]
    });
});

Test Attempt 1

it('should have \'editMode = true\' when it receives a selected ingredient in updated state',
  fakeAsync(() => {
    const fixture = TestBed.createComponent(ShoppingEditComponent);
    const componentInstance = fixture.componentInstance;

    // no change detection occurs, hence the subscribe callback does not get called with state update
    store.setState(updatedShoppingListState); 
    expect(componentInstance['editMode']).toEqual(true);
  })
);

Test Attempt 2

it('should have \'editMode = true\' when it receives a selected ingredient in updated state',
  fakeAsync((done) => {
    const fixture = TestBed.createComponent(ShoppingEditComponent);
    const componentInstance = fixture.componentInstance;
    fixture.whenStable().then(() => {
      store.setState(updatedShoppingListState);
      expect(componentInstance['editMode']).toEqual(true);
      done();
    });
  })
);
  • The problem I am having with attempt 1 is that the change detection doesn't occur.
  • The problem I am having with attempt 2 is that the change detection doesn't occur if I omit the done() callback.
    However, if I include the done() callback, I get an error stating that done() is not a function

For reference: I found the example for mocking the store from the NgRx docs (i'm on Angular 8, so this example is most relevant for me)

I am using Karma/Jasmine for my tests.

Any guidance would be really helpful.


Solution

  • After some research, I think I've found the problem.

    Let's have a look at provideMockStore's implementation:

    export function provideMockStore<T = any>(
      config: MockStoreConfig<T> = {}
    ): Provider[] {
      return [
        ActionsSubject,
        MockState,
        MockStore,
        { provide: INITIAL_STATE, useValue: config.initialState || {} },
        { provide: MOCK_SELECTORS, useValue: config.selectors },
        { provide: StateObservable, useClass: MockState },
        { provide: ReducerManager, useClass: MockReducerManager },
        { provide: Store, useExisting: MockStore },
      ];
    }
    

    The config object that can be given to provideMockStore has this shape:

    export interface MockStoreConfig<T> {
      initialState?: T;
      selectors?: MockSelector[];
    }
    

    As you can see, the value at config.initialState is assigned to the INITIAL_STATE token, which is further injected in Store(MockStore in this case).

    Notice how you're providing it:

    const initialState: State = {
      editedIngredient: null,
      editedIngredientIndex: -1
    };
    
    provideStore({ initialState })
    

    This means that INITIAL_STATE will be this:

    {
      editedIngredient: null,
      editedIngredientIndex: -1
    };
    

    This is how MockStore looks like:

     constructor(
        private state$: MockState<T>,
        actionsObserver: ActionsSubject,
        reducerManager: ReducerManager,
        @Inject(INITIAL_STATE) private initialState: T,
        @Inject(MOCK_SELECTORS) mockSelectors: MockSelector[] = []
      ) {
        super(state$, actionsObserver, reducerManager);
        this.resetSelectors();
        this.setState(this.initialState);
        this.scannedActions$ = actionsObserver.asObservable();
        for (const mockSelector of mockSelectors) {
          this.overrideSelector(mockSelector.selector, mockSelector.value);
        }
      }
    

    Notice it injects INITIAL_STATE. MockState is simply a BehaviorSubject. By calling super(state$, actionsObserver, reducerManager); you're making sure that when you do this.store.pipe() in your component, you'll receive the value of the MockState.

    This is how you're selecting from the store:

    this.store.select('shoppingList').pipe(...)
    

    but your initial state looks like this:

    {
      editedIngredient: null,
      editedIngredientIndex: -1
    };
    

    With this in mind, I think you could solve the problem if you do:

    const initialState = {
      editedIngredient: null,
      editedIngredientIndex: -1
    };
    
    provideMockStore({ initialState: { shoppingList: initialState } })
    

    Also, if you'd like to dive deeper into ngrx/store, you could check out this article.