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.json
with 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
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)