High ROI Testing with Cypress Component Testing

Share on facebook
Share on google
Share on twitter
Share on linkedin

Testing is one of the most struggled topics in Angular development and many developers are either giving up testing altogether or applying inefficient testing practices consuming all their precious time while giving few results in return.

This blog post will change all this as we will cover how I overcame these struggles the hard way and conducted a set of practices for high ROI testing using Cypress component testing.

We will cover:

  1. My testing story
  2. Cypress vs Jest testing
  3. The testing pyramid vs the testing trophy
  4. The High ROI Testing Strategy
  5. Getting started with Cypress component testing
  6. The best way to write component tests
  7. Writing tests for a todo app

So let’s get to it!

My Testing Story

The background for this talk is from experiencing a lot of the typical pains with testing Angular apps.

These problems arise from the traditional philosophy that frontend apps should be tested using the testing pyramid:

The testing pyramid preaches that the main effort on testing should be on the unit tests as they are the cheapest to create.

For a long time, I thought this was the best way to go about testing frontend apps but I learned it the hard way…

Lessons Learned From Writing 10.000 Unit Tests In A Project

While building a big platform for banking apps a team of 50+ developers managed to write 10.000 unit tests to cover everything 100%.

The unit tests provided very little confidence in use cases actually working thus still had to overly rely on manual testing. Furthermore, we would spend a lot of time writing tests that provided close to no value, eg. repeating implementation detail in tests.

We found errors are usually found in the integration points so we had to change the strategy…

Going Towards Integration Tests

Made famous in the React world, “Write tests, not too many, mostly integration” by Guillermo Rauch and later Kent C. Dodds’s blog post and talk about the topic seemed to solve our problem.

At that time, our best tool was Jest, and render the full route of a component with all dependencies. That was a big improvement over the current approach as we got higher confidence in our tests and got more coverage with fewer tests but it still had some drawbacks such as fragility with async timing (had to run in a async context and use tick) and it not running in a real dom (JSDom).

Now we have Cypress component tests that overcome these problems.

Cypress Component Tests to the Rescue

Cypress component testing was a game-changer here as it allowed us to run a complete component (with all its declarations) in a real browser while providing all the Cypress features (retry commands and assertions for stable tests and the Cypress dashboard).

When running these component tests it’s always easy to see in the dashboard (or recorded video/screenshots) what went wrong if a test is failing. Also, it runs the tests in the same browser context as the app, so you can see console errors and debug the tests in the browser’s console.

The High ROI Testing Strategy

The high ROI testing strategy is based on Kent C. Dodds testing trophy:

This approach states:

Write a few E2E smoke tests (Cypress)
Cover use cases with integration tests (Cypress component testing)
Cover edge cases and calculations with unit tests (Jest)
Static: Type everything and use strict mode (Eslint and Typescript)

Covering the use cases of your app with integration tests (using Cypress component testing) is the main focus of this testing strategy as we are getting the perfect balance between effort and confidence. We still apply the other testing strategies when it is appropriate such as smoke tests using E2E and unit tests around calculations.

Using this approach we are maximizing the ROI from testing and using the different testing techniques in the right amount.

Getting started with Cypress component testing

To get started with Cypress component testing in an Angular app we can either do it with plain Cypress or use Nx’s Cypress runner.

I recommend using Nx even if you don’t have a monorepo:

  • Schematics for setting up component testing for a project
  • Provides various runners eg. for Cypress and Angular
  • Support for monorepos

If we are using Nx we can simply set up component testing with:

nx generate @nrwl/angular:cypress-component-configuration --project=todo-app

Running this command would generate a cypress.config.ts as follows:

import { defineConfig } from 'cypress';
import { nxComponentTestingPreset } from '@nrwl/angular/plugins/component-testing';

export default defineConfig({
   component: nxComponentTestingPreset(__filename)
});

And a component-test runner in the Nx project.json file:

  "component-test": {
           "executor": "@nrwl/cypress:cypress",
           "options": {
               "cypressConfig": "apps/todo-app/cypress.config.ts",
               "testingType": "component",
               "skipServe": true,
               "devServerTarget": "todo-app:build"
           }
  }

With this setup, we can run the component tests with:

nx run web:component-test

And with the dashboard:

nx run web:component-test –watch

The best way to write component tests

To get the best results with Cypress component testing we should follow a set of sound best practices:

  • Run in the same browser as your users
  • Render component through route
  • Use SIFERS for a simple and independent test setup
  • Render component under test in a wrapper component
  • Use a dedicated test selector attribute, data-test
  • Use standalone components
  • Act and assert with the edges of the systems; minimal stubs
  • Focus on the use cases

Let’s dive through each of these.

Run in the same browser as your users

We want our tests to resemble the real user experience as much as possible for the highest confidence. Chronium-based browsers account for the waste majority of browsers and Cypress supports these browsers out of the box.

Running the tests in Chrome is a huge improvement over running them in JSDom as you would often have to mock browser APIs when using JSDom to get things working.

Render component through route

The focus of our component tests should be our smart components. Most often that is pages and modals.

When testing a page component we want it to be rendered like in the app so we want route guards and resolvers to be triggered in the test.

We will later see how we can do this in a test setup.

Use SIFERS for a simple and independent test setup

SIFERS is an acronym for Simple, Injectable, Functions, Explicitly, Returning, State.

It’s a setup function that:

  1. Takes in the necessary init parameters
  2. Does the mounting and setup of dependencies
  3. Returns variables needed for the test cases

Solves the common problem when using beforeEach of having to move all initialization logic to the specific test cases later because a test requires a certain init configuration.

An example of such a SIFERS setup function looks like this:

const setup = (initTodoItems: TodoItem[] = []) => {
		return mount(WrapperComponent, {
			imports: [
				RouterTestingModule.withRoutes([...appRoutes]),
				AppModule,
				TranslateModule.forRoot({
					loader: {
						provide: TranslateLoader,
						useClass: CustomLoader,
					},
				}),
			],
		}).then(
			async ({
				fixture: {
					debugElement: { injector },
				},
			}) => {
				const ngZone = injector.get(NgZone);
				const router = injector.get(Router);
				const todoListResourceService = injector.get(TodoListResourcesService);

				// or mock service worker
				todoListResourceService.getTodos = () => {
					return of(initTodoItems);
				};

				await ngZone.run(() => router.navigate(['']));

				return {
					ngZone,
					router,
					injector,
				};
			},
		);
	};

We cover this more in-depth later in this post.

Render component under test in a wrapper component

My recommended approach to Cypress components tests is to load the component under tests through router-outlet, so resolvers and guards are triggered correctly like in your real app. Otherwise, important initialization logic is omitted from the test scope.

Also, all initialization logic should be done in a wrapper component, that wraps the router-outlet using ng-content:

@Component({
	selector: 'app-wrapper-component',
	template: '<router-outlet></router-outlet>',
})
class WrapperComponent {
	constructor(translateService: TranslateService) {
			(window as any).config = config;

		translateService.addLangs(['en']);
		translateService.setDefaultLang('en');
	}
}

In this wrapper component, we also do important initialization logic, such as setting up translations, that should be done before the component under test is rendered.

Use a dedicated test selector attribute, data-test

Using a dedicated test selector attribute such as data-test is one of Cypress’s own best practices as selectors using id or class can change for styling reasons thus breaking the tests. I prefer to go with data-test it as it is a framework-agnostic way of providing a test-specific selector attribute.
It also makes it clear the element is used in a test.

Use standalone components

Since Angular 14 components can now import all needed dependencies for rendering.

@Component({
	selector: 'app-todo-list',
	templateUrl: './todo-list.component.html',
	standalone: true,
	imports: [SharedModule, DuedateTodayCountPipe],
})
export class TodoListComponent {
}

This simplifies the test setup as you only need to import one component for your declarations.

Act and assert with the edges of the systems; minimal stubs

To get the highest confidence possible from our test we want to interact with the system like our real users would. That means interacting and asserting with the DOM.

By interacting with the edges of the system we black box test and make sure to exercise as much code as possible.

Sometimes though, assertions would be on the network layer as there might not be any visual effect on the DOM to assert on.

Focus on the use cases

The goal of our components tests is to cover our use cases as that is what drives the business. This includes both happy and error paths.
When writing a test for a use case, multiple assertions are also fine as they might be relevant for verifying that the feature works.
Also, you want to describe the test in the domain language rather than in a white box way with technical jargon. Eg. it should create todo item vs. it should save todo item in the store.

Writing Tests for a Todo app

Let’s see how we can apply all these best practices to cover a todo app with Cypress component tests.

The setup

As previously discussed, we will use SIFERS to set up our test.

const setup = (initTodoItems: TodoItem[] = []) => {
       return mount(WrapperComponent, {
           imports: [
               RouterTestingModule.withRoutes([...appRoutes]),
               AppModule,
               TranslateModule.forRoot({
                   loader: {
                       provide: TranslateLoader,
                       useClass: CustomLoader,
                   },
               }),
           ],
       }).then(
           async ({
               fixture: {
                   debugElement: { injector },
               },
           }) => {
               const ngZone = injector.get(NgZone);
               const router = injector.get(Router);
               const todoListResourceService = injector.get(TodoListResourcesService);

               // or mock service worker
               todoListResourceService.getTodos = () => {
                   return of(initTodoItems);
               };

               await ngZone.run(() => router.navigate(['']));

               return {
                   ngZone,
                   router,
                   injector,
               };
           },
       );
   };

We see that the SIFERS setup functions take the input needed for the different tests, which is todo items here.

We import RouterTestingModule and AppModule to set up the test for routing and providing app core services.

We set mock responses for the todoListResourceService. Note, that ideally this is done with MSW (mock service worker) but currently MSW is not working with Angular and component tests, see this issue. As soon as we have found a solution to this, having a mock environment with MSW will be my recommended approach to not only testing but also development independent from the backend environment.

We trigger the initial navigation to render the component through the routing. Not that we are mounting a wrapper component that contains a router-outlet:

@Component({
	selector: 'app-wrapper-component',
	template: '<router-outlet></router-outlet>',
})
class WrapperComponent {
	constructor(translateService: TranslateService) {
		(window as any).config = config;
		translateService.addLangs(['en']);
		translateService.setDefaultLang('en');
	}
}


Lastly, the SIFERS function returns the dependencies needed for the test cases.

Create todo item

Let’s write the test for creating todo items. Here we are simply typing in the form, submitting, and asserting (using data-test attributes) that the todo item has been created:

   it('should create todo item', () => {
       setup().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]').shadow().contains(title);
           cy.get('[data-test=todo-item]').shadow().contains(description);
           const formattedDueDate = formatDate(dueDate, 'shortDate', 'en-US');
           cy.get('[data-test=todo-item]').shadow().contains(formattedDueDate);
       });
   });

Show todo item

Here we will set up the test with a to-do item and assert that this is showing in the dom.

  it('should show todo item', () => {
       const title = 'Item to show';
       const description = 'This item should be shown';
       const dueDate = new Date().toLocaleDateString('en-US');
       setup([
           {
               id: '1',
               title,
               description,
               dueDate,
           } as TodoItem,
       ]).then(({}) => {
           cy.get('[data-test=todo-item]').shadow().contains(title);
           cy.get('[data-test=todo-item]').shadow().contains(description);
           const formattedDueDate = formatDate(dueDate, 'shortDate', 'en-US');
           cy.get('[data-test=todo-item]').shadow().contains(formattedDueDate);
       });
   });

Update todo item

Here we set up the test with a todo item, select edit for that todo item, submit, and assert it has been edited.

it('should update todo item', () => {
       const title = 'Item to edited';
       const description = 'This item should be edited';
       const dueDate = new Date().toLocaleDateString('en-US');
       setup([
           {
               id: '1',
               title,
               description,
               dueDate,
           } as TodoItem,
       ]).then(({}) => {
           
           cy.get('[data-test=todo-item]')
               .shadow()
               .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();

	 // Assert is updated
       });
   });

Delete todo item

For deleting we simply init with a todo item, delete that todo item by clicking the delete button and assert no todo items exist.

   it('should delete todo item', () => {
       const title = 'Item to delete';
       const description = 'This item should be deleted';
       setup([
           {
               title,
               description,
           } as TodoItem,
       ]).then(({}) => {
           cy.get('[data-test=todo-item]').shadow().contains(title);
           cy.get('[data-test=todo-item]').shadow().contains(description);

           cy.get('[data-test=todo-item]')
               .shadow()
               .get('[data-test="delete-button"]')
               .click();

           cy.get('[data-test=todo-item]').should('not.exist');
       });
   });

Running the tests

Finally, let’s run the tests and see that they are all competing in no time in the Cypress dashboard:

More resources

I have spoken about this topic on a few other occasions.

You can listen to my podcast with Lars Byrup Brink Nielsen where we talk about this topic.

Where to learn more?

I have done several workshops and trainings on this topic and you will get all the support you need for improving your Angular testing skills and a lot more by joining the next cohort of Angular Architect Accelerator.
You can join the free warmup workshop before the next cohort to see if this is for you here.

Conclusion

Cypress component testing is a game changer in frontend testing and I find it 10x better for testing components than Jest in terms of developer experience and confidence.

If you combine this with the “testing trophy” approach to testing you will find the time you spend on writing tests vs. the confidence (ROI) you will receive will drastically increase and your life as an Angular developer will overall be more joyful.

Do you want to become an Angular architect? Check out Angular Architect Accelerator.

Related Posts and Comments

How to Set up a CI pipeline with Azure Pipelines and Nx

It goes without saying that having a CI pipeline for your Angular apps is a must. Setting one up for regular Angular apps is fairly straightforward but when you have an Nx monorepo there are certain other challenges that you have to overcome to successfully orchestrate a “build once, deploy many” pipeline. This post will

Read More »

How to Set Up Git Hooks in an Nx Repo

Git hooks can be used to automate tasks in your development workflow. The earlier a bug is discovered, the cheaper it is to fix (and the less impact it has). Therefore it can be helpful to run tasks such as linting, formatting, and tests when you are e.g. committing and pushing your code, so any

Read More »

The Stages of an Angular Architecture with Nx

Long gone are the times when the frontend was just a dumb static website. Frontend apps have gotten increasingly complex since the rise of single-page application frameworks like Angular. It comes with the price of increased complexity and the ever-changing frontend landscape requires you to have an architecture that allows you to scale and adapt

Read More »

The Best Way to Use Signals in Angular Apps

Since Angular 16, Angular now has experimental support for signals and there is a lot of confusion in the community about whether this is going to replace RxJS or how it should be used in an app in combination with RxJS. This blog post sheds some light on what I think is the best way

Read More »

Supabase and Angular: A Powerful Combination for Building Web Applications

Supabase is a cloud-based backend as a service (BaaS) platform that provides developers with a set of tools and services for building scalable and secure web applications.It’s much like Firebase but Supabase provides a PostgreSQL database which solves some of the inconveniences with a NoSQL database such as Firestore.For that reason, Supabase has now become

Read More »