Testing NgRx Store with Cypress Component Testing

Jordan Powell - Jan 5 - - Dev Community

What is NgRx

NgRx has been one of my most favorite open-source projects I've worked on in my career. It provides several mechanisms for managing reactivity in Angular applications. Most commonly in it's @ngrx/store package in combination with @ngrx/effects. To understand what it does at a high level I like to refer to the diagram below which I created several years back.

NgRx Diagram

This article will not go into the weeds of NgRx but will focus on integrating it with Cypress Component Testing. However, there are endless resources online for learning how to get started with NgRx. I recommend visiting their Getting Started Docs if you want to learn more or are looking for a quick refresher.

Cypress Component Testing

For those of you not familiar with Cypress Component Testing, it provides a component workbench for you to quickly build and test components from multiple front-end UI libraries — no matter how simple or complex.

"I like to say it's like Jest or Karma had a baby with Storybook!"

What I love about it personally is how simple it is to create really meaningful tests! If you are new to Component Testing I recommend watching my video below showing how to get starting with Component Testing in Angular or you can visit the Angular Component Testing Cypress Docs.

Creating Our Store

Let's use a simple example of a counter that both increments and decrements a count and then displays the total count as a number in a StepperComponent. To get started we always want to start with our actions!



// src/app/store/count.actions.ts

import { createAction } from '@ngrx/store' 

export const incrementCount = createAction('[COUNT] increment count')
export const decrementCount = createAction('[COUNT] decrement count')
export const clearCount = createAction('[COUNT] clear count')


Enter fullscreen mode Exit fullscreen mode

Now let's create a reducer to handle our actions:



// src/app/store/count.reducer.ts

import { createFeature, createReducer, on } from '@ngrx/store' 
import { incrementCount, decrementCount, clearCount } from './count.actions'

interface State {
  count: number
}

const initialState: State = {
  count: 0,
}

const countFeature = createFeature({
  name: 'count',
  reducer: createReducer(
    initialState,
    on(incrementCount, ({ count }) => ({
      count: count + 1
    })),
    on(decrementCount, ({ count }) => ({
      count: count - 1
    })),
    on(clearCount, () => ({
      count: 0
    }))
  )
})

export const { name: reducer, selectCountState, selectCount } = countFeature


Enter fullscreen mode Exit fullscreen mode

Now let's create a StepperComponent that will allow users to increment, decrement and clear the count by integrating it with our store.



// src/app/stepper/stepper.component.ts

import { Component, inject } from '@angular/core' 
import { Store } from '@ngrx/store' 
import { selectCount } from '../store/count.reducer' 
import { incrementCount, decrementCount, clearCount } from '../store/count.actions

@Component({
  selector: 'app-stepper',
  template: `
    <button (click)="decrement()">-</button>
    <span>{{ count$ | async }}</span>
    <button (click)="increment()">+</button>
    <br /><br />
    <button (click)="clear()">Clear</button>
  `,
  styleUrls: ['./stepper.component.css'],
})
export class StepperComponent {
  private readonly store = inject(Store)

  count$ = this.store.select(selectCount)

  increment() {
    this.store.dispatch(incrementCount())
  }

  decrement() {
    this.store.dispatch(decrementCount())
  }

  clear() {
    this.store.dispatch(clearCount())
  }
}


Enter fullscreen mode Exit fullscreen mode

Now let's import our StoreModule into our App.Module:



// src/app/app.module.ts

import { StoreModule } from '@ngrx/store' 
import { reducer } from './store/count.reducer'
...

@NgModule({ 
  ...
  imports: [
    ...,
    StoreModule.forRoot({
      count: reducer
    })
  ],
})
export class AppModule {}


Enter fullscreen mode Exit fullscreen mode

Note
You can apply the same concepts for Standalone Components as well https://ngrx.io/guide/store/reducers#standalone-api-in-module-based-apps

Writing our First Test

Now that we have our Store connected to our new Stepper Component, lets install cypress (if you haven't already done so) by running the following bash command:



npm install cypress@latest


Enter fullscreen mode Exit fullscreen mode

Now let's write our first test by creating a new file next to our StepperComponent named stepper.component.cy.ts



// src/app/stepper/stepper.component.cy.ts

import { StepperComponent } from './stepper.component'

describe('StepperComponent', () => {
  it('can mount', () => {
    cy.mount(StepperComponent)
  })
})


Enter fullscreen mode Exit fullscreen mode

Now let's launch Cypress and click on our new spec file:



npx cypress open --component


Enter fullscreen mode Exit fullscreen mode

Though our test was super easy to write we unfortunately get the following error:

Failure State without Providing Store

This is because we haven't configured our test with our Store. Don't worry this is actually super easy to do! Because we configured our AppModule with our store we can either do one of two things:

  1. We can copy the Store configuration from our AppModule into our mount
  2. We can just import AppModule into our mount

Let's do the later as it requires less code:



// src/app/stepper/stepper.component.cy.ts

import { StepperComponent } from './stepper.component'
import { AppModule } from '../app.module'

describe('StepperComponent', () => {
  it('can mount', () => {
    cy.mount(StepperComponent, {
      imports: [AppModule]
    })
  })
})


Enter fullscreen mode Exit fullscreen mode

And just like that our StepperComponent connected to our store is mounting successfully in our Cypress Component Test!

Passing First Test

Because we will most likely need our Store in every component we mount I recommend doing the following customization to your mount command:



// cypress/support/component.ts

import { mount } from 'cypress/angular'
import { AppModule } from 'src/app/app.module'

declare global {
  namespace Cypress {
    interface Chainable {
      mount: typeof mount
    }
  }
}

// This is necessary as mount takes a generic for it's first two arguments
type MountParams = Parameters<typeof mount>

Cypress.Commands.add(
  'mount',
  (component: MountParams[0], config: MountParams[1] = {}) => {
    return mount(component, {
      ...config,
      imports: [
        ...(config.imports || []),
        AppModule
      ]
    }
  }
)


Enter fullscreen mode Exit fullscreen mode

Now every time we call cy.mount we are automatically importing AppModule. Now we can change our test back to how it was originally and we should get a successfully mounted Stepper Component.



describe('StepperComponent', () => {
  it('can mount', () => {
    cy.mount(StepperComponent)
  })
})


Enter fullscreen mode Exit fullscreen mode

Beyond The Basics

Selectors
Though we have our component mounted we aren't running any assertions against it. Let's add a test validating that the count$ observable which is using our selectCount selector is working correctly by asserting the initial value of count is zero.



  it('has a correct default count of 0', () => {
    cy.mount(StepperComponent);
    cy.get('span').should('have.text', '0');
  });


Enter fullscreen mode Exit fullscreen mode

Now let's test incrementing the count:



  it('can increment the count', () => {
    cy.mount(StepperComponent);
    cy.get('button').contains('+').click();
    cy.get('span').should('have.text', '1');
  });


Enter fullscreen mode Exit fullscreen mode

And then decrementing the count:



  it('can decrement the count', () => {
    cy.mount(StepperComponent);
    cy.get('button').contains('-').click();
    cy.get('span').should('have.text', '-1');
  });


Enter fullscreen mode Exit fullscreen mode

Finally let's test clearing the count:



  it('can clear the count', () => {
    cy.mount(StepperComponent);
    cy.get('button').contains('+').click().click();
    cy.get('span').should('have.text', '2');
    cy.get('button').contains('Clear').click();
    cy.get('span').should('have.text', '0');
  });


Enter fullscreen mode Exit fullscreen mode

Now we should have 5 passing tests that are validating the various ways a user might interact with our Stepper Component.
Basic Passing Tests

Actions
Though we know our component appears to be working as expected, in less trivial use-cases we will want to test our component's integration with our store more thoroughly. Let's dive deeper into dispatched actions.

There are several ways of approaching writing tests for dispatching actions. Let me first start off with the less ideal implementation:

If we look at our Component we see that we are using Angular's click() event binding to call a public method in our component's class that eventually calls our store's dispatch method.

For example this button when clicked calls the increment() method in our component class:



<button (click)="increment()">+</button>


Enter fullscreen mode Exit fullscreen mode

Then our component class increment method dispatches an incrementCount() action to our store:



private readonly store = inject(Store);

increment() {
  this.store.dispatch(incrementCount());
}


Enter fullscreen mode Exit fullscreen mode

Because the increment method is a public property in our class we can easily write a test that asserts that it is called when the increment button is clicked:



  it('can spy on increment invocation', () => {
    cy.mount(StepperComponent).then(({ component }) => {
      cy.spy(component, 'increment').as('increment');
    });
    cy.get('button').contains('+').click();
    cy.get('@increment').should('have.been.calledOnce');
    cy.get('span').should('have.text', 1);
  });


Enter fullscreen mode Exit fullscreen mode

We can then do the same thing for decrement, clear, etc.

Though this does validate that our button click is binding correctly to our methods it doesn't actually do any validating that a specific action is being dispatched. Let's try to take this same approach but apply it to our store's dispatch method.



  it('can spy on store.dispatch', () => {
    cy.mount(StepperComponent).then(({ component }) => {
      cy.spy(component.store, 'dispatch').as('dispatchSpy');
    });
    cy.get('button').contains('+').click();
    cy.get('@dispatchSpy').should('have.been.called');
  });


Enter fullscreen mode Exit fullscreen mode

If you haven't already identified the issue with this test you should see the following error because our store property is a private field in our class.

Store Private Property Error

Though we are getting this error, the test is actually correct and will pass when we run it. Let's manually swallow this TS error by adding a ts-expect-error comment



...
cy.mount(StepperComponent).then(({ component }) => {
  // @ts-expect-error
  cy.spy(component.store, 'dispatch').as('dispatchSpy');
});
...


Enter fullscreen mode Exit fullscreen mode

We are now in business and can write tests validating specific actions are dispatched to our NgRx store. But let's make this even easier by writing a few custom commands!

Custom NgRx Store Commands

cy.store()
We first need an easy way to access our store since it is private in our components. Let's create a new custom command:



// cypress/support/component.ts

import { Store } from '@ngrx/store' 

declare global {
  namespace Cypress {
    interface Chainable {
      mount: typeof mount;
      store(storePropertyName: string): Cypress.Chainable<Store>;
    }
  }
}

Cypress.Commands.add(
  'store',
  { prevSubject: true },
  (subject: MountResponse<MountParams>, storePropertyName: string) => {
    const { component } = subject;

    // @ts-expect-error
    const store = component[storePropertyName] as Store;

    return cy.wrap(store);
  }
);


Enter fullscreen mode Exit fullscreen mode

Now let's create a test using our new store command:



  it('can use cy.store()', () => {
    cy.mount(StepperComponent)
      .store('store')
      .then((store) => {
        cy.spy(store, 'dispatch').as('dispatchSpy');
      });

    cy.get('button').contains('+').click();
    cy.get('@dispatchSpy').should('have.been.called');
    cy.get('span').should('have.text', 1);
  });


Enter fullscreen mode Exit fullscreen mode

Now we can easily access our store by just chaining off our our cy.mount() command. But let's go one step further and make spying on our dispatch easier by creating a dispatch command:

cy.dispatch()



// cypress/support/component.ts

import { Store } from '@ngrx/store' 

declare global {
  namespace Cypress {
    interface Chainable {
      mount: typeof mount;
      store(storePropertyName: string): Cypress.Chainable<Store>;
      dispatch(): Cypress.Chainable;
    }
  }
}

Cypress.Commands.add('dispatch', { prevSubject: true }, (store: Store) => {
  return cy.wrap(cy.spy(store, 'dispatch').as('dispatch'));
});


Enter fullscreen mode Exit fullscreen mode

Now we can write a new test using both of our new commands:



  it('can use cy.dispatch()', () => {
    cy.mount(StepperComponent).store('store').dispatch();
    cy.get('button').contains('+').click();
    cy.get('@dispatch').should('have.been.called');
    cy.get('span').should('have.text', 1);
  });


Enter fullscreen mode Exit fullscreen mode

TADA! We now can easily write Cypress Component Tests for our Components that are tied to an NgRx Store!

Finished Tests

Conclusion:

Using Component Tests to write tests for your Angular Component's using NgRx Store is both super easy and creates high value tests. This article certainly doesn't cover everything that can be tested like this but hopefully paints a clear enough picture so you can see the power of using Cypress Component Testing with NgRx.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .