Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Testing an NgRx project
This is Angular profile imageTim Deschryver
Tim Deschryver forThis is Angular

Posted on • Edited on • Originally published attimdeschryver.dev

     

Testing an NgRx project

Follow me on Twitter at@tim_deschryver | Subscribe to theNewsletter | Originally published ontimdeschryver.dev.


No intro needed, let's directly dive into some code snippets for each part of the Angular application!
Each section includes a code snippet of the relevant part of the application, followed by one or more code snippets to see how we can test it efficiently. Some snippets also put a line in a spotlight (🔦) to showcase best practices.

Actions

Let's start with the easiest one, which are the NgRx actions.
I don't see any value to test these in isolation, instead, they are tested indirectly when we test the reducers and components.

Reducers

A reducer is a (synchronous) pure function that is invoked with the current state and an action.
Based on the state and the action, the reducer returns a new state.

Because a reducer is pure, and there are no external dependencies, the test specifications are very simple.
There's no need to configure and mock anything, in a test we invoke the reducer with a predefined state and an action.

Given the state and the action, the assertion asserts that the newly returned state is correct.

import{createFeature,createReducer}from'@ngrx/store';import{immerOn}from'ngrx-immer';import{customersApiActions,invoicesApiActions,customerPageActions}from'./actions';exportconstcustomersInitialState:{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'exportconstcustomersFeature=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)=>{constinvoice=state.invoices[action.customerId].find((invoice)=>invoice.id===action.invoiceId,);if(invoice){invoice.state='collected';}}),),});
Enter fullscreen modeExit fullscreen mode

Some practices I want to put in the spotlight:

🔦 The usage of the factory method to create new state entities. This creates a single point of entry when the structure of an object changes in the future. It also makes it easy to create an object in a good state, while you can still override the object in specific test cases.

🔦 Test data is assigned to variables (arrange). This data is used to invoke the reducer (act) and to verify the result (assert). Assigning test data to a variable prevents magic values, and later, failing tests when the data is changed.

import{customersFeature,customersInitialState}from'../reducer';import{customersApiActions,invoicesApiActions,customerPageActions}from'../actions';const{reducer}=customersFeature;it('customersApiActions.success adds the customer',()=>{constcustomer=newCustomer();conststate=reducer(customersInitialState,customersApiActions.success({customer}));expect(state).toEqual({customers:{// 🔦 Use the customer variable[customer.id]:customer,},invoices:{},});});it('invoicesApiActions.success adds the invoices',()=>{constinvoices=[newInvoice(),newInvoice(),newInvoice()];constcustomerId='3';conststate=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',()=>{constinvoice=newInvoice();invoice.state='open';constcustomerId='3';conststate=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)functionnewCustomer():Customer{return{id:'1',name:'Jane'};}// 🔦 A factory method to create a new invoice entity (in a valid state)functionnewInvoice():Invoice{return{id:'1',total:100.3};}
Enter fullscreen modeExit fullscreen mode

Selectors

NgRx selectors are pure functions to read a slice from the global store.

I categorize selectors into two groups, selectors that access raw data from the state tree, and selectors that merge data from multiple selectors from the first category and transform it into a useable model.

I never write tests for the selectors from the first category, and I rely on TypeScript to catch my silly mistakes.

The second category has logic in the selectors' projector to transform the data.
It's this logic that is crucial to test.

To test these selectors there are two options:

  1. provide the full state tree to the selector, this also tests the logic of child selectors
  2. invoke the selector's projector method with input parameters, this only tests the project itself

The first option covers more production code, but in my experience, it also has a higher maintenance cost.
That's why I prefer to use the latter.

A selector test isn't complex.
The test invokes the selector's projector method with a given input and verifies its output.

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 invoicesexportconstselectCurrentCustomerWithInvoices=createSelector(fromRouter.selectCustomerId,customersFeature.selectCustomers,customersFeature.selectInvoices,(customerId,customers,invoices)=>{if(!customerId){returnnull;}constcustomer=customers[customerId];constinvoicesForCustomer=invoices[customerId];return{customer,invoices:invoicesForCustomer,};},);
Enter fullscreen modeExit fullscreen mode
import{selectCurrentCustomerWithInvoices}from'../selectors';it('selects the current customer with linked invoices',()=>{constcustomer=newCustomer();constinvoices=[newInvoice(),newInvoice()];constresult=selectCurrentCustomerWithInvoices.projector(customer.id,{customers:{[customer.id]:customer,},invoices:{[customer.id]:invoices,},});expect(result).toEqual({customer,invoices});});functionnewCustomer():Customer{return{id:'1',name:'Jane'};}functionnewInvoice():Invoice{return{id:'1',total:100.3};}
Enter fullscreen modeExit fullscreen mode

Effects

Effects handle all the side-effects of the application.
These are usually asynchronous operations, for example an effect that makes an HTTP request.

Testing NgRx effects is where things are starting to get interesting because this is where, for the first time, (external) dependencies are involved.

To keep effect tests simple and fast, I prefer to not rely on the dependency container of Angular to provide and inject the dependencies with the AngularTestBed.
Instead, I like to instantiate the new effect class manually and provide all of the dependencies myself.
That also means that some dependencies are going to be mocked, In the next snippets I'm using jest to create mocks.

Most of the effect tests that I write are not using the marble diagram syntax to verify the output of an effect.
This, not only to keep things as simple as possible but also because it makes sure that we test the right things.We want to test the effect flow, not the internal details of the effect implementation.
Frankly said, we shouldn't care about which higher-order mapping operator is used, nor should we care if time-based operators are used to wait on a trigger, for example, thedelay,throttle, anddelay RxJS operators. We can assume that these behave as expected because these are tested within the RxJS codebase.

Effect tests can become complex, so let's start with a simple example to cover the basics.
Afterward, we are going to explore some more advanced effect scenarios.

Effects that use Actions and Services

The simple example covers the most common ground and makes an HTTP request when the effect receives an action.
The effect class gets theActions 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()exportclassCustomerEffects{// 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 actionfetch$=createEffect(()=>{returnthis.actions$.pipe(ofType(customerPageActions.enter),switchMap((action)=>this.customerService.getById(action.customerId).pipe(map((customer)=>customersApiActions.fetchCustomerSuccess({customer})),catchError(()=>of(customersApiActions.fetchCustomerError({customerId}))),),),);});constructor(privateactions$:Actions,privatecustomerService:CustomerService){}}
Enter fullscreen modeExit fullscreen mode

Before thefetch$ effect can be tested we need to create a new instance of the Effect class, which requires theActions stream and aCustomerService.

Since the service is under our ownership, it's easy to create a mocked instance. This is needed to prevent the effect from calling the real service and making HTTP requests.

TheActions is a bit more complicated.
Becauseit's a typed observable, it doesn't make it easy to be mocked.
Spawning a new observable also doesn't provide a solution because we need to send actions to the effect during the test in order to trigger it.
So what about using aSubject? This is a good choice, but it requires that we type theSubject to only accept actions, so it becomesSubject<Action>. While this works, it is not very convenient. Instead, I like to use theActionsSubject stream (from @ngrx/store), which atyped Actions subject.

Now, we're able to create a new effect instance, and we can send actions to the effect under test.
The only thing left before we can test the effect is to get the output of an effect.
For that, we subscribe to the effect and capture the emitted actions.

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`constactions=newActionsSubject();consteffects=newCustomersEffects(actions,newCustomerService());// 🔦 Subscribe on the effect to catch emitted actions, which are used to assert the effect outputconstresult:Action[]=[];effects.fetch$.subscribe((action)=>{result.push(action);});constaction=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`constactions=newActionsSubject();letcustomerService=newCustomerService();// 🔦 Service method is test specificcustomerService.getById=(customerId:number)=>{returnthrowError('Yikes.');};consteffects=newCustomersEffects(actions,customerService());constresult:Action[]=[];effects.fetch$.subscribe((action)=>{result.push(action);});constaction=customerPageActions.enter({customerId:'3'});actions.next(action);expect(result).toEqual([customersApiActions.fetchCustomerError({customerId:action.customerId,}),]);});functionnewCustomer({id='1'}={}):Customer{return{id,name:'Jane'};}// 🔦 Service instances are mocked to prevent that HTTP requests are madefunctionnewCustomerService():CustomerService{return{getById:(customerId:number)=>{returnof(newCustomer({id:customerId}));},};}
Enter fullscreen modeExit fullscreen mode

Effect tests rewritten with observer-spy

The above tests have a couple of drawbacks.

A minor drawback is that each test includes boilerplate code to catch the emitted actions. As a countermeasure, we can write a small utility method that catches all emitted actions.

But the major drawback is that the execution time of the test is affected by the time it takes to execute the effect. For effects that rely on time-based operators, this can be a problem. In its best case, this slows down the test. At its worst, it can lead to failing tests because the test exceeds the timeout limit.

Here's where theobserver-spy library _- thanks toShai Reznik for creating this library -_ comes into play. With observer-spy, we can subscribe to an observable stream, "flush" all pending tasks, and lastly, read the emitted values.

To use observer-spy in a test, we have to make small modifications to the test:

  1. subscribe to the effect withsubscribeSpyTo
  2. if the test is time-sensitive, wrap the test callback with thefakeTime function
  3. if the test is time-sensitive, invoke theflush function to fast-forward the time and handle all pending jobs
  4. use thegetValues 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)=>{constactions=newActionsSubject();consteffects=newCustomersEffects(actions,newCustomerService());constobserverSpy=subscribeSpyTo(effects.fetch$);constaction=customerPageActions.enter({customerId:'3'});actions.next(action);flush();expect(observerSpy.getValues()).toEqual([customersApiActions.fetchCustomerSuccess(newCustomer({id:action.customerId,}),),]);}),);functionnewCustomer({id='1'}={}):Customer{return{id,name:'Jane'};}functionnewCustomerService():CustomerService{return{getById:(customerId:number)=>{returnof(newCustomer({id:customerId}));},};}
Enter fullscreen modeExit fullscreen mode

Effect tests and fake timers

If bringing a library just for making these test easy is not your cup of tea, the other option is to use fake timers. This is a solution that isn't framework/library specific. The examples in this post are usingJest fake timers.

It looks similar to your"default" effect tests, but you get to play a time wizard because you'll have to advance the time by using your magic powers.

In contrast to observer-spy, where you need to subscribe on an Observable stream to flush all pending tasks, fake timers allows you to forward the time for all pending tasks. This is useful when you can't subscribe to a source, for example in a component.

With fake timers there are three possibilities to advance the time:

  • advanceTimersByTime: to advance time by a certain amount of milliseconds
  • runOnlyPendingTimers: to advance the time until the current tasks are finished
  • runAllTimers: to advance time until all tasks are finished

Some practices I want to put in the spotlight:

🔦 to make tests less brittle, wait for the pending task(s) to finish withrunOnlyPendingTimers orrunAllTimers instead of advancing the time withadvanceTimersByTime. This makes sure that the test isn't impacted when the duration is modified.

afterEach(()=>{// don't forget to reset the timersjest.useRealTimers();});it('fetch$ dispatches success action with fake timers',()=>{jest.useFakeTimers();constactions=newActionsSubject();consteffects=newWerknemersEffects(actions,getMockStore(),newWerknemerService());constresult:Action[]=[];effects.fetch$.subscribe((action)=>{result.push(action);});constaction=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}),}),]);});
Enter fullscreen modeExit fullscreen mode

Effects that don't dispatch actions

So far we've seen effects that result in actions being dispatched, but as you probably already know, some effects don't dispatch an action (with thedispatch: false option).

To verify that these non-dispatching effects are doing what they're supposed to do, we can reuse 90% of a test, and modify the assertion. Instead of checking the emitted actions, we verify that a side-effect has been executed.

For example, the below test verifies that an action results in a notification.

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',()=>{constnotifications=newNotificationsService();constactions=newActionsSubject();consteffects=newBackgroundEffects(actions,notifications);effects.done$.subscribe();constaction=backgroundSocketActions.done({message:'I am a message'});actions.next(action);expect(notifications.info).toHaveBeenCalledWith(action.message);});functionnewNotificationsService():NotificationsService{return{success:jest.fn(),error:jest.fn(),info:jest.fn(),};}
Enter fullscreen modeExit fullscreen mode

To test that thedispatch config option is set tofalse we use thegetEffectsMetadata 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, thedone$ 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',()=>{constnotifications=newNotificationsService();constactions=newActionsSubject();consteffects=newBackgroundEffects(actions,notifications);effects.done$.subscribe();constaction=backgroundSocketActions.done({message:'I am a message'});actions.next(action);expect(getEffectsMetadata(effects).done$.dispatch).toBe(false);expect(notifications.info).toHaveBeenCalledWith(action.message);});functionnewNotificationsService():NotificationsService{return{success:jest.fn(),error:jest.fn(),info:jest.fn(),};}
Enter fullscreen modeExit fullscreen mode

Effects that use the NgRx Global Store

NgRx v11 included a new methodgetMockStore (imported from@ngrx/store/testing) to new up a new mock store instance. This is perfect for our use case, as we can usegetMockStore to prevent using the Angular TestBed for testing NgRx Effects. Meaning that we can keep the setup to all of our effects the same.

As an example, let's take an effect that only instantiates a new HTTP request for entities that are not in the store. To read from the store, the effect uses a selector to retrieve the entities from the store.
The implementation of such an effect can be found in another blog post,Start using NgRx Effects for this.

The test below usesgetMockStore to mock the ngrx store.
getMockStore accepts a configuration object to "mock" the selectors.
To do so, define the selectors that are used in the effect and assign them the desired return value.

When a return value is assigned to a selector, the logic of the selector isn't executed, but the given value is simply returned.
The rest of the test remains untouched.

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',()=>{constactions=newActionsSubject();consteffects=newCustomersEffects(actions,getMockStore({selectors:[{selector:selectCustomerIds,value:[1,3,4]}],}),newCustomerService(),);constresult:Action[]=[]effects.fetch$.subscribe((action)=>{result.push(action)})constexistingAction=customerPageActions.enter({customerId:1});constnewAction1=customerPageActions.enter({customerId:2});constnewAction2=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})),]);});
Enter fullscreen modeExit fullscreen mode

Effects that use the Angular Router

Manually creating a new instance of the Router is difficult and tedious.
Sadly, it also doesn't have a simple method to create a new instance outside of the Angular TestBed.

So how do we go about this?
We could create a minimal implementation of the Router and just mock the methods that we need, or we could use a library that automatically creates spy implementations for all members and methods of a given type, in our example, the Router.

The test below verifies that the window's title is updated when the user navigates to a different route.

In the example, we use thecreateMock method from theAngular Testing Library (import from@testing-library/angular/jest-utils) to create a mock instance of theTitle service.

The test also usescreateMockWithValues 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.

The test below verifies that the window title is updated upon a router navigation.

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',()=>{constrouterEvents=newSubject<RouterEvent>();constrouter=createMockWithValues(Router,{events:routerEvents,});consttitle=createMock(Title);consteffect=newRoutingEffects(router,{firstChild:{snapshot:{data:{title:'Test Title',},},},}asany,title,);effect.title$.subscribe()routerEvents.next(newNavigationEnd(1,'',''));expect(title.setTitle).toHaveBeenCalledWith('Test Title');});
Enter fullscreen modeExit fullscreen mode

Components With Global Store

With most of the logic pulled outside of the component, we're left with a small component that doesn't require a lot of dependencies to be tested. There's also a big chance that you're splitting your components into two categories: containers, and presentational components.

In this post, we'll focus on containers because these are the ones that interact with the NgRx global store. If you want to become more familiar with testing presentational components, I got another post for you,Getting the most value out of your Angular Component Tests.

To test containers components, we again have two options.

One option is to treat a component test as an integration test.
This means that real implementations of selectors, reducers, and effects are used, but that all communications with external services are mocked. Following the "don't test implementation details" best practice, this seems like the best option. But in this case, I would advise not to do it, because the test is going to be brittle and have a complex setup. The setup is hard because you have to configure the store, you need to know the details of all dependencies, and you have to maintain the state tree.

This is the opposite of what we're trying to achieve here.
We want our test to help us develop and maintain an application, not a test that no one understands and wants to touch. Maintaining such a test might take up more time than developing new features.

The second option is to just test the component itself and the interaction with the store, a unit test.
To verify the store interaction we use a mocked store because this prevents that reducers and effects are invoked.

From my experience, writing unit tests for container components is the most productive approach while we can still be confident in the code that we write.
Because there are focussed unit tests on the reducers, selectors, effects, and containers the tests themselves are easier to reason about.

Testing a component requires, for the first time, the usage of the AngularTestBed.

Here again, we're using theAngular Testing Library. While the Angular Testing Library helps us to make the setup and the component interaction easier, it also guides us to create user-friendly components.
A win-win situation for everyone.

To inject the store into the component, theprovideMockStore method (imported from@ngrx/store/testing) is used and is configured as an Angular provider.

As an example, let's take a look at a component that displays a customer.
The component reads the customer from the store with theselectCustomerWithOrders selector and displays the customer and the customer's orders on the page. There's also a refresh button that dispatches acustomersPageActions.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>    `,})exportclassCustomersSearchPageComponent{customer$=this.store.select(selectCustomerWithOrders);constructor(privatestore:Store){}refresh(customerId:string){this.store.dispatch(customersPageActions.refresh({customerId}));}}
Enter fullscreen modeExit fullscreen mode

The test to check that the customer's name is displayed correctly looks as follows.
The important part here is that a mock store is provided, and while doing so, that the selector is provided a mocked return value. This prevents that we have to configure the whole store, and we can simply provide what is needed. This keeps the test readable and compact.

Some practices I want to put in the spotlight:

🔦 toBeVisible is a custom jest matcher fromjest-dom

🔦Testing With SIFERS byMoshe Kolodny to promote test setups

import{provideMockStore}from'@ngrx/store/testing';import{render,screen}from'@testing-library/angular';import{selectCustomerWithOrders,CustomerWithOrders}from'../selectors';importtype{CustomerWithOrders}from'../selectors';import{customersPageActions}from'../actions';it('renders the customer with her orders',async()=>{constcustomer=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-c9d6bb5b36awaitsetup(customer);// 🔦 toBeVisible is a custom jest matcher from jest-domexpect(screen.getByRole('heading',{name:newRegExp(customer.name,'i'),}),).toBeVisible();// the table header is includedexpect(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-c9d6bb5b362asyncfunctionsetup(customer:CustomerWithOrders){awaitrender('<app-customer-page></app-customer-page>',{imports:[CustomerPageModule],providers:[provideMockStore({selectors:[{selector:selectCustomerWithOrders,value:customer}],}),],});}functionnewCustomer():CustomerWithOrders{return{id:'1',name:'Jane',orders:[],};}
Enter fullscreen modeExit fullscreen mode

The above example verifies that the component renders correctly.
Next, we'll see how we can assert that an action is dispatched to the store, in this example when the refresh button is clicked.

To assert that the component sends the refresh action to the store, we're assigning a spy to thedispatch 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';importtype{CustomerWithOrders}from'../selectors';import{customersPageActions}from'../actions';it('renders the customer name',async()=>{constcustomer=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-c9d6bb5b362const{dispatchSpy}=awaitsetup(customer);// 🔦 toBeVisible is a custom jest matcher from jest-domexpect(screen.getByRole('heading',{name:newRegExp(customer.name,'i'),}),).toBeVisible();// the table header is includedexpect(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-c9d6bb5b362asyncfunctionsetup(customer:CustomerWithOrders){awaitrender('<app-customer-page></app-customer-page>',{imports:[CustomerPageModule],providers:[provideMockStore({selectors:[{selector:selectCustomerWithOrders,value:customer}],}),],});conststore=TestBed.inject(MockStore);store.dispatch=jest.fn();return{dispatchSpy:store.dispatch};}functionnewCustomer():CustomerWithOrders{return{id:'1',name:'Jane',orders:[],};}
Enter fullscreen modeExit fullscreen mode

Component Store

In contrast with the global NgRx store, a component store is strongly coupled to the component.
That's the reason why I prefer to see the component store as an implementation detail and thus I almost don't mock the component store during tests. Because the test is using the real implementation of the component store some of the dependencies of the component store must be mocked to prevent communication with the external world.

In the following example, there's aCustomersSearchStore that is used in theCustomersSearchPageComponent component.
The store holds the customers' state and makes an HTTP request to fetch the customers.
The component uses the store to render the customers in the view.

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';exportinterfaceCustomersSearchState{customers:Customer[];}@Injectable()exportclassCustomersSearchStoreextendsComponentStore<CustomersSearchState>{constructor(privatereadonlycustomersService:CustomersService){super({customers:[]});}readonlycustomers$=this.select((state)=>state.customers);setCustomers(customers:Customer[]){this.patchState({customers});}clearCustomers(){this.patchState({customers:[]});}readonlysearch=this.effect((trigger$:Observable<string>)=>{returntrigger$.pipe(delay(1000),switchMap((query)=>this.customersService.search(query).pipe(tapResponse((customers)=>this.setCustomers(customers),()=>this.clearCustomers(),),),),);});}
Enter fullscreen modeExit fullscreen mode
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],})exportclassCustomersSearchPageComponent{customers$=this.customersStore.customers$;constructor(privatereadonlycustomersStore:CustomersSearchStore){}search(query:string){this.customersStore.search(query);}}
Enter fullscreen modeExit fullscreen mode

To get to know the difference between an integration test and a unit test, we're going to write the same tests for the component.

Integration tests

The integration test verifies that the component and the component store are integrated correctly.
If you've followed the examples in the previous sections, the next test is going to read easily.

The component test is written with the help ofAngular Testing Library.
During the setup, we provide a mock for theCustomersService service, which is a dependency from the component store.
For the rest of the test, we replicate a user interaction with the store and assert that the right things are rendered.
Because the search query has a delay, the test uses Jest fake timers to forward the elapsed time.

These kinds of tests tend to be longer than you're used to and these are going to verify multiple assertions.
This is totally fine. It's even desired to write tests like this if you're using the (Angular) Testing Library.

import{RouterTestingModule}from'@angular/router/testing';import{render,screen}from'@testing-library/angular';import{provideMockWithValues}from'@testing-library/angular/jest-utils';importuserEventfrom'@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();awaitsetup();expect(screen.queryByRole('link')).not.toBeInTheDocument();userEvent.type(screen.getByRole('searchbox'),'query');userEvent.click(screen.getByRole('button',{name:/search/i,}),);jest.runOnlyPendingTimers();constlink=awaitscreen.findByRole('link',{name:/query/i,});expect(link).toHaveAttribute('href','/customer/1');});asyncfunctionsetup(){awaitrender(CustomersSearchPageComponent,{imports:[RouterTestingModule.withRoutes([])],providers:[provideMockWithValues(CustomersService,{search:jest.fn((query)=>{returnof([newCustomer(query)]);}),}),],});}functionnewCustomer(name='customer'):Customer{return{id:'1',name,};}
Enter fullscreen modeExit fullscreen mode

Unit tests

For component stores that are complex and/or require more dependencies, it might be easier and better to unit test the component store and the component separately. Doing this makes it easier to test specific cases. The test suite also going to run faster because the component doesn't need to be rendered to execute component store tests, of which you will write most specifications.

Just like testing the global store, you only write a few component tests that rely on a component store. These make sure that the interaction between the component and the component store is correct.

Component store unit tests

You're going to write many (small) tests to make sure that each method of the component store behaves correctly.
Most of them are updating the state of the component store to assert that the state is in the correct shape.

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();constquery='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);});functionsetup(customersSearch=(query:string)=>of([newCustomer(query)])){constservice=createMockWithValues(CustomersService,{search:jest.fn(customersSearch),});conststore=newCustomersSearchStore(service);letcustomers:Customer[]=[];store.customers$.subscribe((state)=>{customers.length=0;customers.push(...state);});return{store,customers,service};}functionnewCustomer(name='customer'):Customer{return{id:'1',name,};}
Enter fullscreen modeExit fullscreen mode

Component unit tests that use the component store

In comparison to component store tests, we only have a few component tests that rely on the component store.
These tests are also smaller in comparison to the component tests that use the real implementation of the component store.
Instead of using the real implementation of the component store, the component store is mocked during the setup.
Because the component store is provided at the component level, the mocked store instance needs to be provided in thecomponentProviders array.

The component tests can be divided into two groups, one that renders the current state, and the other that invoke component store methods.

For the first group, we assign a predefined result to the select members of the component store.
After the component is rendered, the test takes a look at the component and verifies that the view is correct.

The second group of tests are assigning spies to the component store methods, which are used to check that the component store method is invoked after interacting with the component.

import{RouterTestingModule}from'@angular/router/testing';import{render,screen}from'@testing-library/angular';import{createMockWithValues}from'@testing-library/angular/jest-utils';importuserEventfrom'@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()=>{awaitsetup();constlink=awaitscreen.findByRole('link',{name:/customer/i,});expect(link).toHaveAttribute('href','/customer/1');});it('invokes the search method',async()=>{const{store}=awaitsetup();constquery='john';userEvent.type(screen.getByRole('searchbox'),query);userEvent.click(screen.getByRole('button',{name:/search/i,}),);expect(store.search).toHaveBeenCalledWith(query);});asyncfunctionsetup(){conststore=createMockWithValues(CustomersSearchStore,{customers$:of([newCustomer()]),search:jest.fn(),});awaitrender(CustomersSearchPageComponent,{imports:[RouterTestingModule.withRoutes([])],componentProviders:[{provide:CustomersSearchStore,useValue:store,},],});return{store};}functionnewCustomer():Customer{return{id:'1',name:'name',};}
Enter fullscreen modeExit fullscreen mode

Conclusion

Writing tests for an Angular application doesn't have to be a chore.
When the tests are written correctly, they are used to verify the correctness of the application while they don't hold you back on building new features or changing existing features.

For me, the ideal test is a test that mocks as little as possible and keeps the setup simple.
This makes sure that the test is easier to maintain.

To make the tests in this post as simple as possible, the Angular TestBed is avoided.

Reducers are called with a predefined state and an action in the test. The test then verifies that the returned state is correct.

Selectors that contain logic are tested with theprojector method. Instead of providing the state tree and invoking child selectors, we invoke theprojector with the return values of the child selectors. The result is then asserted against the expected value.

Effect tests are written without the Angular TestBed. We create the effect instance manually and mock its dependencies. The effect that is being tested is subscribed to catch all of the emitted actions, which are then checked. To trigger the effect we send a new action to theActionsSubject.

Components that use the global store are tested with the help of theAngular Testing Library. In component tests, we don't use the real store instead, but we use a mocked store.

Components with the component store have two kinds of tests, unit tests, and integration tests. I prefer to write integration tests, but when they become too complex, I prefer to write unit tests.
Integration tests use the real store and mock the component store dependencies.
Unit tests are written on the component store, and additionally on the component while providing a mocked component store instance.

Happy testing!


Follow me on Twitter at@tim_deschryver | Subscribe to theNewsletter | Originally published ontimdeschryver.dev.

Top comments(1)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
mustapha profile image
Mustapha Aouas
Technical writer, speaker & JS / TS developer — I like sharing what I know and learning what I don't 👨🏻‍💻 — Angular Lyon co-organizer
  • Location
    France
  • Education
    Epitech Paris - Master's degree in CS
  • Joined

Wow I’m in love with the observer-spy lib!
Great post mate, thanks for sharing!

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Free, open and honest Angular education.

Read our welcome letter which is an open invitation for you to join.

More fromThis is Angular

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp