End to End Testing with Cypress

Introduction

When building modern React applications, ensuring that your app behaves correctly from the user’s perspective is critical. Unit tests and integration tests validate individual pieces of functionality, but they don’t guarantee that the app works seamlessly when all parts are combined.

That’s where End-to-End (E2E) testing comes in — it verifies that the entire application flow works as intended, from the UI interactions to backend responses.

Cypress has quickly become one of the most popular E2E testing tools for React developers. It runs tests directly in the browser, allowing you to simulate user behavior like clicking buttons, filling forms, and navigating between pages. Unlike traditional testing tools that run externally, Cypress runs inside the same environment as your application — giving you direct access to DOM elements, network requests, and even browser events.

This comprehensive post will walk you through everything you need to know about End-to-End Testing with Cypress — from setup and writing your first test to advanced debugging and test organization strategies.


1. What Is End-to-End Testing?

End-to-End (E2E) testing ensures that all components of your application work together correctly. It validates the complete user journey — simulating how a real user would interact with the app.

For example:

  • A user visits the login page.
  • They enter their email and password.
  • They click the “Login” button.
  • They’re redirected to the dashboard upon successful authentication.

E2E testing verifies that this sequence works correctly in a real browser environment.

Why E2E Testing Is Important

  • Ensures integration between frontend and backend.
  • Prevents regressions after code updates.
  • Simulates real user behavior.
  • Increases confidence before deployment.

In React, where applications are dynamic and depend heavily on state management, E2E testing helps catch bugs that unit or component tests might miss.


2. Introduction to Cypress

Cypress is an all-in-one testing framework that provides:

  • A test runner that runs tests in a real browser.
  • Automatic waiting for DOM elements.
  • Network interception to mock or verify API calls.
  • Time travel debugging to see snapshots of each test step.
  • A built-in dashboard to visualize test results.

Unlike Selenium or Puppeteer, Cypress runs inside the browser, giving it native access to your application’s DOM and network layer.


3. Setting Up Cypress

Setting up Cypress in a React project is simple. It can be added to any existing React application created using tools like Create React App (CRA) or Vite.

Step 1: Install Cypress

In your React project directory, run:

npm install --save-dev cypress

or with Yarn:

yarn add --dev cypress

Step 2: Open the Cypress Test Runner

Once installed, you can open the Cypress interface using:

npx cypress open

This will launch the Cypress interactive test runner, which automatically creates a cypress/ folder in your project containing:

  • e2e/ – where your test files go.
  • fixtures/ – contains mock data for testing.
  • support/ – reusable commands and configuration.

Step 3: Folder Structure

After initialization, your project structure looks like this:

my-react-app/
│
├── src/
├── cypress/
│   ├── e2e/
│   │   └── example.cy.js
│   ├── fixtures/
│   ├── support/
│   └── cypress.config.js

Step 4: Running Tests in Headless Mode

To run tests without opening the Cypress UI:

npx cypress run

This executes all tests in headless mode using the default Electron browser — perfect for CI/CD pipelines.


4. Writing End-to-End Tests

Cypress tests are written using Mocha syntax (describe, it, etc.), and it provides its own API (cy) for interacting with the DOM.

Structure of a Cypress Test

A basic Cypress test has two main blocks:

  • describe() defines a test suite.
  • it() defines an individual test.

Each command in Cypress begins with the cy object, which allows you to interact with elements and perform assertions.


Example: Testing a User Login Flow

Below is a complete Cypress E2E test for verifying a React login page.

describe('User Login Flow', () => {
  it('should allow users to log in', () => {
// Visit the login page
cy.visit('/login');
// Type in email and password
cy.get('input[name="email"]').type('[email protected]');
cy.get('input[name="password"]').type('password');
// Click the submit button
cy.get('button[type="submit"]').click();
// Verify that the URL changes to /dashboard
cy.url().should('include', '/dashboard');
// Verify that the dashboard is displayed
cy.contains('Welcome').should('be.visible');
}); });

Explanation

  1. cy.visit() navigates to the specified route.
  2. cy.get() selects DOM elements using CSS selectors.
  3. cy.type() simulates user input.
  4. cy.click() triggers button clicks.
  5. cy.url() and cy.contains() perform assertions to ensure correct navigation and content display.

5. Interacting with DOM Elements

Cypress provides rich commands to interact with HTML elements. These commands automatically wait for elements to appear, reducing the need for manual delays.

Examples

Typing into Input Fields

cy.get('input[name="username"]').type('john_doe');

Clicking Buttons

cy.get('button[type="submit"]').click();

Selecting Dropdown Values

cy.get('select[name="role"]').select('Admin');

Checking Checkboxes

cy.get('input[type="checkbox"]').check();

Asserting Visibility

cy.contains('Welcome').should('be.visible');

All these commands chain together seamlessly. Cypress waits for elements to appear before acting, minimizing flaky tests.


6. Assertions in Cypress

Assertions verify that your app behaves as expected after each interaction.

Examples

Check URL

cy.url().should('include', '/dashboard');

Check Text

cy.contains('Welcome, User').should('exist');

Check Element State

cy.get('button').should('be.disabled');
cy.get('input').should('have.value', '[email protected]');

Check Visibility

cy.get('.success-message').should('be.visible');

Assertions ensure that each test step leads to the correct UI state.


7. Mocking and Controlling Network Requests

Cypress provides powerful network control features that allow you to intercept, mock, or wait for API calls. This is crucial when testing apps that rely on asynchronous data fetching.

Example: Mocking an API Response

cy.intercept('GET', '/api/users', {
  statusCode: 200,
  body: [{ id: 1, name: 'John Doe' }],
}).as('getUsers');

cy.visit('/users');
cy.wait('@getUsers');
cy.contains('John Doe').should('be.visible');

Explanation

  • cy.intercept() mocks a network request.
  • .as() gives the request an alias for later reference.
  • cy.wait('@alias') ensures the request completes before continuing.

Simulating API Errors

cy.intercept('GET', '/api/users', {
  statusCode: 500,
  body: { error: 'Internal Server Error' },
}).as('getUsersError');

You can then verify that the app displays the appropriate error message:

cy.contains('Something went wrong').should('be.visible');

8. Advanced Features of Cypress

Cypress isn’t just a simple test runner — it comes with powerful features that make E2E testing more reliable, readable, and efficient.

1. Automatic Waiting

Cypress automatically waits for commands and assertions to pass before moving to the next line. No need for setTimeout or manual waits.

2. Time Travel

Every command in a test run is captured visually. You can click on each command in the Cypress UI to see the app’s state at that point in time.

3. Screenshots and Videos

Cypress can capture screenshots and videos automatically:

npx cypress run --record

4. Spying on Functions

You can spy on browser events or app methods:

cy.spy(window, 'alert').as('alertSpy');
cy.get('button').click();
cy.get('@alertSpy').should('have.been.calledWith', 'Login successful');

5. Custom Commands

You can define reusable commands for repetitive actions.
In cypress/support/commands.js:

Cypress.Commands.add('login', (email, password) => {
  cy.get('input[name="email"]').type(email);
  cy.get('input[name="password"]').type(password);
  cy.get('button[type="submit"]').click();
});

Use it in tests:

cy.visit('/login');
cy.login('[email protected]', 'password');

9. Organizing Tests

A well-organized test structure improves maintainability.

Recommended Folder Structure

cypress/
  e2e/
login/
  login.cy.js
dashboard/
  dashboard.cy.js
fixtures/
user.json
support/
commands.js

Fixtures for Test Data

Fixtures store sample JSON data used in tests:
cypress/fixtures/user.json

{
  "email": "[email protected]",
  "password": "password"
}

Use it in tests:

cy.fixture('user').then((user) => {
  cy.get('input[name="email"]').type(user.email);
  cy.get('input[name="password"]').type(user.password);
});

10. Debugging Tests in Cypress

Cypress provides several debugging features to help you analyze test failures effectively.

1. Using .debug()

You can pause tests at any point and inspect variables in the browser console.

cy.get('input[name="email"]').debug();

2. Using .pause()

cy.pause();

This pauses the test until you manually resume from the Cypress runner.

3. Viewing Console Logs

Cypress logs all actions in real time, allowing you to follow each step in the browser console.

4. Visual Debugging

  • Use Time Travel in the Cypress UI to inspect DOM snapshots.
  • Take screenshots on test failure automatically by configuring:
"video": true,
"screenshotsFolder": "cypress/screenshots"

11. Running Cypress in Continuous Integration (CI)

E2E tests are most effective when integrated into your CI/CD pipeline to ensure every deployment passes tests automatically.

Example with GitHub Actions

Create a .github/workflows/cypress.yml:

name: Cypress Tests
on: [push]
jobs:
  cypress-run:
runs-on: ubuntu-latest
steps:
  - name: Checkout code
    uses: actions/checkout@v3
  - name: Install dependencies
    run: npm ci
  - name: Run Cypress tests
    run: npx cypress run

Cypress can also integrate with CI tools like Jenkins, CircleCI, or GitLab CI.


12. Best Practices for Writing Reliable Cypress Tests

  1. Use Stable Selectors:
    Prefer custom attributes like data-testid instead of class names. <button data-testid="login-button">Login</button> cy.get('[data-testid="login-button"]').click();
  2. Avoid Hardcoded Waits:
    Use cy.wait() for specific API aliases instead of fixed delays.
  3. Test User Flows, Not Implementation:
    Focus on what the user does, not how the code is structured.
  4. Clean Up State:
    Reset app data before tests to ensure predictable results.
  5. Parallelize Tests:
    Split long test suites into multiple files for faster execution.

13. Comparing Cypress with Other E2E Tools

FeatureCypressSeleniumPlaywright
Execution EnvironmentRuns inside browserExternal driverHeadless + browser
Auto WaitingYesNoYes
Network MockingBuilt-inLimitedBuilt-in
Ease of SetupSimpleComplexModerate
Real-Time DebuggingExcellentLimitedGood

Cypress stands out for its developer-friendly interface, live reloading, and detailed test visualization.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *