End-to-end (E2E) tests are the highest-confidence tests in the testing pyramid — they run a real browser against your fully deployed application and simulate exactly what a user does. Cypress is the leading E2E framework for modern web applications, providing a real browser runner, time-travel debugging with screenshots at every step, network request interception, and a developer-friendly API. This lesson builds E2E tests for the critical user journeys of the task manager: registration, login, creating a task, and marking it complete.
Cypress Core Commands
| Command | Purpose | Example |
|---|---|---|
cy.visit(url) |
Navigate to a URL | cy.visit('/auth/login') |
cy.get(selector) |
Get elements by CSS selector or data-testid | cy.get('[data-testid=title-input]') |
cy.contains(text) |
Get element containing text | cy.contains('Task One') |
.type(text) |
Type into an input | cy.get('input').type('Hello') |
.click() |
Click an element | cy.get('button[type=submit]').click() |
.select(value) |
Select from a dropdown | cy.get('select').select('high') |
.should(assertion) |
Assert element state — retries until passing | cy.get('.badge').should('contain', 'Completed') |
.and(assertion) |
Chain assertions | .should('be.visible').and('have.text', 'Done') |
cy.intercept(method, url) |
Intercept network requests | cy.intercept('POST', '/api/v1/tasks').as('createTask') |
cy.wait('@alias') |
Wait for intercepted request to complete | cy.wait('@createTask') |
cy.request(options) |
Make direct HTTP requests (no browser) | Seed test data via API |
cy.fixture(file) |
Load fixture data from fixtures/ folder |
cy.fixture('tasks.json').then(tasks => ...) |
data-testid attributes — not by CSS classes, element IDs, or text content. CSS classes change when designs change; text content changes when copy changes. A data-testid="submit-button" attribute communicates intent and stays stable. Add data-testid attributes to interactive elements in your Angular templates — they are invisible to users and cost nothing in production.cy.request() to set up test state via the API rather than clicking through the UI. Logging in through the UI for every test that needs an authenticated user wastes time and creates test interdependency. Create a cy.login() custom command that calls cy.request('POST', '/api/v1/auth/login', credentials) and stores the token — then each test starts already authenticated with a direct API call rather than UI interaction.
cy.request() to create necessary data via the API at the start of each test and clean it up at the end, or use a test database that is reset between test runs. Cypress’s beforeEach hook is the right place for API-based setup. Never share state between it blocks — each test must be independent.Complete Cypress Test Examples
// cypress/support/commands.js — custom commands
Cypress.Commands.add('login', (email, password) => {
cy.request({
method: 'POST',
url: `${Cypress.env('apiUrl')}/auth/login`,
body: { email, password },
}).then(response => {
// Store token in localStorage for the Angular app to pick up
// (or however your app stores the token)
window.localStorage.setItem('accessToken', response.body.data.accessToken);
cy.visit('/tasks'); // navigate to app after login
});
});
Cypress.Commands.add('createTask', (taskData) => {
cy.request({
method: 'POST',
url: `${Cypress.env('apiUrl')}/tasks`,
headers:{ Authorization: `Bearer ${window.localStorage.getItem('accessToken')}` },
body: taskData,
}).then(res => res.body.data);
});
Cypress.Commands.add('cleanupTasks', () => {
cy.request({
method: 'DELETE',
url: `${Cypress.env('apiUrl')}/tasks/all`,
headers:{ Authorization: `Bearer ${window.localStorage.getItem('accessToken')}` },
failOnStatusCode: false,
});
});
// cypress/e2e/auth.cy.js — authentication flows
describe('Authentication', () => {
const testEmail = `test-${Date.now()}@example.com`;
describe('Registration', () => {
it('should register a new user and redirect to tasks', () => {
cy.visit('/auth/register');
cy.get('[data-testid=name-input]').type('Test User');
cy.get('[data-testid=email-input]').type(testEmail);
cy.get('[data-testid=password-input]').type('Password123!');
cy.get('[data-testid=confirm-password-input]').type('Password123!');
cy.get('[data-testid=terms-checkbox]').check();
cy.intercept('POST', '/api/v1/auth/register').as('register');
cy.get('[data-testid=submit-btn]').click();
cy.wait('@register').its('response.statusCode').should('eq', 201);
cy.url().should('include', '/tasks');
});
it('should show validation errors for weak password', () => {
cy.visit('/auth/register');
cy.get('[data-testid=password-input]').type('weak').blur();
cy.get('[data-testid=password-error]')
.should('be.visible')
.and('contain', 'at least 8 characters');
});
it('should show error for already-registered email', () => {
cy.visit('/auth/register');
cy.get('[data-testid=name-input]').type('Another User');
cy.get('[data-testid=email-input]').type(testEmail); // same email
cy.get('[data-testid=password-input]').type('Password123!');
cy.get('[data-testid=confirm-password-input]').type('Password123!');
cy.get('[data-testid=terms-checkbox]').check();
cy.intercept('POST', '/api/v1/auth/register').as('register');
cy.get('[data-testid=submit-btn]').click();
cy.wait('@register').its('response.statusCode').should('eq', 409);
cy.get('[data-testid=form-error]').should('contain', 'already registered');
});
});
describe('Login', () => {
it('should login and reach the task list', () => {
cy.visit('/auth/login');
cy.get('[data-testid=email-input]').type(testEmail);
cy.get('[data-testid=password-input]').type('Password123!');
cy.intercept('POST', '/api/v1/auth/login').as('login');
cy.get('[data-testid=submit-btn]').click();
cy.wait('@login').its('response.statusCode').should('eq', 200);
cy.url().should('include', '/tasks');
});
it('should show error for wrong password', () => {
cy.visit('/auth/login');
cy.get('[data-testid=email-input]').type(testEmail);
cy.get('[data-testid=password-input]').type('WrongPassword!');
cy.get('[data-testid=submit-btn]').click();
cy.get('[data-testid=form-error]').should('contain', 'Invalid credentials');
});
it('should redirect to login when accessing protected route unauthenticated', () => {
cy.visit('/tasks'); // no login
cy.url().should('include', '/auth/login');
});
});
});
// cypress/e2e/tasks.cy.js — task management flows
describe('Task Management', () => {
beforeEach(() => {
cy.login('test@test.com', 'Password123!');
cy.cleanupTasks();
});
it('should create a task and see it in the list', () => {
cy.visit('/tasks');
cy.get('[data-testid=new-task-btn]').click();
cy.url().should('include', '/tasks/new');
cy.get('[data-testid=title-input]').type('My E2E Test Task');
cy.get('[data-testid=priority-select]').select('high');
cy.get('[data-testid=description-input]').type('Created by Cypress');
cy.intercept('POST', '/api/v1/tasks').as('createTask');
cy.get('[data-testid=submit-btn]').click();
cy.wait('@createTask').its('response.statusCode').should('eq', 201);
cy.url().should('include', '/tasks');
cy.contains('My E2E Test Task').should('be.visible');
cy.get('[data-testid=priority-badge]').first().should('contain', 'high');
});
it('should complete a task', () => {
// Set up: create task via API (faster than UI)
cy.createTask({ title: 'Task to Complete', priority: 'medium' });
cy.visit('/tasks');
cy.contains('Task to Complete').should('be.visible');
cy.intercept('PATCH', '/api/v1/tasks/*').as('updateTask');
cy.get('[data-testid=complete-btn]').first().click();
cy.wait('@updateTask').its('response.statusCode').should('eq', 200);
cy.get('[data-testid=status-badge]').first().should('contain', 'completed');
});
it('should delete a task', () => {
cy.createTask({ title: 'Task to Delete' });
cy.visit('/tasks');
cy.intercept('DELETE', '/api/v1/tasks/*').as('deleteTask');
cy.get('[data-testid=delete-btn]').first().click();
cy.get('[data-testid=confirm-delete-btn]').click(); // confirmation dialog
cy.wait('@deleteTask').its('response.statusCode').should('eq', 204);
cy.contains('Task to Delete').should('not.exist');
});
it('should filter tasks by status', () => {
cy.createTask({ title: 'Pending Task', status: 'pending' });
cy.createTask({ title: 'Completed Task', status: 'completed' });
cy.visit('/tasks');
cy.get('[data-testid=status-filter]').select('pending');
cy.contains('Pending Task').should('be.visible');
cy.contains('Completed Task').should('not.exist');
});
});
How It Works
Step 1 — Cypress Retries Assertions Automatically
cy.get('.badge').should('contain', 'Completed') does not just check the element once — Cypress retries the assertion every 50ms for up to 4 seconds (the default timeout). This handles the natural async behaviour of Angular applications: after clicking “Complete”, the HTTP request takes 100ms, then Angular updates the DOM. Without retry, assertions would need explicit waits. The retry mechanism makes assertions robust without setTimeout hacks.
Step 2 — cy.intercept() Gives Visibility into Network Requests
cy.intercept('POST', '/api/v1/tasks').as('createTask') registers an interception before the action that triggers it. After clicking submit, cy.wait('@createTask') waits for that specific request to complete. This solves the “race condition” problem — the test only proceeds after the network request resolves. You can also assert on the request (.its('request.body')) and response (.its('response.statusCode')).
Step 3 — Custom Commands Eliminate Repetitive Setup
cy.login() is a custom command that performs the authentication setup once and can be called in any test’s beforeEach. By making the login call directly via cy.request() (API call, not UI), it bypasses the Angular login form — completing in milliseconds rather than the seconds it would take to click through the form. This makes every subsequent test faster and more focused.
Step 4 — cy.request() Seeds Test Data Without UI Interaction
Creating test data via the API (cy.createTask()) is 10x faster than creating it through the UI, more reliable, and keeps tests focused on what they are testing. A test for “completing a task” should not also test “creating a task” — using cy.request() to create the task directly means the test only exercises the complete flow.
Step 5 — data-testid Attributes Decouple Tests from Implementation
Selecting by [data-testid=submit-btn] means the test is robust to CSS refactors, text changes, and component restructuring — as long as the button exists and has the testid, the test passes. Selecting by button.btn.btn-primary breaks when the styling framework changes. Selecting by text breaks when the copy team changes “Submit” to “Save”. data-testid is the most stable selector strategy.
Common Mistakes
Mistake 1 — Selecting elements by CSS class (fragile)
❌ Wrong — breaks when CSS is refactored:
cy.get('.btn-primary.mat-button').click(); // breaks on style change
cy.get('h2.task-title').should('contain', 'Task One'); // breaks on semantic change
✅ Correct — use data-testid attributes:
cy.get('[data-testid=submit-btn]').click();
cy.get('[data-testid=task-title]').should('contain', 'Task One');
Mistake 2 — Not waiting for network requests before asserting
❌ Wrong — assertion runs before API response updates the UI:
cy.get('[data-testid=complete-btn]').click();
cy.get('[data-testid=status-badge]').should('contain', 'completed'); // may fail — API in-flight
✅ Correct — intercept and wait for the request:
cy.intercept('PATCH', '/api/v1/tasks/*').as('update');
cy.get('[data-testid=complete-btn]').click();
cy.wait('@update'); // ensures API responded before asserting UI
cy.get('[data-testid=status-badge]').should('contain', 'completed');
Mistake 3 — Logging in through the UI in every test (slow)
❌ Wrong — 3-second login form for every test:
beforeEach(() => {
cy.visit('/auth/login');
cy.get('[data-testid=email]').type(email);
cy.get('[data-testid=password]').type(password);
cy.get('[data-testid=submit]').click();
cy.url().should('include', '/tasks'); // wait for redirect
});
✅ Correct — programmatic login via API:
beforeEach(() => {
cy.login(email, password); // 100ms API call, not 3s form interaction
});
Quick Reference
| Task | Cypress Code |
|---|---|
| Navigate | cy.visit('/tasks') |
| Get element | cy.get('[data-testid=my-btn]') |
| Type text | cy.get('input').type('hello') |
| Click | cy.get('button').click() |
| Assert visible | cy.get('.el').should('be.visible') |
| Assert text | cy.get('.el').should('contain', 'text') |
| Assert not exist | cy.contains('Task').should('not.exist') |
| Intercept API | cy.intercept('POST', '/api/tasks').as('create') |
| Wait for request | cy.wait('@create').its('response.statusCode').should('eq', 201) |
| API setup (fast) | cy.request('POST', url, body) |
| Custom command | Cypress.Commands.add('login', (email, pw) => { ... }) |