Angular E2E Testing
Introduction
End-to-End (E2E) testing is a crucial part of the testing strategy for Angular applications. Unlike unit tests that focus on isolated components, E2E tests verify that all parts of your application work together as expected from the user's perspective. These tests simulate real user interactions with your application in a browser environment, which helps ensure that your application works correctly in production scenarios.
In this guide, we'll explore E2E testing in Angular using two popular testing frameworks: Cypress (the currently recommended approach) and Protractor (Angular's legacy E2E testing tool). By the end, you'll understand how to set up, write, and run effective E2E tests for your Angular applications.
Why E2E Testing Matters
Before diving into implementation, let's understand why E2E testing is valuable:
- Validates User Workflows: Ensures critical user paths work as expected
- Catches Integration Issues: Identifies problems that only appear when components interact
- Tests Real Browser Behavior: Verifies functionality across actual browser environments
- Provides Confidence for Deployment: Reduces the risk of releasing broken features
Getting Started with Cypress
Cypress has become the preferred E2E testing tool for Angular applications due to its modern architecture, developer-friendly API, and excellent debugging capabilities.
Setting Up Cypress in an Angular Project
If you're starting a new Angular project, you can add Cypress during project creation:
ng new my-app --e2e=cypress
For existing projects, you can add Cypress with:
ng add @cypress/schematic
This will:
- Install Cypress dependencies
- Add Cypress configuration files
- Update your angular.jsonwith Cypress test configuration
- Create example test files
Writing Your First Cypress Test
Let's write a basic E2E test for an Angular application with a simple login form:
- Create a test file in cypress/e2e/login.cy.ts:
describe('Login Page', () => {
  beforeEach(() => {
    // Visit the login page before each test
    cy.visit('/login');
  });
  it('should display the login form', () => {
    // Check if login elements are present
    cy.get('form').should('be.visible');
    cy.get('input[name="email"]').should('be.visible');
    cy.get('input[name="password"]').should('be.visible');
    cy.get('button[type="submit"]').should('be.visible');
  });
  it('should show error for invalid credentials', () => {
    // Type invalid credentials
    cy.get('input[name="email"]').type('[email protected]');
    cy.get('input[name="password"]').type('wrongpassword');
    
    // Submit the form
    cy.get('button[type="submit"]').click();
    
    // Assert error message appears
    cy.get('.error-message')
      .should('be.visible')
      .and('contain', 'Invalid email or password');
  });
  it('should navigate to dashboard after successful login', () => {
    // Type valid credentials (assuming these work in your test environment)
    cy.get('input[name="email"]').type('[email protected]');
    cy.get('input[name="password"]').type('password123');
    
    // Submit the form
    cy.get('button[type="submit"]').click();
    
    // Verify navigation to dashboard occurred
    cy.url().should('include', '/dashboard');
    cy.get('.welcome-message').should('contain', 'Welcome, User');
  });
});
Running Cypress Tests
To run your Cypress tests:
ng e2e
Or for more control, use:
npx cypress open
This opens the Cypress Test Runner, providing an interactive interface where you can select and run specific tests while watching them execute in real time.
Cypress Best Practices for Angular Applications
1. Use Data-Test Attributes
Instead of relying on CSS classes or element types that might change as your UI evolves, use dedicated data attributes for testing:
<!-- In your Angular component template -->
<button data-test="submit-button" class="btn btn-primary">Login</button>
// In your Cypress test
cy.get('[data-test="submit-button"]').click();
2. Create Custom Commands for Common Operations
For operations you perform frequently across tests, create custom Cypress commands:
// In cypress/support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
  cy.visit('/login');
  cy.get('[data-test="email-input"]').type(email);
  cy.get('[data-test="password-input"]').type(password);
  cy.get('[data-test="login-button"]').click();
});
// Extend the Cypress namespace to include your custom commands
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<Element>;
    }
  }
}
Now you can use this command in any test:
describe('Dashboard Features', () => {
  beforeEach(() => {
    // Use custom command for login
    cy.login('[email protected]', 'password123');
  });
  
  it('should display user projects', () => {
    cy.get('[data-test="project-list"]').should('be.visible');
    // More assertions...
  });
});
3. Test Real API Calls vs. Mocking
Cypress allows both approaches:
Testing with real API calls:
describe('Product List', () => {
  it('should display products from API', () => {
    cy.visit('/products');
    // Wait for actual API response
    cy.get('[data-test="product-item"]', { timeout: 10000 })
      .should('have.length.at.least', 1);
  });
});
Mocking API responses:
describe('Product List with Mocked Data', () => {
  beforeEach(() => {
    // Intercept API call and return mock data
    cy.intercept('GET', '/api/products', {
      statusCode: 200,
      body: [
        { id: 1, name: 'Product 1', price: 19.99 },
        { id: 2, name: 'Product 2', price: 29.99 },
      ]
    }).as('getProducts');
  });
  it('should display mocked products', () => {
    cy.visit('/products');
    cy.wait('@getProducts');
    cy.get('[data-test="product-item"]').should('have.length', 2);
    cy.get('[data-test="product-item"]').first().should('contain', 'Product 1');
  });
});
Using Protractor (Legacy Approach)
While Cypress is now recommended, many existing Angular projects still use Protractor. Here's how to work with it:
Basic Protractor Test Example
// In e2e/src/app.e2e-spec.ts
import { browser, by, element } from 'protractor';
describe('Angular App', () => {
  beforeEach(() => {
    browser.get('/');
  });
  it('should display welcome message', () => {
    const welcomeElement = element(by.css('app-root h1'));
    expect(welcomeElement.getText()).toEqual('Welcome to my-app!');
  });
  
  it('should navigate to about page', () => {
    element(by.linkText('About')).click();
    
    // Verify URL changed
    expect(browser.getCurrentUrl()).toContain('/about');
    
    // Verify page content
    const heading = element(by.css('h1'));
    expect(heading.getText()).toEqual('About Us');
  });
});
Running Protractor Tests
ng e2e
Real-World Example: Testing a Todo Application
Let's walk through testing a more complex scenario - a todo application with Cypress:
The Todo Application
Our example application allows users to:
- View a list of todos
- Add new todos
- Mark todos as completed
- Delete todos
Comprehensive E2E Test Suite
// In cypress/e2e/todo-app.cy.ts
describe('Todo Application', () => {
  beforeEach(() => {
    // Reset the application state before each test
    cy.visit('/');
    
    // Clear existing todos (assuming there's a clear all button or API)
    cy.get('[data-test="clear-all-btn"]').click();
  });
  it('should display empty todo list initially', () => {
    cy.get('[data-test="todo-list"]').should('exist');
    cy.get('[data-test="todo-item"]').should('not.exist');
    cy.get('[data-test="empty-message"]').should('be.visible');
  });
  it('should add a new todo', () => {
    const todoText = 'Buy groceries';
    
    // Type and submit new todo
    cy.get('[data-test="new-todo-input"]').type(todoText);
    cy.get('[data-test="add-todo-btn"]').click();
    
    // Verify todo was added
    cy.get('[data-test="todo-item"]').should('have.length', 1);
    cy.get('[data-test="todo-item"]').first().should('contain', todoText);
    cy.get('[data-test="empty-message"]').should('not.exist');
  });
  it('should mark a todo as completed', () => {
    // Add a todo first
    const todoText = 'Complete E2E tests';
    cy.get('[data-test="new-todo-input"]').type(todoText);
    cy.get('[data-test="add-todo-btn"]').click();
    
    // Mark as completed
    cy.get('[data-test="todo-checkbox"]').click();
    
    // Verify it's marked as completed
    cy.get('[data-test="todo-item"]').should('have.class', 'completed');
  });
  it('should delete a todo', () => {
    // Add a todo first
    const todoText = 'Delete this todo';
    cy.get('[data-test="new-todo-input"]').type(todoText);
    cy.get('[data-test="add-todo-btn"]').click();
    
    // Verify it exists
    cy.get('[data-test="todo-item"]').should('have.length', 1);
    
    // Delete the todo
    cy.get('[data-test="delete-todo-btn"]').click();
    
    // Verify it's deleted
    cy.get('[data-test="todo-item"]').should('not.exist');
    cy.get('[data-test="empty-message"]').should('be.visible');
  });
  it('should handle multiple todos correctly', () => {
    // Add multiple todos
    const todos = ['Buy milk', 'Call doctor', 'Send email'];
    
    todos.forEach(todo => {
      cy.get('[data-test="new-todo-input"]').type(todo);
      cy.get('[data-test="add-todo-btn"]').click();
    });
    
    // Verify all todos were added
    cy.get('[data-test="todo-item"]').should('have.length', 3);
    
    // Check counter shows correct number
    cy.get('[data-test="todo-count"]').should('contain', '3');
    
    // Complete the second todo
    cy.get('[data-test="todo-item"]').eq(1)
      .find('[data-test="todo-checkbox"]').click();
      
    // Verify completed count
    cy.get('[data-test="completed-count"]').should('contain', '1');
    cy.get('[data-test="remaining-count"]').should('contain', '2');
  });
});
CI/CD Integration
To make E2E testing part of your continuous integration workflow:
GitHub Actions Example
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
  cypress-run:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: 16
          
      - name: Install dependencies
        run: npm ci
        
      - name: Cypress run
        uses: cypress-io/github-action@v5
        with:
          build: npm run build
          start: npm start
          wait-on: 'http://localhost:4200'
E2E Testing Best Practices
- Test from the user's perspective: Focus on user workflows rather than implementation details
- Keep tests independent: Each test should work in isolation
- Use realistic test data: Simulate real user data and scenarios
- Balance coverage and speed: E2E tests are slower than unit tests, so focus on critical paths
- Handle asynchronous operations properly: Wait for elements to appear instead of using arbitrary timeouts
- Implement retry logic: Add retry capability for flaky operations
- Take screenshots on failures: Capture visual evidence when tests fail
- Monitor test execution time: Keep E2E tests reasonably fast
Troubleshooting Common Issues
1. Flaky Tests
If tests pass sometimes and fail other times:
// Increase timeout for problematic operations
cy.get('[data-test="slow-loading-element"]', { timeout: 10000 })
  .should('be.visible');
// Add retry logic
Cypress.Commands.add('clickUntilGone', (selector) => {
  cy.get(selector).then($el => {
    if ($el.length) {
      cy.get(selector).click();
      cy.clickUntilGone(selector);
    }
  });
});
2. Element Not Found Errors
When elements aren't found when expected:
// Wait for Angular to stabilize
cy.visit('/dynamic-page', {
  onBeforeLoad(win) {
    // Wait for Angular to be stable
    cy.spy(win.console, 'error').as('consoleError');
  },
});
cy.get('@consoleError').should('not.be.called');
// Check element exists in DOM before interacting
cy.get('[data-test="my-element"]').should('exist').click();
Summary
E2E testing is an essential part of ensuring your Angular application works as expected from the user's perspective. In this guide, we've covered:
- The basics of E2E testing in Angular
- Setting up and using Cypress for modern E2E testing
- Writing effective test suites for real user scenarios
- Best practices and troubleshooting techniques
- Integration with CI/CD pipelines
By implementing E2E tests alongside unit and integration tests, you create a comprehensive testing strategy that builds confidence in your application's reliability and functionality.
Additional Resources
- Cypress Documentation
- Angular Testing Guide
- Protractor Documentation (for legacy projects)
- Testing Angular Applications (book)
Practice Exercises
- Create an E2E test suite for a user registration form that validates inputs and displays appropriate error messages
- Implement tests for an authentication flow including login, protected routes, and logout
- Write tests for a shopping cart that verify adding, updating quantity, and removing items
- Create a test suite for a pagination component that loads data dynamically from an API
- Implement visual regression testing using Cypress and a plugin like cypress-image-snapshot
💡 Found a typo or mistake? Click "Edit this page" to suggest a correction. Your feedback is greatly appreciated!