Cypress E2E Tests — User Journeys, Custom Commands, and Network Interception

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 => ...)
Note: Always select elements by 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.
Tip: Use 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.

Warning: E2E tests must be able to run against a clean state — never rely on data from previous test runs. Use 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) => { ... })

🧠 Test Yourself

A Cypress test clicks “Complete Task” and immediately asserts the status badge shows “completed”. The test is flaky — it sometimes fails. What is the most likely cause and fix?