Complete testing of NGRX store with JEST

Klajdi Avdiaj
ITNEXT
Published in
6 min readMay 12, 2023

--

Image source: Sarindu Udagepala

In this article, I will show a full guide on testing ngrx store building blocks following: action, reducers, effects and selectors using JEST (a testing framework for javascript/typescript).

Scenario

What are we going to do? Well, I am preparing a scenario like the following:

  1. We want to fetch the books of an online library. They contain an id and name as json properties.
  2. We need an Angular service called BookService which will call an endpoint to fetch the books list from the API.
  3. ngrx store actions: getBooks and getBooksSuccess.
  4. ngrx store selectors: selectBooksList.
  5. ngrx store reducers: saving the books list from getBooksSuccess action into the state.
  6. ngrx store effects: getBooks$ which is going to call the BookService whenever the getBooks action is triggered and after fetching the list, it will return getBooksSuccess action with the list as a parameter.
  7. ngrx store module: This store module will inject the feature store for registering reducers and the effects we need.
  8. index.ts file will export everything from actions and selectors. This will help a lot to construct better imports. You will check it out in the implemetation.
  9. The structure of the files looks like below:
books ngrx store folder structure

Files

  1. book.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

import { Observable } from 'rxjs';

@Injectable({
providedIn: 'root'
})
export class BooksService {
constructor(private http: HttpClient) {}

getBooks(): Observable<{ id: number; name: string }[]> {
return this.http.get<{ id: number; name: string }[]>('/api/books');
}
}
  1. books.actions.ts
import { createAction, props } from '@ngrx/store';

const prefix = '[Books]';

export const getBooks = createAction(`${prefix} Get Books`);
export const getBooksSuccess = createAction(
`${getBooks.type} Success`,
props<{
books: { id: number; name: string }[];
}>()
);

3. books.effects.ts

import { BooksService } from '../../services/books.service';

import { Injectable } from '@angular/core';

import { Actions, createEffect, ofType } from '@ngrx/effects';

import { map, switchMap } from 'rxjs/operators';

import * as fromBooks from './index';

@Injectable()
export class BooksEffects {
constructor(private readonly actions$: Actions, private readonly booksService: BooksService) {}

getBooks$ = createEffect(() =>
this.actions$.pipe(
ofType(fromBooks.getBooks),
switchMap(() => this.booksService.getBooks()),
map((books: { id: number; name: string }[]) => fromBooks.getBooksSuccess({ books }))
)
);
}

4. books.model.ts

export interface IBooksState {
books: { id: number; name: string }[];
}

5. books.reducers.ts

import { createReducer, on } from '@ngrx/store';

import { IBooksState } from './books.model';
import * as fromBooks from './index';

export const initialBooksState: IBooksState = {
books: [],
isLoading: false
};

const reducer = createReducer<IBooksState>(
initialBooksState,
on(fromBooks.getBooks, (state) => {
return {
...state,
isLoading: true
};
}),
on(fromBooks.getBooksSuccess, (state, { books }) => {
return {
...state,
isLoading: false,
books
};
})
);

export function booksReducer(state = initialBooksState, actions): IBooksState {
return reducer(state, actions);
}

6. books.selector.ts

import { createFeatureSelector, createSelector } from '@ngrx/store';

import { IBooksState } from './books.model';

export const selectBooksState = createFeatureSelector<IBooksState>('books');
export const selectBooksList = createSelector(selectBooksState, (state) => state.books);

7. books-store.module.ts

import { NgModule } from '@angular/core';

import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';

import { BooksEffects } from './books.effects';
import { booksReducer } from './books.reducers';

@NgModule({
imports: [
StoreModule.forFeature('books', booksReducer),
EffectsModule.forFeature([BooksEffects])
]
})
export class BooksStoreModule {}

8. index.ts

export * from './books.actions';
export * from './books.selectors';

Now we have all ngrx store files in place. Let’s see how to test them using jest.

Adding JEST unit tests

  1. books.service.spec.ts → Let’s start with testing the service and make sure that we are calling the correct API endpoint when the getBooks function is called. Basically, we’re using TestBed from Angular to inject the BooksService and also importing HttpClientTestingModule in order to have access to HttpClient.
import { HttpClient } from '@angular/common/http';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';

import { BooksService } from './books.service';

describe('BooksService', () => {
let service: BooksService;
let httpClient: HttpClient;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [BooksService]
});

service = TestBed.inject(BooksService);
httpClient = TestBed.inject(HttpClient);
});

it('should be created', () => {
expect(service).toBeTruthy();
});

it('should send request to get the books', async () => {
const postRequestSpy = jest.spyOn(httpClient, 'get');
service.getBooks().subscribe();
expect(postRequestSpy).toHaveBeenCalledWith('/api/books');
});
});

2. books.mocks.ts → Before we get started, we’re gonna need data that will contain all mocks of objects we need for testing the books store.

export const booksListMock: { id: number; name: string }[] = [
{
id: 1,
name: 'Book 1'
},
{
id: 2,
name: 'Book 2'
}
];

export const booksStateMock: IBooksState = {
books: booksListMock,
isLoading: false
};

3. books.actions.spec.ts → Testing that by dispatching an action, it's actually passing all the props and type as it should be. Also, if you notice, in the imports, I have created a file books.mocks.ts which is going to hold all the mocks we need for testing the store.

import { booksListMock } from './books.mocks';
import * as fromBooks from './index';

describe('BooksActions', () => {
describe('GetBooks', () => {
it('should create an action to get books', () => {
const expectedAction = {
type: fromBooks.getBooks.type
};
const action = fromBooks.getBooks();
expect(action).toEqual(expectedAction);
});
});

describe('GetBooksSuccess', () => {
it('should create an action to get books success', () => {
const expectedAction = {
type: fromBooks.getBooksSuccess.type,
books: booksListMock
};
const action = fromBooks.getBooksSuccess({
books: booksListMock
});
expect(action).toEqual(expectedAction);
});
});
});

4. books.effects.spec.ts → Testing that by dispatching an action, the effect that is listening on it calls the correct service and returns a correct action.

import { NO_ERRORS_SCHEMA } from '@angular/core';
import { TestBed, waitForAsync } from '@angular/core/testing';

import { provideMockActions } from '@ngrx/effects/testing';
import { Action } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';

import { of, ReplaySubject } from 'rxjs';
import { take } from 'rxjs/operators';

import { BooksService } from '../../services/books.service';
import { BooksEffects } from './books.effects';
import { booksListMock } from './books.mocks';
import * as fromBooks from './index';

describe('BooksEffects', () => {
let effect: BooksEffects;
let action$: ReplaySubject<Action>;
let booksService: BooksService;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: [
BooksEffects,
provideMockActions(() => action$),
provideMockStore({
initialState: {
books: null
}
}),
{
provide: BooksService,
useValue: {
getBooks: jest.fn(() => of(booksListMock))
}
}
],
schemas: [NO_ERRORS_SCHEMA]
});

effect = TestBed.inject(BooksEffects);
booksService = TestBed.inject(BooksService);
action$ = new ReplaySubject();
}));

it('should be created', () => {
expect(effect).toBeTruthy();
});

it('should get books', async () => {
action$.next(fromBooks.getBooks());

const result = await new Promise((resolve) => effect.getBooks$.pipe(take(1)).subscribe(resolve));

expect(booksService.getBooks).toHaveBeenCalledWith();
expect(result).toEqual(
fromBooks.getBooksSuccess({
books: booksListMock
})
);
});
});

5. books.reducers.ts → Testing to make sure that when dispatching an action the reducer listening to it will update the store object in the expected way.

import { booksListMock } from './books.mocks';
import { IBooksState } from './books.model';
import { booksReducer, initialBooksState } from './books.reducers';
import * as fromBooks from './index';

describe('BooksReducers', () => {
let initialState: IBooksState;

beforeEach(() => {
initialState = { ...initialBooksState };
});

it('should change state when getBooks', () => {
const result = booksReducer(initialState, fromBooks.getBooks());

expect(result).toEqual({
isLoading: true,
books: []
});
});

it('should change state when getBooksSuccess', () => {
const result = booksReducer(
initialState,
fromBooks.getBooksSuccess({
books: booksListMock
})
);
expect(result).toEqual({
isLoading: false,
books: booksListMock
});
});
});

6. books.selectors.spec.ts → Testing that the selectors we wrote are fetching and returning the correct data from the store object.

import { booksStateMock } from './books.mocks';
import * as fromBooks from './index';

describe('BooksSelectors', () => {
it('should select book state', () => {
expect(fromBooks.selectBooksState.projector(booksStateMock)).toEqual(booksStateMock);
});

it('should select books list from state', () => {
expect(fromBooks.selectBooksList.projector(booksStateMock)).toEqual(booksStateMock.books);
});
});

Finish line

Let's run the jest test with coverage. It is highly likely that you will get 100% code coverage for the store you just created. Remember that if you are using ngrx means that most of the application logic, api calls and data transformation are happening there. So good code coverage means that your application logic is fully covered and harder to break.

That’s it

As usual, thank you for reading. I appreciate the time you take to read my content and stories. I hope you can find this article useful.
Stay tuned and happy coding!

--

--