Testing NgRx Store with Cypress Component Testing
Jordan Powell
Posted on January 5, 2024
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.
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')
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
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())
}
}
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 {}
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
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)
})
})
Now let's launch Cypress and click on our new spec file:
npx cypress open --component
Though our test was super easy to write we unfortunately get the following error:
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:
- We can copy the Store configuration from our
AppModule
into ourmount
- We can just import
AppModule
into ourmount
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]
})
})
})
And just like that our StepperComponent
connected to our store is mounting successfully in our Cypress Component 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
]
}
}
)
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)
})
})
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');
});
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');
});
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');
});
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');
});
Now we should have 5 passing tests that are validating the various ways a user might interact with our Stepper Component.
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>
Then our component class increment method dispatches an incrementCount()
action to our store:
private readonly store = inject(Store);
increment() {
this.store.dispatch(incrementCount());
}
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);
});
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');
});
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.
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');
});
...
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);
}
);
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);
});
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'));
});
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);
});
TADA! We now can easily write Cypress Component Tests for our Components that are tied to an NgRx Store!
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.
Posted on January 5, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024