Oisín Foley
Oisín Foley

Reputation: 3467

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();
    });
  })
);

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.

Upvotes: 4

Views: 2687

Answers (2)

Ois&#237;n Foley
Ois&#237;n Foley

Reputation: 3467

I managed to get the store.select subscriber to update, thanks to Andrei's input.
The following is what I needed to do:

import * as fromApp from '../../store/app.reducer';

const seedData = getShoppingListSeedData();
const mockInitialAppState: fromApp.AppState = {
  shoppingList: {
    ingredients: seedData,
    editedIngredient: null,
    editedIngredientIndex: -1
  },
  ... default state of other store types' defined in AppState are also provided
};

describe('When an item is selected', () => {
/* In the question code, I was passing the ShoppingList 'State' type into MockStore<T>,  
   whereas it should have been the AppState, as we see on the next line 
*/
    let store: MockStore<fromApp.AppState>;

    const shoppingListState: ShoppingListState = {
      ingredients: seedData,
      editedIngredient: seedData[0],
      editedIngredientIndex: 0
    };

    beforeEach(() => {
      TestBed.configureTestingModule({
        imports: [FormsModule],
        declarations: [ShoppingEditComponent],
        providers: [
          // this ensures that "shoppingList: initialState" is part of the initial state, as per Andrei's suggestion
          provideMockStore({ initialState: { ...mockInitialAppState } })
        ]
      });
      store = TestBed.get<Store<fromApp.AppState>>(Store);
    });

    it('should have \'editMode = true\' and form values should be set to name and amount of the selected ingredient',
      fakeAsync(() => {
        const fixture = TestBed.createComponent(ShoppingEditComponent);
        const componentInstance = fixture.componentInstance;

        tick();
        fixture.detectChanges();

        fixture.whenStable().then(() => {

          store.setState({
            ...mockInitialAppState,
            shoppingList: {
              // the updated state
              ...shoppingListState,
            }
          });

        });

        tick();
        fixture.detectChanges();

        expect(componentInstance['editMode']).toEqual(true);
      })
    );
  });

To summarise, the mistakes I was making were:

  • Incorrectly passing in the initial state to the store
    Instead of passing in the store type of shoppingList to provideMockStore, i was passing in a store type of ingredients, editedIngredient and editedIngredientIndex

  • Defining the store type incorrectly
    Instead of defining my mock store with type AppState, I was only defining it as being of type ShoppingListState.
    This meant there was a type mismatch between the store in my test and the store in my component.

This resulted in store updates received by the component during the test being undefined.

Upvotes: 2

Andrei Gătej
Andrei Gătej

Reputation: 11979

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.

Upvotes: 4

Related Questions