Testing NgRx with Jest + Marbles

Brian Love
Google Developer Expert
Angular and Web Technologies
Demo Files
git clone git@github.com:blove/ngrx-testing.git
Stack
- Angular 6
- NgRx
- jest + jest-preset-angular
- jasmine-marbles
- faker
Why Jest?

It's fast
Jest Test Runner
- Uses jsdom
- Execute tests in parallel
- No need for karma
- Configure via angular-jest-preset
Testing NgRx Actions
- Do not need tests
- Will be covered when testing dispatch()
- Nothing to test
Testing NgRx Reducer
- Assert no state mutation for invalid action
- Assert state mutation for valid actions
Assert NOOP action
describe('undefined action', () => {
it('should return the default state', () => {
const action = { type: 'NOOP' } as any;
const result = reducer(undefined, action);
expect(result).toBe(initialState);
});
});
Assert AddUser
describe('[User] Add User', () => {
it('should toggle loading state', () => {
const action = new AddUser({ user });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
error: undefined,
loading: true
});
});
});
Assert AddUserSuccess
describe('[User] Add User Success', () => {
it('should add a user to state', () => {
const action = new AddUserSuccess({ user });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
entities: {
[user.id]: user
},
ids: [user.id],
loading: false
});
});
});
Assert AddUserFail
describe('[User] Add User Fail', () => {
it('should update error in state', () => {
const error = new Error();
const action = new AddUserFail({ error });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
error,
loading: false
});
});
});
Assert AddUserFail
describe('[User] Add User Fail', () => {
it('should update error in state', () => {
const error = new Error();
const action = new AddUserFail({ error });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
error,
loading: false
});
});
});
Do I Test Entity State Mutations?
- Likely, not necessary
- It will affect code coverage
- It's a bit tedious (user.reducer.spec.ts)
Testing NgRx Effects
- Model the actions hot observable
- Model any HTTPClient observables
- Model and test dispatched Action(s)
- Model and test catchError() action
Let's Review an Effect
@Effect()
addUser: Observable<Action> = this.actions$
.ofType<AddUser>(UserActionTypes.AddUser)
.pipe(
map(action => action.payload),
exhaustMap(payload => this.userService.addUser(payload.user)),
map(user => new AddUserSuccess({ user: user })),
catchError(error => of(new AddUserFail({ error })))
);
Model and test an observable stream?

jasmine-marbles
npm i jasmine-marbles -D yarn add jasmine-marbles -D
Marbles for Testing
- Marbles provide a simple way to describe observable streams.
- They can be used to model and test observables
- They can also be used to model and test subscriptions
- Uses a scheduler to provide timing
- Timing is done frame-by-frame
- Each character in a marble is 10 frames
- A frame is a virtual "millisecond" or "clock cycle" of the scheduler
Marble Diagrams
Marble Observable Syntax
- The first character is "zero frame"
- A dash "-" represents the passage of 10 frames
- A pipe "|" indicates completion (.complete())
- A hash "#" indicates error (.error())
- A carrot "^" indicates subscription point in a hot observable
- Any other character indicates emitted value (.next())
Emit a value and complete
- A dash "-" represents the passage of 10 frames
- The letter "a" represents our first emitted value
- A pipe "|" represents completion
- The below observable is 60 frames
cold("--a--|")
Emit a value and error
- A dash "-" represents the passage of 10 frames
- The letter "a" represents our first emitted value
- A pipe "#" represents error
- The below observable is 60 frames
cold("--a--#")
Multiple values & complete
- A dash "-" represents the passage of 10 frames
- The letter "a" represents our first emitted value
- The letter "b" represents our second emitted value
- The below observable is 80 frames
cold("--a-b--|")
Test Effect Success Path
describe('addUser', () => {
it('should return an AddUserSuccess action, with the user, on success', () => {
const user = generateUser();
const action = new AddUser({ user });
const outcome = new AddUserSuccess({ user });
actions.stream = hot('-a', { a: action });
const response = cold('-a|', { a: user });
const expected = cold('--b', { b: outcome });
userService.addUser = jest.fn(() => response);
expect(effects.addUser).toBeObservable(expected);
});
});
Test Effect Failure Path
describe('addUser', () => {
it('should return an AddUserFail action, with an error, on failure', () => {
const user = generateUser();
const action = new AddUser({ user });
const error = new Error();
const outcome = new AddUserFail({ error });
actions.stream = hot('-a', { a: action });
const response = cold('-#|', {}, error);
const expected = cold('--(b|)', { b: outcome });
userService.addUser = jest.fn(() => response);
expect(effects.addUser).toBeObservable(expected);
});
});
Testing NgRx Selectors
- No need to test entity selectors
- Use the projector() function to test projected values
Projected Selector
export const selectSelectedUserId = createSelector(
selectUserState,
fromUser.getSelectedUserId
);
export const selectSelectedUser = createSelector(
selectUserEntities,
selectSelectedUserId,
(entities, selectedUserId) => entities && entities[selectedUserId]
);
Test Projected Selector
describe('User selectors', () => {
const users = generateUsers(2);
const entities = {
[users[0].id]: users[0],
[users[1].id]: users[1]
};
const selectedUser = users[0];
it('should return null when entities is falsy', () => {
expect(selectSelectedUser.projector(null, selectedUser.id)).toBe(null);
});
it('should get the retrieve the selected user', () => {
expect(selectSelectedUser.projector(entities, selectedUser.id)).toBe(
selectedUser
);
});
});
Test Dispatched Actions
- Spy on dispatch()
- Invoke dispatch() via component
- Assert specific Action was dispatched
Test Dispatched Action
describe('ngOnInit', () => {
it('should dispatch SelectUser action for specified id parameter', () => {
const action = new SelectUser({ id: user.id });
const spy = jest.spyOn(store, 'dispatch');
expect(spy).toHaveBeenCalledWith(action);
});
it('should dispatch LoadUser action for specified id parameter', () => {
const action = new LoadUser({ id: user.id });
const spy = jest.spyOn(store, 'dispatch');
expect(spy).toHaveBeenCalledWith(action);
});
});
NgRx + Jest + Marbles
= 🦄 + 🌈 + 😎
Testing NgRx with Marbles
By Brian Love
Testing NgRx with Marbles
Learn how to test NgRx effects and reducers using Jest and jasmine-marbles.
- 3,601