End-to-End Testing with Cypress

I cover Cypress E2E setup, the core commands, data-cy selectors, cy.intercept for network stubbing, CI integration, and when E2E tests are worth writing.

End-to-End Testing with Cypress

Unit tests told you the function worked. Integration tests told you the API responded with the right status code. Then you deployed, and someone filed a bug: clicking the checkout button does nothing. The form submitted, the spinner appeared, and the page just sat there.

That failure is invisible to unit and integration tests. It only shows up when a real browser navigates a real page and a real user tries to complete a flow. Cypress drives a browser, clicks through your UI, and tells you whether your application actually works from one end to the other.

Quick answer: Install with npm install -D cypress. Create cypress.config.ts at the project root with a baseUrl pointing to your dev server. Write specs in cypress/e2e/ using cy.visit() to navigate, cy.get('[data-cy="..."]') to find elements, and cy.should() to assert. Use cy.intercept() to control network responses without needing a real backend. Run headlessly in CI with npx cypress run.

Series: Part 4 of 4. Part 1: Testing JavaScript Apps: Unit, Integration, and E2E · Part 2: Writing Better Tests with Vitest · Part 3: API Testing with Supertest

On this page

Setting up Cypress in a project

Install Cypress as a dev dependency:

npm install -D cypress

The first time you open Cypress, it creates a cypress/ folder with an e2e/ subdirectory for specs and a support/ directory for global setup. Before writing any tests, create cypress.config.ts at the project root:

import { defineConfig } from 'cypress'

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
  },
})

Setting baseUrl means cy.visit('/dashboard') expands to the full URL automatically. When the port changes between environments, you update one place. Without it, you’d hardcode the full URL in every spec.

Open the interactive test runner for debugging:

npx cypress open

Run all specs headlessly — the mode you’ll use in CI:

npx cypress run

A spec file has this shape:

describe('cart flow', () => {
  beforeEach(() => {
    cy.visit('/cart')
  })

  it('shows an empty state when no items are added', () => {
    cy.get('[data-cy="empty-cart-message"]').should('be.visible')
  })
})

describe groups related tests. it describes one behavior. beforeEach runs before every test in the group — usually navigation or login.

Check this before moving on

  • cypress.config.ts exists at the project root with a baseUrl
  • npx cypress open launches the test runner and shows the cypress/e2e/ folder
  • The dev server is running at the address in baseUrl

The commands you write in every test

Most Cypress tests use the same handful of commands:

  • cy.visit('/path') — navigate to a page relative to baseUrl
  • cy.get('[data-cy="name"]') — find an element by a test attribute
  • cy.click() — click the current element
  • cy.type('text') — type into a form input
  • cy.should('condition', value) — assert something about the element

Here’s what a realistic flow looks like:

it('adds a product to the cart', () => {
  cy.visit('/shop')
  cy.get('[data-cy="product-card"]').first().click()
  cy.get('[data-cy="add-to-cart"]').click()
  cy.get('[data-cy="cart-badge"]').should('contain', '1')
  cy.get('[data-cy="checkout-button"]').click()
  cy.url().should('include', '/checkout')
})

One thing that catches people early: Cypress commands don’t return values the way normal JavaScript functions do. const el = cy.get('button') doesn’t give you a DOM element — it queues a command. If you need to work with a value, chain .then() or create an alias with .as().

// ✗ Does not work — cy.get() returns a Chainable, not an element
const badge = cy.get('[data-cy="cart-badge"]')
badge.should('contain', '1')

// ✓ Chain directly
cy.get('[data-cy="cart-badge"]').should('contain', '1')

Cypress also retries commands automatically. If a button renders after a 300ms fade-in, cy.get() keeps trying until it finds the element or hits the timeout (4 seconds by default). You rarely need to add manual waits for DOM interactions.

Selectors that survive a redesign

The most common reason E2E suites become fragile is using CSS classes or element IDs as selectors:

cy.get('.btn-primary')   // breaks when the design system renames the class
cy.get('#email-input')   // breaks when an engineer removes the ID
cy.contains('Submit')    // breaks when the button label changes

The fix is a dedicated test attribute on every element your tests target:

<button data-cy="submit-button">Submit</button>
<input data-cy="email-input" type="email" />
cy.get('[data-cy="submit-button"]').click()
cy.get('[data-cy="email-input"]').type('alex@example.com')

data-cy sends a signal: this element exists for tests. Designers can restyle the button, rename the class, move it in the DOM. The test selector stays independent. Cypress’s own best-practices documentation recommends this pattern for exactly this reason.

The one-time cost is adding attributes to the components you’re testing. The benefit is a test suite that doesn’t require updates every time the UI is restyled.

Custom commands and fixtures

When the same setup steps — login, navigate to a specific state, seed data — appear in several spec files, move them into a custom command. Custom commands live in cypress/support/commands.ts:

Cypress.Commands.add('login', (email: string, password: string) => {
  cy.session([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()
    cy.url().should('include', '/dashboard')
  })
})

The cy.session() wrapper here is worth understanding. After the first successful login, Cypress caches the session state — cookies, localStorage, sessionStorage. If 20 tests all call cy.login(), only the first one drives the login UI. Every subsequent test restores the cached session. This cuts test run time significantly once your suite grows.

Add the TypeScript declaration to keep autocomplete working:

// cypress/support/index.d.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>
    }
  }
}

Fixtures are JSON files in cypress/fixtures/ that hold test data:

[
  { "id": "1", "name": "Wireless Keyboard", "price": 79 },
  { "id": "2", "name": "USB Hub", "price": 35 }
]

Load them in a test with cy.fixture():

cy.fixture('products.json').then((products) => {
  cy.get('[data-cy="product-card"]').should('have.length', products.length)
})

Fixtures keep test data out of spec files and out of your application code. When the data shape changes, you update one file.

Controlling the network with cy.intercept

This is the part of Cypress that changes how you think about testing networked features.

By default, Cypress tests run against a real app making real requests. That works, but it makes tests slow and dependent on the backend being in the right state. cy.intercept() replaces any network request with a response you define:

it('shows catalog products', () => {
  cy.intercept('GET', '/api/products', { fixture: 'products.json' }).as('getProducts')
  cy.visit('/catalog')
  cy.wait('@getProducts')
  cy.get('[data-cy="product-card"]').should('have.length', 2)
})

.as('getProducts') names the intercept. cy.wait('@getProducts') pauses the test until that exact request fires, then moves on. You’re not waiting a fixed number of milliseconds — you’re waiting for a named network event. If the request never fires, the test fails with a clear message.

Error states are easy to test without touching the backend:

it('shows an error message when the order fails', () => {
  cy.intercept('POST', '/api/orders', { statusCode: 500 }).as('createOrder')
  cy.get('[data-cy="place-order"]').click()
  cy.wait('@createOrder')
  cy.get('[data-cy="error-banner"]').should('be.visible')
})

What surprises people is that cy.intercept isn’t only for stubbing. You can assert on the request payload itself:

cy.wait('@createOrder').its('request.body').should('have.property', 'productId')

This turns a fire-and-forget API call into a verifiable contract. Instead of hoping your frontend sends the right payload, you can confirm it. This is especially useful for checkout flows, form submissions, and anything where the request body matters.

Running Cypress in CI

The Cypress team maintains a GitHub Action that starts your dev server and waits for it before running tests:

- name: Run E2E tests
  uses: cypress-io/github-action@v6
  with:
    start: npm run dev
    wait-on: 'http://localhost:3000'
    browser: chrome

start runs the dev server as a background process. wait-on polls the URL until it responds before launching Cypress. Without wait-on, tests start before the server is ready and fail on the first cy.visit() — a confusing failure on a first CI setup.

Keep secrets out of your spec files. Read them with Cypress.env() and supply them through environment variables in CI:

env:
  CYPRESS_TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
  CYPRESS_TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}

In the spec:

const email = Cypress.env('TEST_USER_EMAIL')
const password = Cypress.env('TEST_USER_PASSWORD')
cy.login(email, password)

Secrets end up in build artifacts and recorded test runs if you embed them directly in test files. The CYPRESS_* environment variable pattern is the safe alternative.

When E2E tests are worth the cost

E2E tests are the most expensive kind to write, run, and maintain. They’re slower than unit tests by orders of magnitude and break for more reasons — the server has to be running, the database has to be in the right state, and the browser has to render everything correctly. That cost is worth paying for specific things. Not for everything.

You want to verify Right tool
A calculation returns the correct value Unit test
An API endpoint responds with the right status Integration (Supertest)
A user can complete signup end to end E2E (Cypress)
A redirect fires after authentication E2E (Cypress)
A form shows validation errors on blur Unit or E2E depending on complexity
A third-party OAuth screen works Stub at the boundary — don’t test third-party UI

The guiding question: “Would this failure only become visible when a real person clicks through a real browser?” If yes, it belongs in Cypress. If a unit test or a Supertest call can catch it first, use that instead.

In practice, the most useful Cypress suites are narrow. Ten to twenty tests covering the critical paths — authentication, the main feature flow, checkout, whatever failure would cause the most user harm. The goal isn’t coverage numbers. It’s catching the bugs that slip past every other layer.

E2E tests are user-journey assertions

The shift that makes Cypress useful is treating each test as a user-journey assertion rather than a code coverage target. You’re not checking that a function returns the right value. You’re checking whether a user can sign in, navigate to the right page, and complete the action the application exists to provide.

That framing keeps your suite small on purpose. Unit tests handle logic. Supertest handles APIs. Cypress handles the paths that only exist when the whole application runs in a browser together.

Start with the flow you’d be most alarmed to see broken in production. Write three or four tests that cover it completely. Get them running on every push. That’s a more useful foundation than a large, fragile suite that no one trusts.

Sources