| |

Angular 105 🅰️ E2E Testing with Cypress

Cypress is the E2E testing framework that the Angular team officially recommends for end-to-end testing. It runs in a real browser, executes the test code in the same run loop as the application, and provides a test runner that shows exactly what happened at every step. The ng add @cypress/schematic command is the official Angular schematic that installs Cypress, configures the ng e2e builder, and scaffolds the test files in a single step .

The previous chapter covered Playwright. Cypress and Playwright solve the same problem — verifying the application from the user’s perspective — but they take different approaches. Playwright is a separate process that drives the browser through a protocol. Cypress runs inside the browser, next to the application. The difference affects how tests are written, how debugging works, and how the application is controlled during the test.

Key point: Cypress is built around the idea that tests should be written the way a user thinks. The cy.get() command finds an element. The .type() command types into it. The .click() command clicks it. The .should() assertion retries automatically until the condition is true or the timeout expires. The test reads like a sequence of user actions, and Cypress handles the timing, the waiting, and the retries. The ng e2e command runs the tests through the Angular CLI .


Why Cypress for Angular

Angular applications are single-page applications. The page does not reload on navigation. The DOM updates asynchronously. The test must wait for the application to stabilize before asserting. Cypress handles this with automatic waiting and retry-ability.

The official recommendation. The Angular CLI’s ng e2e command lists Cypress as one of the supported E2E frameworks. The @cypress/schematic package is maintained by the Cypress team and provides the Angular builder that integrates Cypress with the Angular CLI. The Angular documentation points to the schematic as the way to set up E2E testing .

The automatic waiting problem. A traditional E2E test fails intermittently because it asserts before the application has finished rendering. Cypress’s .should() command retries the assertion until it passes or the timeout expires. The cy.get() command waits for the element to exist before proceeding. The waiting is built into every command .

The debugging problem. Cypress runs the test in the browser, next to the application. The test runner shows the application state at every step. When a test fails, the developer can inspect the DOM, the network requests, and the console at the exact moment of failure. The debugging experience is different from a headless test runner that reports a failure with no context.

The Angular integration problem. The @cypress/schematic package adds the Cypress builder to the angular.json file. The developer runs ng e2e and the builder starts the Angular development server, launches Cypress, and runs the tests. The integration is automatic .

The trade-off. Cypress runs the test in the browser, which means the test cannot easily control the browser’s behavior outside the page. Playwright’s separate process model gives more control over the browser context, the network, and the operating system. Cypress compensates with a more integrated debugging experience and a test runner that shows exactly what happened. The choice depends on the team’s priorities.


a. Setting Up Cypress in an Angular Project

The @cypress/schematic package is the official Angular schematic for Cypress. It is maintained by the Cypress team and provides the builder, the configuration, and the scaffolding .

The installation is a single command:

ng add @cypress/schematic

The schematic performs several actions. It installs the cypress package as a development dependency. It adds npm scripts for running Cypress in open mode (cypress:open) and run mode (cypress:run). It scaffolds the cypress/ directory with the configuration, the support files, and an example spec. It prompts whether to add or update the ng e2e command to use Cypress. It prompts whether to add Cypress component testing .

The cypress.config.ts file is created with the default configuration:

import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:4200',
    specPattern: 'cypress/e2e/**/*.cy.ts',
    supportFile: 'cypress/support/e2e.ts',
    video: false,
    screenshotOnRunFailure: true,
  },
});

The baseUrl option tells Cypress where the application is running. The specPattern tells Cypress where to find the test files. The supportFile is loaded before every test and is where custom commands are registered. The video option controls whether Cypress records a video of the test run. The screenshotOnRunFailure option captures a screenshot when a test fails .

The angular.json file is updated with the Cypress builder:

{
  "projects": {
    "my-app": {
      "architect": {
        "e2e": {
          "builder": "@cypress/schematic:cypress",
          "options": {
            "devServerTarget": "my-app:serve",
            "watch": true,
            "headless": false
          },
          "configurations": {
            "production": {
              "devServerTarget": "my-app:serve:production"
            }
          }
        }
      }
    }
  }
}

The builder starts the development server before running the tests. The watch option keeps Cypress open and re-runs the tests on every change. The headless option controls whether the browser is visible .


b. Writing Cypress E2E Tests

A Cypress test is written in a describe block with it blocks. The cy object provides the commands. The commands are chained, and each command returns the subject for the next command.

describe('Login Page', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('displays the login form', () => {
    cy.get('input[type="email"]').should('be.visible');
    cy.get('input[type="password"]').should('be.visible');
    cy.get('button[type="submit"]').should('contain', 'Login');
  });

  it('logs in successfully', () => {
    cy.get('input[type="email"]').type('test@example.com');
    cy.get('input[type="password"]').type('password123');
    cy.get('button[type="submit"]').click();

    cy.url().should('include', '/dashboard');
    cy.get('.welcome-message').should('contain', 'Welcome back');
  });
});

The cy.visit('/login') command navigates to the login page. The cy.get() command finds the element. The .type() command types into it. The .click() command clicks it. The .should() assertion retries until the condition is true .

The cy.intercept() command intercepts network requests and provides mock responses. This is how the test controls the API responses without depending on a real backend.

it('navigates to the dashboard after login', () => {
  cy.intercept('POST', '/api/auth/login', {
    statusCode: 200,
    body: { token: 'fake-token', user: { name: 'Test User' } },
  }).as('loginRequest');

  cy.get('input[type="email"]').type('test@example.com');
  cy.get('input[type="password"]').type('password123');
  cy.get('button[type="submit"]').click();

  cy.wait('@loginRequest');
  cy.url().should('include', '/dashboard');
});

The cy.intercept() command registers the interceptor. The .as('loginRequest') assigns an alias. The cy.wait('@loginRequest') waits for the request to complete. The test then asserts the URL .

The data-cy attribute is the recommended way to select elements. The attribute is added to the template and used in the test. The attribute is stable and does not change when the CSS classes change.

<button data-cy="submit-button" class="btn btn-primary">Login</button>
cy.get('[data-cy="submit-button"]').click();

The data-cy attribute is preferred over CSS classes because the classes are for styling and may change. The data-cy attribute is for testing and is stable .


c. Cypress Component Testing

Cypress also supports component testing. The mount function from cypress/angular renders an Angular component in isolation. The test interacts with the component and asserts its behavior.

import { mount } from 'cypress/angular';
import { ButtonComponent } from './button.component';

describe('ButtonComponent', () => {
  it('renders the button with the label', () => {
    mount(ButtonComponent, {
      componentProperties: {
        label: 'Click me',
      },
    });
    cy.get('button').should('contain.text', 'Click me');
  });
});

The mount function renders the component. The componentProperties option sets the inputs. The test interacts with the rendered component .

For legacy @Input() decorators, the componentRef.setInput method updates the input and triggers change detection.

cy.mount(StepperComponent, { componentProperties: { count: 100 } })
  .then(({ fixture }) => {
    fixture.componentRef.setInput('count', 110);
    return cy.contains('span', '110');
  });

The componentRef.setInput method is required for legacy inputs. Without it, the change detection does not run, and the test sees the old value .

For signal inputs, the componentProperties option sets the signal value directly. Cypress 14 and later include signal support in the cypress/angular harness.

cy.mount(TestComponent, {
  componentProperties: {
    title: 'Test Component',
    count: 5,
  },
});
cy.get('[data-cy="test-component-title-display"]').should('have.text', 'Test Component');
cy.get('[data-cy="test-component-count-display"]').should('have.text', '5');

The signal inputs are set through componentProperties. The signal output is spied on with createOutputSpy() .

The createOutputSpy() function creates an EventEmitter and sets up a spy on the .emit() method. The spy is accessed with an alias.

import { createOutputSpy } from 'cypress/angular';

cy.mount('<app-stepper (change)="change.emit($event)"></app-stepper>', {
  declarations: [StepperComponent],
  componentProperties: {
    change: createOutputSpy<boolean>('changeSpy'),
  },
});
cy.get(incrementSelector).click();
cy.get('@changeSpy').should('have.been.called');

The createOutputSpy function simplifies the spying on outputs. The spy is accessed with the @changeSpy alias .


Complete Example Session

This session demonstrates Cypress E2E tests and component tests for an Angular application.

// ============================================
// PART 1: THE CYPRESS CONFIG
// ============================================

// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:4200',
    specPattern: 'cypress/e2e/**/*.cy.ts',
    supportFile: 'cypress/support/e2e.ts',
    video: false,
    screenshotOnRunFailure: true,
  },
  component: {
    devServer: {
      framework: 'angular',
      bundler: 'webpack',
    },
    specPattern: '**/*.cy.ts',
  },
});

The configuration defines both E2E and component testing. The baseUrl points to the Angular development server. The specPattern tells Cypress where to find the tests.

// ============================================
// PART 2: THE E2E TEST — LOGIN FLOW
// ============================================

// cypress/e2e/login.cy.ts
describe('Login Page', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('displays the login form', () => {
    cy.get('input[type="email"]').should('be.visible');
    cy.get('input[type="password"]').should('be.visible');
    cy.get('button[type="submit"]').should('contain', 'Login');
  });

  it('logs in successfully', () => {
    cy.intercept('POST', '/api/auth/login', {
      statusCode: 200,
      body: { token: 'fake-token', user: { name: 'Test User' } },
    }).as('loginRequest');

    cy.get('input[type="email"]').type('test@example.com');
    cy.get('input[type="password"]').type('password123');
    cy.get('button[type="submit"]').click();

    cy.wait('@loginRequest');
    cy.url().should('include', '/dashboard');
    cy.get('.welcome-message').should('contain', 'Welcome');
  });

  it('shows an error for invalid credentials', () => {
    cy.intercept('POST', '/api/auth/login', {
      statusCode: 401,
      body: { error: 'Invalid credentials' },
    }).as('loginRequest');

    cy.get('input[type="email"]').type('wrong@example.com');
    cy.get('input[type="password"]').type('wrongpassword');
    cy.get('button[type="submit"]').click();

    cy.wait('@loginRequest');
    cy.get('.error-message').should('contain', 'Invalid credentials');
    cy.url().should('include', '/login');
  });
});

The test uses cy.intercept to mock the API. The cy.wait command waits for the mocked request. The cy.url command asserts the URL.

// ============================================
// PART 3: THE E2E TEST — ROUTE GUARDS
// ============================================

// cypress/e2e/auth-guard.cy.ts
describe('Auth Guard', () => {
  it('redirects to login when not authenticated', () => {
    cy.visit('/dashboard');
    cy.url().should('include', '/login');
  });

  it('allows access when authenticated', () => {
    cy.intercept('POST', '/api/auth/login', {
      statusCode: 200,
      body: { token: 'fake-token' },
    }).as('login');

    cy.visit('/login');
    cy.get('input[type="email"]').type('test@example.com');
    cy.get('input[type="password"]').type('password123');
    cy.get('button[type="submit"]').click();
    cy.wait('@login');

    cy.visit('/dashboard');
    cy.url().should('include', '/dashboard');
  });
});

The test verifies that the route guard redirects unauthorized users and allows authenticated users.

// ============================================
// PART 4: THE E2E TEST — FORM VALIDATION
// ============================================

// cypress/e2e/forms.cy.ts
describe('Form Validation', () => {
  it('shows an error for an empty required field', () => {
    cy.visit('/contact');
    cy.get('button[type="submit"]').click();
    cy.get('.error-message').should('contain', 'Name is required');
  });

  it('shows an error for an invalid email', () => {
    cy.visit('/contact');
    cy.get('input[name="email"]').type('not-an-email');
    cy.get('button[type="submit"]').click();
    cy.get('.error-message').should('contain', 'Invalid email');
  });

  it('submits successfully with valid data', () => {
    cy.visit('/contact');
    cy.get('input[name="name"]').type('Alice');
    cy.get('input[name="email"]').type('alice@example.com');
    cy.get('button[type="submit"]').click();
    cy.get('.success-message').should('contain', 'Thank you');
  });
});

The tests verify the form validation behavior. The cy.get command finds the inputs. The .type() command types into them. The .click() command submits the form.

// ============================================
// PART 5: THE COMPONENT TEST
// ============================================

// src/app/button/button.component.cy.ts
import { mount } from 'cypress/angular';
import { ButtonComponent } from './button.component';

describe('ButtonComponent', () => {
  it('renders the button with the label', () => {
    mount(ButtonComponent, {
      componentProperties: {
        label: 'Click me',
      },
    });
    cy.get('button').should('contain.text', 'Click me');
  });

  it('emits an event when clicked', () => {
    mount(ButtonComponent, {
      componentProperties: {
        label: 'Click me',
        clicked: createOutputSpy('clickSpy'),
      },
    });
    cy.get('button').click();
    cy.get('@clickSpy').should('have.been.called');
  });
});

The mount function renders the component. The componentProperties option sets the inputs. The createOutputSpy function spies on the output.

// ============================================
// PART 6: THE CUSTOM COMMAND
// ============================================

// cypress/support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
  cy.intercept('POST', '/api/auth/login', {
    statusCode: 200,
    body: { token: 'fake-token' },
  }).as('login');

  cy.visit('/login');
  cy.get('input[type="email"]').type(email);
  cy.get('input[type="password"]').type(password);
  cy.get('button[type="submit"]').click();
  cy.wait('@login');
});

declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>;
    }
  }
}

The custom command encapsulates the login flow. The test uses cy.login('user@example.com', 'password') instead of repeating the steps.

// ============================================
// PART 7: USING THE CUSTOM COMMAND
// ============================================

// cypress/e2e/dashboard.cy.ts
describe('Dashboard', () => {
  beforeEach(() => {
    cy.login('user@example.com', 'password123');
  });

  it('displays the user projects', () => {
    cy.get('[data-cy="project-list"]').should('be.visible');
    cy.get('[data-cy="project-item"]').should('have.length.at.least', 1);
  });
});

The beforeEach block uses the custom command to log in. Each test starts with an authenticated session.

// ============================================
// PART 8: THE CI PIPELINE
// ============================================

// .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run cy:run
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: cypress-screenshots
          path: cypress/screenshots/

The CI pipeline runs the Cypress tests in headless mode. The screenshots are uploaded on failure.

// ============================================
// PART 9: THE NG E2E COMMAND
// ============================================

// angular.json
{
  "projects": {
    "my-app": {
      "architect": {
        "e2e": {
          "builder": "@cypress/schematic:cypress",
          "options": {
            "devServerTarget": "my-app:serve",
            "watch": true,
            "headless": false
          },
          "configurations": {
            "production": {
              "devServerTarget": "my-app:serve:production"
            }
          }
        }
      }
    }
  }
}

The ng e2e command runs the Cypress builder. The builder starts the development server and launches Cypress.

// ============================================
// PART 10: THE E2E CHECKLIST
// ============================================

// 1. Install Cypress with ng add @cypress/schematic
// 2. Configure cypress.config.ts with baseUrl
// 3. Use data-cy attributes for selectors
// 4. Use cy.intercept to mock API responses
// 5. Use cy.wait for aliased requests
// 6. Use custom commands for repeated flows
// 7. Test the critical user workflows
// 8. Test the route guards and form validation
// 9. Run the tests in CI
// 10. Review the screenshots and videos on failure

The ten parts cover the Cypress config, the login flow test, the route guard test, the form validation test, the component test, the custom command, the custom command usage, the CI pipeline, the ng e2e command, and the E2E checklist.


Quick Reference

The Cypress Commands

CommandPurpose
cy.visit(url)Navigate to a URL
cy.get(selector)Find an element
cy.contains(text)Find by text
cy.intercept(method, url, response)Mock a request
cy.wait(alias)Wait for a request
cy.url()Get the current URL
.type(text)Type into an input
.click()Click an element
.should(assertion)Assert a condition

The Cypress Assertions

AssertionPurpose
should('be.visible')Element is visible
should('contain', text)Element contains text
should('have.value', value)Input has value
should('have.length', n)Collection has n items
should('include', text)URL includes text

The Cypress Configuration

OptionPurpose
baseUrlThe application URL
specPatternWhere the tests are
supportFileThe support file
videoRecord a video
screenshotOnRunFailureCapture a screenshot

The Angular Schematic Commands

CommandPurpose
ng add @cypress/schematicInstall Cypress
ng e2eRun the E2E tests
npm run cypress:openOpen Cypress
npm run cypress:runRun Cypress headless

Best Practices

✅ Do This:

// Use data-cy attributes for selectors
cy.get('[data-cy="submit-button"]').click();                     // ✅
// Use cy.intercept to mock API responses
cy.intercept('GET', '/api/users', { body: mockUsers }).as('getUsers'); // ✅
// Use custom commands for repeated flows
Cypress.Commands.add('login', (email, password) => { ... });     // ✅
// Use cy.wait for aliased requests
cy.wait('@getUsers');                                            // ✅

❌ Don’t Do This:

// Don't use CSS classes for selectors
cy.get('.btn-primary').click();                                  // ❌
// Don't use cy.wait with arbitrary time
cy.wait(5000);                                                   // ❌
// Don't test implementation details
// Cypress tests the rendered DOM.                              // ❌
// Don't forget the data-cy attribute in the template
<button class="btn">Login</button>  // no data-cy                // ❌

Common Pitfalls

PitfallWhy It HappensFix
Test flakyArbitrary waitUse cy.wait('@alias')
Element not foundCSS class changedUse data-cy attribute
API not mockedNo cy.interceptAdd the interceptor
Test depends on orderShared stateReset in beforeEach
Screenshot not capturedscreenshotOnRunFailure falseEnable the option

Real-World Examples

1. Visit

cy.visit('/login');

2. Get Element

cy.get('input[type="email"]').should('be.visible');

3. Type

cy.get('input[type="email"]').type('test@example.com');

4. Click

cy.get('button[type="submit"]').click();

5. Intercept

cy.intercept('POST', '/api/auth/login', { statusCode: 200, body: { token: 'fake' } }).as('login');

6. Wait

cy.wait('@login');

7. Assert URL

cy.url().should('include', '/dashboard');

8. Assert Text

cy.get('.welcome-message').should('contain', 'Welcome');

9. Custom Command

Cypress.Commands.add('login', (email, password) => { ... });

10. Data Attribute

<button data-cy="submit-button">Login</button>

Visual

The Cypress Architecture

┌──────────────────────────────────────────────┐
│  CYPRESS ARCHITECTURE                        │
│                                              │
│  Browser                                     │
│    ├─ Cypress test code                      │
│    ├─ Application code                       │
│    └─ Same run loop                          │
│                                              │
│  The test runs next to the application.      │
│  The debugging is direct.                    │
│                                              │
└──────────────────────────────────────────────┘

The Command Chain

┌──────────────────────────────────────────────┐
│  COMMAND CHAIN                               │
│                                              │
│  cy.get('input')                             │
│    .type('text')                             │
│    .should('have.value', 'text')             │
│                                              │
│  Each command returns the subject.           │
│  The next command acts on it.                │
│  The assertion retries until it passes.      │
│                                              │
└──────────────────────────────────────────────┘

The Intercept Flow

┌──────────────────────────────────────────────┐
│  INTERCEPT FLOW                              │
│                                              │
│  cy.intercept('POST', '/api/login', {...})   │
│    .as('login')                              │
│                                              │
│  cy.get('button').click()                    │
│                                              │
│  cy.wait('@login')                           │
│                                              │
│  The request is intercepted.                 │
│  The mock response is returned.              │
│  The test waits for the request.             │
│                                              │
└──────────────────────────────────────────────┘

The Component Mount

┌──────────────────────────────────────────────┐
│  COMPONENT MOUNT                             │
│                                              │
│  mount(ButtonComponent, {                    │
│    componentProperties: {                    │
│      label: 'Click me'                       │
│    }                                         │
│  });                                         │
│                                              │
│  The component renders in isolation.         │
│  The test interacts with it.                 │
│  No routing. No HTTP.                        │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
CypressE2E testing framework
Angular schematic@cypress/schematic
Installng add @cypress/schematic
Runng e2e
Configcypress.config.ts
Selectordata-cy attribute
API mockcy.intercept
Waitcy.wait('@alias')
Component testmount from cypress/angular
Custom commandCypress.Commands.add

Key takeaways:

  • Cypress is the E2E testing framework that the Angular team recommends. The ng add @cypress/schematic command installs Cypress, configures the ng e2e builder, and scaffolds the test files. The schematic is maintained by the Cypress team and integrates Cypress with the Angular CLI .
  • The cy.get() command finds an element and the .should() assertion retries until the condition is true. The automatic retry is what makes Cypress tests less flaky than traditional E2E tests. The test reads like a sequence of user actions .
  • The cy.intercept() command mocks API responses. The .as() method assigns an alias. The cy.wait('@alias') command waits for the request to complete. The test controls the API responses without depending on a real backend .
  • The data-cy attribute is the recommended selector. The attribute is added to the template and used in the test. It is stable and does not change when the CSS classes change. The data-test attribute is also used in some projects .
  • Cypress supports component testing with the mount function. The mount function from cypress/angular renders an Angular component in isolation. The componentProperties option sets the inputs. The createOutputSpy function spies on the outputs .
  • Custom commands encapsulate repeated flows. The Cypress.Commands.add method registers a custom command. The cy.login() command is an example. The test uses the custom command instead of repeating the steps .
  • The ng e2e command runs the Cypress builder. The builder starts the Angular development server and launches Cypress. The watch option keeps Cypress open and re-runs the tests on every change .

Remember: Cypress is the official E2E testing framework for Angular. It runs in the browser, next to the application, and provides a test runner that shows exactly what happened at every step. The ng add @cypress/schematic command installs it. The ng e2e command runs it. The cy.get() command finds elements. The .should() assertion retries. The cy.intercept() command mocks APIs. The data-cy attribute is the stable selector. The mount function tests components. The custom commands encapsulate flows. Cypress is the tool that verifies the application works for the user.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!