Testing & TDD

End-to-End Testing with Cypress

38 min Lesson 13 of 35

Introduction to Cypress

Cypress is a next-generation front-end testing tool built for the modern web. Unlike traditional E2E tools like Selenium, Cypress runs directly in the browser alongside your application, providing fast, reliable, and flake-free testing.

Why Cypress?

  • Time Travel: Take snapshots as tests run, travel back in time to any command
  • Debuggability: Readable errors and stack traces, Chrome DevTools integration
  • Automatic Waiting: No need for explicit waits or sleeps
  • Real-Time Reloads: Watch tests execute in real-time as you develop
  • Screenshots & Videos: Automatically capture failures and record entire runs
  • Network Control: Stub and control network traffic easily
Note: Cypress is designed for testing anything that runs in a browser. It works with any frontend framework or none at all.

Installing Cypress

Install Cypress via npm:

# Install Cypress npm install --save-dev cypress # Open Cypress Test Runner npx cypress open # Run Cypress tests headlessly npx cypress run

After installation, Cypress creates this folder structure:

cypress/ ├── e2e/ # Test files (.cy.js) ├── fixtures/ # Test data (JSON, CSV, etc.) ├── support/ # Reusable commands and configuration │ ├── commands.js # Custom commands │ └── e2e.js # Global hooks and config └── downloads/ # Downloaded files during tests

Writing Your First Test

Create a test file in cypress/e2e/:

// cypress/e2e/first-test.cy.js describe('My First Test', () => { it('visits the app', () => { cy.visit('https://example.com'); cy.contains('Example Domain'); }); it('has the correct title', () => { cy.visit('https://example.com'); cy.title().should('eq', 'Example Domain'); }); });
Tip: Use .cy.js or .cy.ts extension for Cypress test files. This helps IDEs provide proper autocomplete and prevents linters from complaining about Cypress globals.

Cypress Fundamentals

Selecting Elements

Cypress provides powerful selectors inspired by jQuery:

// By CSS selector cy.get('.btn-primary'); cy.get('#username'); // By data attribute (recommended) cy.get('[data-cy="submit-button"]'); // By text content cy.contains('Submit'); cy.contains('button', 'Submit'); // Relational queries cy.get('form').find('input[type="email"]'); cy.get('.header').within(() => { cy.get('nav').should('be.visible'); }); // First, last, nth cy.get('li').first(); cy.get('li').last(); cy.get('li').eq(2); // 3rd element (0-indexed)
Best Practice: Use data-cy, data-test, or data-testid attributes for selecting elements. Avoid CSS class names or IDs which may change during styling refactors.

Interacting with Elements

// Typing cy.get('[data-cy="email-input"]').type('user@example.com'); cy.get('[data-cy="password"]').type('password123{enter}'); // Type and press Enter // Clicking cy.get('[data-cy="submit-btn"]').click(); cy.get('[data-cy="menu-item"]').dblclick(); cy.get('[data-cy="context-menu"]').rightclick(); // Checkboxes and radios cy.get('[data-cy="terms-checkbox"]').check(); cy.get('[data-cy="terms-checkbox"]').uncheck(); cy.get('[data-cy="gender-male"]').check(); // Select dropdowns cy.get('[data-cy="country-select"]').select('United States'); cy.get('[data-cy="country-select"]').select('US'); // By value // Clear input cy.get('[data-cy="search"]').clear(); // Focus and blur cy.get('[data-cy="input"]').focus(); cy.get('[data-cy="input"]').blur();

Assertions

Cypress includes Chai assertions built-in:

// Implicit assertions (retry automatically) cy.get('[data-cy="title"]').should('be.visible'); cy.get('[data-cy="title"]').should('have.text', 'Welcome'); cy.get('[data-cy="input"]').should('have.value', 'test'); cy.get('[data-cy="btn"]').should('be.disabled'); cy.get('[data-cy="list"]').should('have.length', 5); // Chaining assertions cy.get('[data-cy="error"]') .should('be.visible') .and('contain', 'Invalid') .and('have.class', 'error-message'); // Negative assertions cy.get('[data-cy="modal"]').should('not.exist'); cy.get('[data-cy="spinner"]').should('not.be.visible'); // Explicit assertions (use then() for more control) cy.get('[data-cy="count"]').then($el => { const count = parseInt($el.text()); expect(count).to.be.greaterThan(0); expect(count).to.be.lessThan(100); });

Working with Network Requests

Cypress makes it easy to test and stub network requests:

describe('API Tests', () => { it('waits for API call', () => { // Intercept HTTP request cy.intercept('GET', '/api/users').as('getUsers'); cy.visit('/users'); // Wait for request to complete cy.wait('@getUsers').then((interception) => { expect(interception.response.statusCode).to.eq(200); expect(interception.response.body).to.have.length.greaterThan(0); }); }); it('stubs API response', () => { // Stub with custom response cy.intercept('GET', '/api/users', { statusCode: 200, body: [ { id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Smith' } ] }).as('getUsers'); cy.visit('/users'); cy.wait('@getUsers'); cy.contains('John Doe'); cy.contains('Jane Smith'); }); it('simulates network error', () => { cy.intercept('GET', '/api/users', { statusCode: 500, body: { error: 'Server error' } }); cy.visit('/users'); cy.contains('Failed to load users'); }); });

Using Fixtures

Store test data in fixtures for reusability:

// cypress/fixtures/users.json [ { "id": 1, "name": "John Doe", "email": "john@example.com" }, { "id": 2, "name": "Jane Smith", "email": "jane@example.com" } ]

Load and use fixtures in tests:

describe('Using Fixtures', () => { it('loads user data from fixture', () => { cy.fixture('users').then((users) => { cy.intercept('GET', '/api/users', users); }); cy.visit('/users'); cy.contains('John Doe'); }); // Alternative: use fixture in intercept directly it('uses fixture directly', () => { cy.intercept('GET', '/api/users', { fixture: 'users.json' }); cy.visit('/users'); cy.contains('Jane Smith'); }); });

Custom Commands

Create reusable commands to avoid repetition:

// cypress/support/commands.js Cypress.Commands.add('login', (email, password) => { cy.visit('/login'); cy.get('[data-cy="email"]').type(email); cy.get('[data-cy="password"]').type(password); cy.get('[data-cy="submit"]').click(); // Wait for redirect cy.url().should('include', '/dashboard'); }); Cypress.Commands.add('logout', () => { cy.get('[data-cy="user-menu"]').click(); cy.get('[data-cy="logout"]').click(); cy.url().should('include', '/login'); }); // Add custom assertion Cypress.Commands.add('shouldBeLoggedIn', () => { cy.get('[data-cy="user-menu"]').should('exist'); cy.getCookie('auth_token').should('exist'); });

Use custom commands in tests:

describe('User Authentication', () => { it('logs in successfully', () => { cy.login('user@example.com', 'password123'); cy.shouldBeLoggedIn(); }); it('logs out successfully', () => { cy.login('user@example.com', 'password123'); cy.logout(); cy.url().should('include', '/login'); }); });

Working with Forms

Test complex form interactions:

describe('Contact Form', () => { beforeEach(() => { cy.visit('/contact'); }); it('submits form with valid data', () => { cy.get('[data-cy="name"]').type('John Doe'); cy.get('[data-cy="email"]').type('john@example.com'); cy.get('[data-cy="message"]').type('Hello, this is a test message.'); cy.intercept('POST', '/api/contact').as('submitForm'); cy.get('[data-cy="submit"]').click(); cy.wait('@submitForm').its('response.statusCode').should('eq', 200); cy.contains('Thank you for your message'); }); it('shows validation errors', () => { cy.get('[data-cy="submit"]').click(); cy.get('[data-cy="name-error"]').should('contain', 'Name is required'); cy.get('[data-cy="email-error"]').should('contain', 'Email is required'); }); it('validates email format', () => { cy.get('[data-cy="email"]').type('invalid-email'); cy.get('[data-cy="email"]').blur(); cy.get('[data-cy="email-error"]').should('contain', 'Invalid email'); }); });

Handling Async Behavior

Cypress automatically waits for commands and assertions to pass:

describe('Automatic Waiting', () => { it('waits for element to appear', () => { cy.visit('/loading-example'); // Cypress retries for up to 4 seconds (default timeout) cy.get('[data-cy="content"]').should('be.visible'); }); it('uses custom timeout', () => { cy.visit('/slow-loading'); // Override default timeout cy.get('[data-cy="content"]', { timeout: 10000 }) .should('be.visible'); }); it('waits for conditions', () => { cy.visit('/counter'); cy.get('[data-cy="increment"]').click().click().click(); // Cypress retries until assertion passes cy.get('[data-cy="count"]').should('have.text', '3'); }); });
Note: Cypress commands are queued and executed asynchronously. You don't need async/await or promises - Cypress handles timing automatically.

Testing Authentication Flows

describe('Authentication', () => { it('completes full login flow', () => { // Start at home page cy.visit('/'); // Click login link cy.get('[data-cy="login-link"]').click(); cy.url().should('include', '/login'); // Fill login form cy.get('[data-cy="email"]').type('user@example.com'); cy.get('[data-cy="password"]').type('password123'); // Intercept login request cy.intercept('POST', '/api/auth/login').as('loginRequest'); cy.get('[data-cy="submit"]').click(); // Verify request and response cy.wait('@loginRequest').then((interception) => { expect(interception.request.body).to.have.property('email'); expect(interception.response.statusCode).to.eq(200); }); // Verify redirect and authentication state cy.url().should('include', '/dashboard'); cy.getCookie('auth_token').should('exist'); cy.get('[data-cy="user-name"]').should('contain', 'Welcome'); }); });

Viewport and Device Testing

Test responsive designs:

describe('Responsive Design', () => { it('displays mobile menu on small screens', () => { cy.viewport(375, 667); // iPhone SE cy.visit('/'); cy.get('[data-cy="mobile-menu-toggle"]').should('be.visible'); cy.get('[data-cy="desktop-nav"]').should('not.be.visible'); }); it('displays desktop nav on large screens', () => { cy.viewport(1920, 1080); cy.visit('/'); cy.get('[data-cy="desktop-nav"]').should('be.visible'); cy.get('[data-cy="mobile-menu-toggle"]').should('not.be.visible'); }); // Test predefined viewports ['iphone-x', 'ipad-2', 'macbook-15'].forEach((device) => { it(`displays correctly on ${device}`, () => { cy.viewport(device); cy.visit('/'); cy.get('[data-cy="main-content"]').should('be.visible'); }); }); });
Exercise:

Create a comprehensive E2E test suite for an e-commerce checkout flow:

  1. Navigate to product listing page
  2. Search for a specific product
  3. Click on product to view details
  4. Add product to cart
  5. View cart and verify product is there
  6. Update quantity and verify price calculation
  7. Proceed to checkout
  8. Fill shipping and payment information
  9. Submit order (stub the payment API)
  10. Verify order confirmation page displays correct details

Use fixtures for product data, create custom commands for reusable actions, and intercept all API calls.

Best Practices

  • Use data-cy attributes: Don't rely on CSS classes or IDs that may change
  • Create custom commands: Encapsulate common actions (login, logout, etc.)
  • Use fixtures: Store test data separately from test logic
  • Stub network requests: Make tests faster and more reliable
  • Test user journeys: Focus on complete workflows, not isolated features
  • Keep tests independent: Each test should run in isolation
  • Use beforeEach wisely: Set up common state, but keep tests readable
Common Mistakes:
  • Using .then() like Promises - Cypress commands are queued differently
  • Not waiting for network requests before assertions
  • Selecting elements by text that may change with translations
  • Creating too many small tests instead of testing complete flows
  • Not cleaning up state between tests

In the next lesson, we'll explore Playwright, another powerful E2E testing tool with excellent cross-browser support.