Reputation: 3467
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();
});
})
);
done()
callback.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.
Upvotes: 4
Views: 2687
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
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