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.

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. Createcypress.config.tsat the project root with abaseUrlpointing to your dev server. Write specs incypress/e2e/usingcy.visit()to navigate,cy.get('[data-cy="..."]')to find elements, andcy.should()to assert. Usecy.intercept()to control network responses without needing a real backend. Run headlessly in CI withnpx 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
- The commands you write in every test
- Selectors that survive a redesign
- Custom commands and fixtures
- Controlling the network with cy.intercept
- Running Cypress in CI
- When E2E tests are worth the cost
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.tsexists at the project root with abaseUrl -
npx cypress openlaunches the test runner and shows thecypress/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 tobaseUrlcy.get('[data-cy="name"]')— find an element by a test attributecy.click()— click the current elementcy.type('text')— type into a form inputcy.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.