28
loading...
This website collects cookies to deliver better user experience
import { createFeature, createReducer } from '@ngrx/store';
import { immerOn } from 'ngrx-immer';
import { customersApiActions, invoicesApiActions, customerPageActions } from './actions';
export const customersInitialState: {
customers: Record<string, Customer>;
invoices: Record<string, Invoice[]>;
} = {
customers: {},
invoices: {},
};
// the customersFeature reducer manages the customers and invoices state
// when a customer or the invoices are fetched, these are added to the state
// when the invoices are collected, the state is of the invoice is updated to 'collected'
export const customersFeature = createFeature({
name: 'customers',
reducer: createReducer(
customersInitialState,
immerOn(customersApiActions.success, (state, action) => {
state.customers[action.customer.id] = action.customer;
}),
immerOn(invoicesApiActions.success, (state, action) => {
state.invoices[action.customerId] = action.invoices;
}),
immerOn(customerPageActions.collected, (state, action) => {
const invoice = state.invoices[action.customerId].find(
(invoice) => invoice.id === action.invoiceId,
);
if (invoice) {
invoice.state = 'collected';
}
}),
),
});
import { customersFeature, customersInitialState } from '../reducer';
import { customersApiActions, invoicesApiActions, customerPageActions } from '../actions';
const { reducer } = customersFeature;
it('customersApiActions.success adds the customer', () => {
const customer = newCustomer();
const state = reducer(customersInitialState, customersApiActions.success({ customer }));
expect(state).toEqual({
customers: {
// 🔦 Use the customer variable
[customer.id]: customer,
},
invoices: {},
});
});
it('invoicesApiActions.success adds the invoices', () => {
const invoices = [newInvoice(), newInvoice(), newInvoice()];
const customerId = '3';
const state = reducer(
customersInitialState,
invoicesApiActions.success({ customerId, invoices }),
);
expect(state).toEqual({
customers: {},
invoices: {
// 🔦 Use the customerId and invoices variable
[customerId]: invoices,
},
});
});
it('customerPageActions.collected updates the status of the invoice to collected', () => {
const invoice = newInvoice();
invoice.state = 'open';
const customerId = '3';
const state = reducer(
{ ...customersInitialState, invoices: { [customerId]: [invoice] } },
customerPageActions.collected({ customerId, invoiceId: invoice.id }),
);
expect(state.invoices[customerdId][0]).toBe('collected');
});
// 🔦 A factory method to create a new customer entity (in a valid state)
function newCustomer(): Customer {
return { id: '1', name: 'Jane' };
}
// 🔦 A factory method to create a new invoice entity (in a valid state)
function newInvoice(): Invoice {
return { id: '1', total: 100.3 };
}
import { createSelector } from '@ngrx/store';
import { fromRouter } from '../routing';
import { customersFeature } from './reducer.ts';
// the selector reads the current customer id from the router url
// based on the customer id, the customer and the customer's invoices are retrieved
// the selector returns the current customer with the linked invoices
export const selectCurrentCustomerWithInvoices = createSelector(
fromRouter.selectCustomerId,
customersFeature.selectCustomers,
customersFeature.selectInvoices,
(customerId, customers, invoices) => {
if (!customerId) {
return null;
}
const customer = customers[customerId];
const invoicesForCustomer = invoices[customerId];
return {
customer,
invoices: invoicesForCustomer,
};
},
);
import { selectCurrentCustomerWithInvoices } from '../selectors';
it('selects the current customer with linked invoices', () => {
const customer = newCustomer();
const invoices = [newInvoice(), newInvoice()];
const result = selectCurrentCustomerWithInvoices.projector(customer.id, {
customers: {
[customer.id]: customer,
},
invoices: {
[customer.id]: invoices,
},
});
expect(result).toEqual({ customer, invoices });
});
function newCustomer(): Customer {
return { id: '1', name: 'Jane' };
}
function newInvoice(): Invoice {
return { id: '1', total: 100.3 };
}
TestBed
.delay
, throttle
, and delay
RxJS operators. We can assume that these behave as expected because these are tested within the RxJS codebase.Actions
stream and a service (that acts as a wrapper around HTTP requests) injected into the effect class.import { Injectable } from '@angular/core';
import { switchMap } from 'rxjs';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { customersApiActions, customerPageActions } from '../actions';
import { CustomerService } from './customer.service';
@Injectable()
export class CustomerEffects {
// the effect initiates a request to the customers service when the page is entered
// depending on the response, the effect dispatches a success or failure action
fetch$ = createEffect(() => {
return this.actions$.pipe(
ofType(customerPageActions.enter),
switchMap((action) =>
this.customerService.getById(action.customerId).pipe(
map((customer) => customersApiActions.fetchCustomerSuccess({ customer })),
catchError(() => of(customersApiActions.fetchCustomerError({ customerId }))),
),
),
);
});
constructor(private actions$: Actions, private customerService: CustomerService) {}
}
fetch$
effect can be tested we need to create a new instance of the Effect class, which requires the Actions
stream and a CustomerService
.Actions
is a bit more complicated.Subject
? This is a good choice, but it requires that we type the Subject
to only accept actions, so it becomes Subject<Action>
. While this works, it is not very convenient. Instead, I like to use the ActionsSubject
stream (from @ngrx/store), which a typed Actions subject.import { ActionsSubject, Action } from '@ngrx/store';
import { CustomersEffects } from '../customers.effects';
import { CustomerService } from '../customer.service';
import { customersApiActions, customerPageActions } from '../actions';
it('fetch$ dispatches a success action', () => {
// 🔦 The Effect Actions stream is created by instantiating a new `ActionsSubject`
const actions = new ActionsSubject();
const effects = new CustomersEffects(actions, newCustomerService());
// 🔦 Subscribe on the effect to catch emitted actions, which are used to assert the effect output
const result: Action[] = [];
effects.fetch$.subscribe((action) => {
result.push(action);
});
const action = customerPageActions.enter({ customerId: '3' });
actions.next(action);
expect(result).toEqual([
customersApiActions.fetchCustomerSuccess(
newCustomer({
id: action.customerId,
}),
),
]);
});
it('fetch$ dispatches an error action on failure', () => {
// 🔦 The actions stream is created by instantiating a new `ActionsSubject`
const actions = new ActionsSubject();
let customerService = newCustomerService();
// 🔦 Service method is test specific
customerService.getById = (customerId: number) => {
return throwError('Yikes.');
};
const effects = new CustomersEffects(actions, customerService());
const result: Action[] = [];
effects.fetch$.subscribe((action) => {
result.push(action);
});
const action = customerPageActions.enter({ customerId: '3' });
actions.next(action);
expect(result).toEqual([
customersApiActions.fetchCustomerError({
customerId: action.customerId,
}),
]);
});
function newCustomer({ id = '1' } = {}): Customer {
return { id, name: 'Jane' };
}
// 🔦 Service instances are mocked to prevent that HTTP requests are made
function newCustomerService(): CustomerService {
return {
getById: (customerId: number) => {
return of(newCustomer({ id: customerId }));
},
};
}
subscribeSpyTo
fakeTime
functionflush
function to fast-forward the time and handle all pending jobsgetValues
function on the subscribed spy to verify the emitted actions
import { subscribeSpyTo, fakeTime } from '@hirez_io/observer-spy';
import { ActionsSubject, Action } from '@ngrx/store';
import { throwError } from 'rxjs';
import { CustomerService } from '../customer.service';
import { CustomersEffects } from '../effects';
import { customersApiActions, customerPageActions } from '../actions';
it(
'fetch$ dispatches success action',
fakeTime((flush) => {
const actions = new ActionsSubject();
const effects = new CustomersEffects(actions, newCustomerService());
const observerSpy = subscribeSpyTo(effects.fetch$);
const action = customerPageActions.enter({ customerId: '3' });
actions.next(action);
flush();
expect(observerSpy.getValues()).toEqual([
customersApiActions.fetchCustomerSuccess(
newCustomer({
id: action.customerId,
}),
),
]);
}),
);
function newCustomer({ id = '1' } = {}): Customer {
return { id, name: 'Jane' };
}
function newCustomerService(): CustomerService {
return {
getById: (customerId: number) => {
return of(newCustomer({ id: customerId }));
},
};
}
advanceTimersByTime
: to advance time by a certain amount of millisecondsrunOnlyPendingTimers
: to advance the time until the current tasks are finishedrunAllTimers
: to advance time until all tasks are finishedrunOnlyPendingTimers
or runAllTimers
instead of advancing the time with advanceTimersByTime
. This makes sure that the test isn't impacted when the duration is modified.afterEach(() => {
// don't forget to reset the timers
jest.useRealTimers();
});
it('fetch$ dispatches success action with fake timers', () => {
jest.useFakeTimers();
const actions = new ActionsSubject();
const effects = new WerknemersEffects(actions, getMockStore(), newWerknemerService());
const result: Action[] = [];
effects.fetch$.subscribe((action) => {
result.push(action);
});
const action = werknemerActions.missingWerknemerOpened({ werknemerId: 3 });
actions.next(action);
jest.advanceTimersByTime(10_000);
// 🔦 to make tests less brittle, wait for the task to finish with `runOnlyPendingTimers` or `runOnlyPendingTimers` instead of advancing the time with `advanceTimersByTime`.
// This makes sure that the test isn't impacted when the duration is modified.
jest.runOnlyPendingTimers();
expect(result).toEqual([
werknemerActions.fetchWerknemerSuccess({
werknemer: newWerknemer({ id: action.werknemerId }),
}),
]);
});
dispatch: false
option).import { ActionsSubject, Action } from '@ngrx/store';
import { throwError } from 'rxjs';
import { BackgroundEffects } from '../background.effects';
import { NotificationsService } from '../notifications.service';
import { backgroundSocketActions } from '../actions';
it('it shows a notification on done', () => {
const notifications = newNotificationsService();
const actions = new ActionsSubject();
const effects = new BackgroundEffects(actions, notifications);
effects.done$.subscribe();
const action = backgroundSocketActions.done({ message: 'I am a message' });
actions.next(action);
expect(notifications.info).toHaveBeenCalledWith(action.message);
});
function newNotificationsService(): NotificationsService {
return {
success: jest.fn(),
error: jest.fn(),
info: jest.fn(),
};
}
dispatch
config option is set to false
we use the getEffectsMetadata
method, which returns the configuration of all effects in a class. Next, we can access the config options of the effect we want to test, in this case, the done$
member.import { ActionsSubject, Action } from '@ngrx/store';
import { getEffectsMetadata } from '@ngrx/effects';
import { throwError } from 'rxjs';
import { BackgroundEffects } from '../background.effects';
import { NotificationsService } from '../notifications.service';
import { backgroundSocketActions } from '../actions';
it('it shows a notification on done', () => {
const notifications = newNotificationsService();
const actions = new ActionsSubject();
const effects = new BackgroundEffects(actions, notifications);
effects.done$.subscribe();
const action = backgroundSocketActions.done({ message: 'I am a message' });
actions.next(action);
expect(getEffectsMetadata(effects).done$.dispatch).toBe(false);
expect(notifications.info).toHaveBeenCalledWith(action.message);
});
function newNotificationsService(): NotificationsService {
return {
success: jest.fn(),
error: jest.fn(),
info: jest.fn(),
};
}
getMockStore
to mock the ngrx store.getMockStore
accepts a configuration object to "mock" the selectors.import { ActionsSubject, Action } from '@ngrx/store';
import { getMockStore } from '@ngrx/store/testing';
import { CustomersEffects } from '../customers.effects';
import { CustomerService } from '../customer.service';
import { customersApiActions, customerPageActions } from '../actions';
it('fetch$ dispatches success action', () => {
const actions = new ActionsSubject();
const effects = new CustomersEffects(
actions,
getMockStore({
selectors: [{ selector: selectCustomerIds, value: [1, 3, 4] }],
}),
newCustomerService(),
);
const result: Action[] = []
effects.fetch$.subscribe((action) => {
result.push(action)
})
const existingAction = customerPageActions.enter({ customerId: 1 });
const newAction1 = customerPageActions.enter({ customerId: 2 });
const newAction2 = customerPageActions.enter({ customerId: 5 });
actions.next(existingAction);
actions.next(newAction1);
actions.next(newAction2);
expect(result).toEqual([
customersApiActions.fetchCustomerSuccess(newCustomer({ id: newAction1.customerId })),
customersApiActions.fetchCustomerSuccess(newCustomer({ id: newAction2.customerId })),
]);
});
createMock
method from the Angular Testing Library (import from @testing-library/angular/jest-utils
) to create a mock instance of the Title
service.createMockWithValues
to set a custom implementation for the router events. This way, we're able to emit new navigation events later to trigger the effect. The implementation of such an effect can be found in another blog post, Start using NgRx Effects for this.import { Title } from '@angular/platform-browser';
import { NavigationEnd, Router, RouterEvent } from '@angular/router';
import { createMock, createMockWithValues } from '@testing-library/angular/jest-utils';
import { Subject } from 'rxjs';
import { RoutingEffects } from '../routing.effects';
it('sets the title to the route data title', () => {
const routerEvents = new Subject<RouterEvent>();
const router = createMockWithValues(Router, {
events: routerEvents,
});
const title = createMock(Title);
const effect = new RoutingEffects(
router,
{
firstChild: {
snapshot: {
data: {
title: 'Test Title',
},
},
},
} as any,
title,
);
effect.title$.subscribe()
routerEvents.next(new NavigationEnd(1, '', ''));
expect(title.setTitle).toHaveBeenCalledWith('Test Title');
});
TestBed
.provideMockStore
method (imported from @ngrx/store/testing
) is used and is configured as an Angular provider.selectCustomerWithOrders
selector and displays the customer and the customer's orders on the page. There's also a refresh button that dispatches a customersPageActions.refresh
action to the store.import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectCustomerWithOrders } from './selectors';
import { customersPageActions } from './actions';
@Component({
selector: 'app-customer-page',
template: `
<ng-container *ngIf="customer$ | async as customer">
<h2>Customer: {{ customer.name }}</h2>
<button (click)="refresh(customer.id)">Refresh</button>
<table>
<thead>
<tr>
<th>Date</th>
<th>Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let order of customer.orders">
<td>{{ order.date }}</td>
<td>{{ order.amount }}</td>
<td>{{ order.status }}</td>
</tr>
</tbody>
</table>
</ng-container>
`,
})
export class CustomersSearchPageComponent {
customer$ = this.store.select(selectCustomerWithOrders);
constructor(private store: Store) {}
refresh(customerId: string) {
this.store.dispatch(customersPageActions.refresh({ customerId }));
}
}
import { provideMockStore } from '@ngrx/store/testing';
import { render, screen } from '@testing-library/angular';
import { selectCustomerWithOrders, CustomerWithOrders } from '../selectors';
import type { CustomerWithOrders } from '../selectors';
import { customersPageActions } from '../actions';
it('renders the customer with her orders', async () => {
const customer = newCustomer();
customer.orders = [
{ date: '2020-01-01', amount: 100, status: 'canceled' },
{ date: '2020-01-02', amount: 120, status: 'shipped' },
];
// 🔦 Testing With SIFERS by Moshe Kolodny https://medium.com/@kolodny/testing-with-sifers-c9d6bb5b36
await setup(customer);
// 🔦 toBeVisible is a custom jest matcher from jest-dom
expect(
screen.getByRole('heading', {
name: new RegExp(customer.name, 'i'),
}),
).toBeVisible();
// the table header is included
expect(screen.getAllByRole('row')).toHaveLength(3);
screen.getByRole('cell', {
name: customer.orders[0].date,
});
screen.getByRole('cell', {
name: customer.orders[0].amount,
});
screen.getByRole('cell', {
name: customer.orders[0].status,
});
});
// 🔦 Testing With SIFERS by Moshe Kolodny https://medium.com/@kolodny/testing-with-sifers-c9d6bb5b362
async function setup(customer: CustomerWithOrders) {
await render('<app-customer-page></app-customer-page>', {
imports: [CustomerPageModule],
providers: [
provideMockStore({
selectors: [{ selector: selectCustomerWithOrders, value: customer }],
}),
],
});
}
function newCustomer(): CustomerWithOrders {
return {
id: '1',
name: 'Jane',
orders: [],
};
}
dispatch
method of the store. We use this spy in the assertion to verify that the action is dispatched.import { provideMockStore } from '@ngrx/store/testing';
import { render, screen } from '@testing-library/angular';
import { selectCustomerWithOrders, CustomerWithOrders } from '../selectors';
import type { CustomerWithOrders } from '../selectors';
import { customersPageActions } from '../actions';
it('renders the customer name', async () => {
const customer = newCustomer();
customer.orders = [
{ date: '2020-01-01', amount: 100, status: 'canceled' },
{ date: '2020-01-02', amount: 120, status: 'shipped' },
];
// 🔦 Testing With SIFERS by Moshe Kolodny https://medium.com/@kolodny/testing-with-sifers-c9d6bb5b362
const { dispatchSpy } = await setup(customer);
// 🔦 toBeVisible is a custom jest matcher from jest-dom
expect(
screen.getByRole('heading', {
name: new RegExp(customer.name, 'i'),
}),
).toBeVisible();
// the table header is included
expect(screen.getAllByRole('row')).toHaveLength(3);
screen.getByRole('cell', {
name: customer.orders[0].date,
});
screen.getByRole('cell', {
name: customer.orders[0].amount,
});
screen.getByRole('cell', {
name: customer.orders[0].status,
});
userEvent.click(
screen.getByRole('button', {
name: /refresh/i,
}),
);
expect(dispatchSpy).toHaveBeenCalledWith(
customersPageActions.refresh({ customerId: customer.id }),
);
});
// 🔦 Testing With SIFERS by Moshe Kolodny https://medium.com/@kolodny/testing-with-sifers-c9d6bb5b362
async function setup(customer: CustomerWithOrders) {
await render('<app-customer-page></app-customer-page>', {
imports: [CustomerPageModule],
providers: [
provideMockStore({
selectors: [{ selector: selectCustomerWithOrders, value: customer }],
}),
],
});
const store = TestBed.inject(MockStore);
store.dispatch = jest.fn();
return { dispatchSpy: store.dispatch };
}
function newCustomer(): CustomerWithOrders {
return {
id: '1',
name: 'Jane',
orders: [],
};
}
CustomersSearchStore
that is used in the CustomersSearchPageComponent
component.import { Injectable } from '@angular/core';
import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { Observable, delay, switchMap } from 'rxjs';
import { CustomersService } from './services';
import { Customer } from './models';
export interface CustomersSearchState {
customers: Customer[];
}
@Injectable()
export class CustomersSearchStore extends ComponentStore<CustomersSearchState> {
constructor(private readonly customersService: CustomersService) {
super({ customers: [] });
}
readonly customers$ = this.select((state) => state.customers);
setCustomers(customers: Customer[]) {
this.patchState({ customers });
}
clearCustomers() {
this.patchState({ customers: [] });
}
readonly search = this.effect((trigger$: Observable<string>) => {
return trigger$.pipe(
delay(1000),
switchMap((query) =>
this.customersService.search(query).pipe(
tapResponse(
(customers) => this.setCustomers(customers),
() => this.clearCustomers(),
),
),
),
);
});
}
import { Component } from '@angular/core';
import { CustomersSearchStore } from './customers-search.store';
@Component({
template: `
<input type="search" #query />
<button (click)="search(query.value)">Search</button>
<a *ngFor="let customer of customers$ | async" [routerLink]="['customer', customer.id]">
{{ customer.name }}
</a>
`,
providers: [CustomersSearchStore],
})
export class CustomersSearchPageComponent {
customers$ = this.customersStore.customers$;
constructor(private readonly customersStore: CustomersSearchStore) {}
search(query: string) {
this.customersStore.search(query);
}
}
CustomersService
service, which is a dependency from the component store.import { RouterTestingModule } from '@angular/router/testing';
import { render, screen } from '@testing-library/angular';
import { provideMockWithValues } from '@testing-library/angular/jest-utils';
import userEvent from '@testing-library/user-event';
import { of } from 'rxjs';
import { CustomersSearchPageComponent } from '../customers-search.component';
import { Customer } from '../models';
import { CustomersService } from '../services';
afterEach(() => {
jest.useRealTimers();
});
it('fires a search and renders the retrieved customers', async () => {
jest.useFakeTimers();
await setup();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
userEvent.type(screen.getByRole('searchbox'), 'query');
userEvent.click(
screen.getByRole('button', {
name: /search/i,
}),
);
jest.runOnlyPendingTimers();
const link = await screen.findByRole('link', {
name: /query/i,
});
expect(link).toHaveAttribute('href', '/customer/1');
});
async function setup() {
await render(CustomersSearchPageComponent, {
imports: [RouterTestingModule.withRoutes([])],
providers: [
provideMockWithValues(CustomersService, {
search: jest.fn((query) => {
return of([newCustomer(query)]);
}),
}),
],
});
}
function newCustomer(name = 'customer'): Customer {
return {
id: '1',
name,
};
}
import { createMockWithValues } from '@testing-library/angular/jest-utils';
import { of, throwError } from 'rxjs';
import { Customer, CustomersSearchStore } from '../customers-search.store';
import { CustomersService } from '../services';
afterEach(() => {
jest.useRealTimers();
});
it('initializes with no customers', async () => {
const { customers } = setup();
expect(customers).toHaveLength(0);
});
it('search fills the state with customers', () => {
jest.useFakeTimers();
const { store, customers, service } = setup();
const query = 'john';
store.search(query);
jest.runOnlyPendingTimers();
expect(service.search).toHaveBeenCalledWith(query);
expect(customers).toHaveLength(1);
});
it('search error empties the state', () => {
jest.useFakeTimers();
const { store, customers } = setup(() => throwError('Yikes.'));
store.setState({ customers: [newCustomer()] });
store.search('john');
jest.runOnlyPendingTimers();
expect(customers).toHaveLength(0);
});
it('clearCustomers empties the state', () => {
const { store, customers } = setup();
store.setState({ customers: [newCustomer()] });
store.clearCustomers();
expect(customers).toHaveLength(0);
});
function setup(customersSearch = (query: string) => of([newCustomer(query)])) {
const service = createMockWithValues(CustomersService, {
search: jest.fn(customersSearch),
});
const store = new CustomersSearchStore(service);
let customers: Customer[] = [];
store.customers$.subscribe((state) => {
customers.length = 0;
customers.push(...state);
});
return { store, customers, service };
}
function newCustomer(name = 'customer'): Customer {
return {
id: '1',
name,
};
}
componentProviders
array.import { RouterTestingModule } from '@angular/router/testing';
import { render, screen } from '@testing-library/angular';
import { createMockWithValues } from '@testing-library/angular/jest-utils';
import userEvent from '@testing-library/user-event';
import { of } from 'rxjs';
import { CustomersSearchPageComponent } from '../customers-search.component';
import { Customer, CustomersSearchStore } from '../customers-search.store';
it('renders the customers', async () => {
await setup();
const link = await screen.findByRole('link', {
name: /customer/i,
});
expect(link).toHaveAttribute('href', '/customer/1');
});
it('invokes the search method', async () => {
const { store } = await setup();
const query = 'john';
userEvent.type(screen.getByRole('searchbox'), query);
userEvent.click(
screen.getByRole('button', {
name: /search/i,
}),
);
expect(store.search).toHaveBeenCalledWith(query);
});
async function setup() {
const store = createMockWithValues(CustomersSearchStore, {
customers$: of([newCustomer()]),
search: jest.fn(),
});
await render(CustomersSearchPageComponent, {
imports: [RouterTestingModule.withRoutes([])],
componentProviders: [
{
provide: CustomersSearchStore,
useValue: store,
},
],
});
return { store };
}
function newCustomer(): Customer {
return {
id: '1',
name: 'name',
};
}
projector
method. Instead of providing the state tree and invoking child selectors, we invoke the projector
with the return values of the child selectors. The result is then asserted against the expected value.ActionsSubject
.