In this post, we will cover how to do Cypress Component testing with MSW (mock service worker) and why it’s beneficial to have a mock environment with MSW.
The mock environment
My recommendation for most enterprise projects is to have a mocking environment as it serves the following purposes :
* The front end can be developed independently based on a backend API contract.
* The mock environment can be reused for testing.
The frontend can be developed independently based on a backend API contract
With scrum, often you would develop a feature that includes backend and frontend tasks and the frontend would depend on the backend to work. This dependency from the frontend to the backend can block the frontend work and cause a bottleneck, especially in teams that have dedicated frontend and backend developers (not full-stack).
By having a mock environment, this bottleneck is fixed by the following workflow:
- Before you start working on a given feature you do a kickoff meeting agreeing on the backend API contract (API interface, return values, endpoint etc.)
- The frontend and backend can now start their work independently
- The frontend will use a tool like MSW to create mocked backend responses for running the app in a mock environment and hides this feature behind a feature flag
- The frontend will then, regardless of the backend work (because behind the feature flag) deploy the work to production
- When the backend is ready and deployed to production the testers can get exclusive access to the feature flag to test it before the rest of the users
- When the new feature is tested and accepted the feature flag can be turned on for everyone
This way not only can you test the new feature conditionally in production before your other users but the frontend and backend are not strickly depending on the deployment order and there is no blocked frontend work due to missing backend.
For more about my recommended work processes for efficient sprints in teams, read this post (also you get to see me when I shaved off all my hair during the COVID lockdown lol).
The mock environment can be reused for testing.
The other benefit is you can repurpose your mock environment for testing. Especially when doing component testing as these components would naturally use the whole frontend part and only mock the backend out.
My recommended testing strategy is the high ROI testing strategy (based on Kent C. Dodds testing trophy in the react world) as this maximizes the ROI of your testing by getting the perfect mix between confidence and testing effort by focusing on component testing.
Basically the testing strategy goes as follows:
- Write a few end-to-end tests for testing the app
- Cover all your use cases with component tests
- Cover edge cases and computational logic with unit tests
- Use eslint and typescript to have automatic rules and type-safety for everything
This gives the infamous testing trophy given the proportional invested testing effort:
So given this heavy focus on component testing it is helpful if you can reuse your mock environment for testing and as as soon as you mount the component you already have test mocks in place making it very easy to write the tests.
For most of your “sunshine” test cases, it would then simply be adequate to reuse the mock environment in your tests and override them if needed.
So let’s see how we can write component tests for a todo app.
Component testing a todo app
To show you all this in practice, I will share with you a todo app, how first the mock environment is set up with MSW, and how it is repurposed for component tests.
The todo app looks like this:
Setting up the MSW mocks
First, we will install MSW:
yarn add -D msw
We need to init MSW as:
npx msw init apps/todo-app/src
This will generate the service worker file mockServiceWorker.js
.
We load this mock service worker file in our app build script.
I like to use NX as the build system for almost all my Angular projects (even for non-monorepo projects) so we are also using NX here.
In the project.json
we add the mockServiceWorker.js
so it will be loaded on init:
"assets": [ "apps/todo-app/src/favicon.ico", "apps/todo-app/src/assets", "apps/todo-app/src/mockServiceWorker.js" ],
Now let’s create some mock handlers.
Creating the mock handlers
For the todo app, we create the following mock responses in a todo-handlers.ts
file. Also, we keep all our MSW mock-specific stuff in a dedicated library, for this project this is all located in a library at libs/todo-app/domain/mock
. Learn more about my architectural approach with Nx here.
First, we create the mock for the get todo items endpoint:
import { http, HttpResponse } from 'msw'; export const MOCK_TODO_ITEMS = [ { id: 'c6893de8-5ea7-4bd5-ab34-b935990abc9e', title: 'Steel Ergonomic', description: 'Engineer Group instruction set recontextualize Steel', dueDate: new Date().toLocaleDateString('en-us'), }, { id: 'dd0b94c6-3efd-4a98-b16a-5f396cb842b9', title: 'programming Tasty', description: 'interface Intelligent Concrete Soap Stravenue Refined Frozen Towels Heard Island and McDonald Islands', dueDate: new Date().toLocaleDateString('en-us'), }, { id: '21a59b9e-a9b2-4438-b86b-d2465e717c1d', title: 'redefine magenta', description: 'Representative Avon indigo local online', dueDate: new Date().toLocaleDateString('en-us'), }, { id: 'fd6fbccb-f405-40f5-b83d-3e6a6d0cb090', title: 'Fresh Handcrafted Steel Pants', description: 'enterprise North Korean Won mint green Oklahoma eco-centric', dueDate: new Date().toLocaleDateString('en-us'), }, { id: '999230a7-4594-4ace-810d-39f72823f3ae', title: 'Fantastic Metal Pants solid state', description: 'Handmade Concrete Sleek Personal Loan Account sticky', dueDate: new Date().toLocaleDateString('en-us'), }, ] as TodoItem[]; export const GET_TODOLIST_REGEX = '**api/todoList'; export const todoHandlers = [ http.get(GET_TODOLIST_REGEX, () => { return HttpResponse.json([ { result: { data: MOCK_TODO_ITEMS, }, }, ]); }), // ...
We use the http.get
to have the mock service worker intercept a given request URL regex and return the given HttpResponse.json
data. Note, for this project I am using tRPC (from the SPARTAN stack) so I have to envelope the response with [{result: {}}]
.
The rest of the mocks for the updating operations are even simpler and the full file for the mock handlers looks like this:
import { TodoItem } from '@todo/shared/todo-interfaces'; import { http, HttpResponse } from 'msw'; export const MOCK_TODO_ITEMS = [ { id: 'c6893de8-5ea7-4bd5-ab34-b935990abc9e', title: 'Steel Ergonomic', description: 'Engineer Group instruction set recontextualize Steel', dueDate: new Date().toLocaleDateString('en-us'), }, { id: 'dd0b94c6-3efd-4a98-b16a-5f396cb842b9', title: 'programming Tasty', description: 'interface Intelligent Concrete Soap Stravenue Refined Frozen Towels Heard Island and McDonald Islands', dueDate: new Date().toLocaleDateString('en-us'), }, { id: '21a59b9e-a9b2-4438-b86b-d2465e717c1d', title: 'redefine magenta', description: 'Representative Avon indigo local online', dueDate: new Date().toLocaleDateString('en-us'), }, { id: 'fd6fbccb-f405-40f5-b83d-3e6a6d0cb090', title: 'Fresh Handcrafted Steel Pants', description: 'enterprise North Korean Won mint green Oklahoma eco-centric', dueDate: new Date().toLocaleDateString('en-us'), }, { id: '999230a7-4594-4ace-810d-39f72823f3ae', title: 'Fantastic Metal Pants solid state', description: 'Handmade Concrete Sleek Personal Loan Account sticky', dueDate: new Date().toLocaleDateString('en-us'), }, ] as TodoItem[]; export const GET_TODOLIST_REGEX = '**api/todoList'; export const todoHandlers = [ http.get(GET_TODOLIST_REGEX, () => { return HttpResponse.json([ { result: { data: MOCK_TODO_ITEMS, }, }, ]); }), http.post('**api/createTodoItem', async req => { const newTodoItem = await req.request.json(); return HttpResponse.json([ { result: { data: { ...(newTodoItem[0] as TodoItem), // random id id: Math.random().toString(36).substring(7), }, }, }, ]); }), http.post('**api/updateTodoItem', async req => { const newTodoItem = await req.request.json(); return HttpResponse.json([ { result: { data: { ...(newTodoItem[0] as TodoItem), }, }, }, ]); }), http.delete('**api/deleteTodoItem/**', async req => { const { id } = req.params; return HttpResponse.json([ { result: { data: { id, }, }, }, ]); }), ];
I recommend you create a handler file for each domain of the app and expose all the handlers in a handlers.ts
file like this:
import { todoHandlers } from './handlers/todo-handlers'; import { generalHandlers } from './handlers/general-handlers'; export const handlers = [...generalHandlers, ...todoHandlers];
Finally, we expose this whole mock setup to the app with a browser.ts
file. MSW supports a browser and node setup and we will need to run this in a browser so we have the following browser.ts
file:
import { setupWorker } from 'msw/browser'; import { handlers } from './handlers'; export const worker = setupWorker(...handlers);
Setting up the mock environment
In the project.json
we will create a mock environment replacement like this:
"mock": { "fileReplacements": [ { "replace": "libs/todo-app/domain/src/environments/environment.ts", "with": "libs/todo-app/domain/src/environments/environment.mock.ts" } ] }
Which will make us run the app with:
nx serve todo-app -c mock
And have us use the following environment file:
export const environment = { production: false, mock: true, };
Then in our main.ts
we will check if we are in mock and then load the MSW service worker:
import { worker } from '@todo/todo-app/domain/mocks'; async function enableMocking() { if (!environment.mock) { return; } // `worker.start()` returns a Promise that resolves // once the Service Worker is up and ready to intercept requests. return worker.start(); } enableMocking().then(() => { bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err), ); });
We load the MSW service worker first and then run the app.
Writing the tests
For writing the Cypress component tests we have a todo-list.component.cy.ts
next to the todo-list.component.ts
file.
The test setup
For the test setup, I am a fan of the SIFERS (setup function called first in each test spec) as the SIFERS setup function serves to simplify the test setup before each test case and it overcomes the traditional problems with beforeEach
test callback function (eg. needing to provide a config value to a test setup before the component under test is initialized). See more about my high ROI testing strategy here.
import { GET_TODOLIST_REGEX, MOCK_TODO_ITEMS, worker, } from '@todo/todo-app/domain/mocks'; const setup = ( { todoItems }: { todoItems: TodoItem[] } = { todoItems: null }, ) => { return cy .wrap( worker.start({ serviceWorker: { url: `/mockServiceWorker.js` }, }), ) .then(() => { if (todoItems) { return cy.wrap( worker.use( http.get(GET_TODOLIST_REGEX, () => { return HttpResponse.json([ { result: { data: todoItems, }, }, ]); }), ), ); } return null; }) .then(() => { mount(WrapperComponent, { imports: [], providers: [...appConfig.providers], }).then( async ({ fixture: { debugElement: { injector }, }, }) => { const ngZone = injector.get(NgZone); const router = injector.get(Router); await ngZone.run(() => router.navigate([''])); return { ngZone, router, injector, }; }, ); }); };
The idea is the setup function takes in the configuration input needed for the test, handles all the complexity for the test setup without bloating the test cases, and returns the dependencies needed for the test cases (eg. the injector). In this case, the inputs are the todo items to load from MSW overriding the default mock values from the mock environment and it is setting up the app by setting the app’s environment providers (includes core services and routing setup) and mounts a wrapper component for routing.
Also, for allowing the test cases to override the default MSW mock values, we are first starting the MSW mock interception with calling worker.start
and then we call worker.use
to override mock values.
Making MSW work with Cypress component testing in Angular apps
Note: for a long time Cypress component testing with Angular and MSW didn’t work together due to an infinite loop when loading the MSW. This is fixed in this app by:
Adding devServerPublicPathRoute: ''
to the component property in the cypress.config.ts
:
import { defineConfig } from 'cypress'; import { nxComponentTestingPreset } from '@nx/angular/plugins/component-testing'; export default defineConfig({ component: { ...nxComponentTestingPreset(__filename), devServerPublicPathRoute: '', }, includeShadowDom: true, });
and by loading the mock service worker in the test with:
worker.start({ serviceWorker: { url: `/mockServiceWorker.js` }, }),
Thanks to this Github issue and the latest introduction of the devServerPublicPathRoute
property this issue is now fixed.
Wrapper component
The wrapper component looks like this:
@Component({ selector: 'app-wrapper-component', template: '<router-outlet></router-outlet>', standalone: true, imports: [RouterModule], }) class WrapperComponent { constructor(translateService: TranslateService) { translateService.addLangs(['en']); translateService.setDefaultLang('en'); } }
Normally this wrapper component will just contain the router setup but can also contain some init logic such as setting the translation language.
Now the test setup is in place, it’s easy to write the tests for the todo app use cases!
Get todo items test
For this test case, we are just reusing and referencing the mock environment values and assert that a todo item is present in the DOM:
it('should show todo item', () => { const todoItem = MOCK_TODO_ITEMS[0]; setup().then(({}) => { cy.get('[data-test=todo-item]').contains(todoItem.title); cy.get('[data-test=todo-item]').contains(todoItem.description); const formattedDueDate = formatDate( todoItem.dueDate, 'shortDate', 'en-US', ); cy.get('[data-test=todo-item]').contains(formattedDueDate); }); });
Create todo items test
For creating todo items we init test with an empty todo list and add a todo item (and assert it has been added):
it('should create todo item', () => { setup({todoItems: []}).then(({}) => { const title = 'Some title'; cy.get('[data-test=todo-title]').type(title); const description = 'Some description'; cy.get('[data-test=todo-description]').type(description); const dueDate = new Date().toLocaleDateString('en-US'); cy.get('[data-test=todo-duedate]').type(dueDate); cy.get('[data-test=create-todo-submit]').click(); cy.get('[data-test=todo-item]').contains(title); cy.get('[data-test=todo-item]').contains(description); const formattedDueDate = formatDate(dueDate, 'shortDate', 'en-US'); cy.get('[data-test=todo-item]').contains(formattedDueDate); }); });
Update todo items test
For the update test case, we init it with only one of the todo items from the mock environment for simplification, assert it is there, click edit, edit in the form, and assert it has been updated:
it('should update todo item', () => { const todoItem = MOCK_TODO_ITEMS[0]; setup({ todoItems: [todoItem] }).then(({}) => { cy.get('[data-test=todo-item]').contains(todoItem.title); cy.get('[data-test=todo-item]').contains(todoItem.description); const formattedDueDate = formatDate( todoItem.dueDate, 'shortDate', 'en-US', ); cy.get('[data-test=todo-item]').contains(formattedDueDate); cy.get('[data-test=todo-item]') .get('[data-test="edit-button"]') .click(); const updatedTitle = 'Edited title'; cy.get('[data-test=todo-title]').clear().type(updatedTitle); const updatedDescription = 'Edited description'; cy.get('[data-test=todo-description]').clear().type(updatedDescription); const currentDate = new Date(); const updatedDueDate = new Date( currentDate.setDate(currentDate.getDate() + 1), ).toLocaleDateString('en-US'); cy.get('[data-test=todo-duedate]').clear().type(updatedDueDate); cy.get('[data-test=create-todo-submit]').click(); cy.get('[data-test=todo-item]').contains(updatedTitle); cy.get('[data-test=todo-item]').contains(updatedDescription); const updatedFormattedDueDate = formatDate( updatedDueDate, 'shortDate', 'en-US', ); cy.get('[data-test=todo-item]').contains(updatedFormattedDueDate); }); });
Delete todo items test
Finally, for delete, we init with one todo item from the mock environment and assert that it has been deleted after clicking delete.
it('should delete todo item', () => { const todoItem = MOCK_TODO_ITEMS[0]; setup({ todoItems: [todoItem] }).then(({}) => { cy.get('[data-test=todo-item]').contains(todoItem.title); cy.get('[data-test=todo-item]').contains(todoItem.description); cy.get('[data-test=todo-item]') .get('[data-test="delete-button"]') .click(); cy.get('[data-test=todo-item]').should('not.exist'); }); });
Conclusion
In this post, we saw how to create a mock environment with MSW that can be used to simplify testing with Cypress component tests. It was very easy to create the tests after we already had mocks we could use.
If you want to learn more about my recommended approach to testing, check out this post.
Also, if you want my best help, I cover topics like this much more in-depth in my course Angular Architect Accelerator and I can give you interactive feedback on your Angular projects over this 8-week training to accelerate your Angular career. You can reserve a seat for the free warmup workshop for the next cohort here.
Do you want to become an Angular architect? Check out Angular Architect Accelerator.