Claude Code with Cypress: E2E Testing, Component Testing, CI
Why Cypress generates flaky tests without CLAUDE.md
Cypress is deterministic by design. Its command queue, automatic retry, and built-in waiting are meant to eliminate the timing hacks that plague other test runners. The problem is that Claude Code does not know which Cypress patterns exploit that determinism and which fight it. Without explicit constraints, Claude reaches for cy.wait(2000) when an element is slow to appear, skips cy.intercept on routes that return variable data, generates bare cy.get('.button') selectors instead of data-cy attributes, and writes a flat describe block with no Page Object layer regardless of how complex the UI is.
The result is a test suite that passes locally in one environment and intermittently fails in CI, where network calls resolve in a different order, animations are not disabled, and the viewport is a different size. None of that is Cypress failing. It is Claude generating tests that bet on timing rather than state.
This guide covers the CLAUDE.md configuration that anchors Claude Code to Cypress 13's actual model: commands that chain deterministically, intercepts that stub before a route is called, selectors tied to stable attributes, and a Page Object layer that survives refactors. If you are setting up Claude Code for the first time, the Claude Code setup guide covers installation. For the Playwright comparison with a similar CLAUDE.md model, Claude Code with Playwright is a useful contrast.
The Cypress CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every session. For a Cypress test suite it needs to declare: Cypress version and config file location, the E2E and component testing split, selector strategy, the retry-ability rules that replace cy.wait, the cy.intercept policy for network calls, fixture organisation, custom commands location, and the hard rules that block the patterns Claude generates most often without guidance.
# Cypress testing rules
## Stack
- Cypress 13.x, TypeScript 5.x strict
- React 18.x (component testing via @cypress/react)
- Vite 5.x dev server for component specs
- cypress.config.ts at project root
## Project structure
- cypress/e2e/ , E2E spec files (*.cy.ts)
- cypress/component/ , Component spec files (*.cy.tsx)
- cypress/support/ , commands.ts, e2e.ts, component.ts
- cypress/fixtures/ , JSON fixtures (one file per domain entity)
- cypress/pages/ , Page Object classes (one class per route/feature)
## Selector strategy
- ALWAYS use data-cy attributes: cy.get('[data-cy="submit-btn"]')
- NEVER use CSS class selectors in tests: cy.get('.btn-primary') is FORBIDDEN
- NEVER use XPath or positional selectors: cy.get('button:nth-child(2)') is FORBIDDEN
- Text selectors only for user-visible labels: cy.contains('Submit order') is OK
## Retry-ability rules
- ALWAYS assert state, never sleep: cy.get('[data-cy="result"]').should('be.visible')
- NEVER use cy.wait(number) for anything except aliased network calls
- cy.wait() accepts ONLY route aliases: cy.wait('@createOrder')
- Use .should() chains on any element that may not exist immediately
- Use .then() for sequential assertions that depend on previous values
## Network stubbing policy
- ALWAYS call cy.intercept() before the action that triggers the request
- ALWAYS alias every intercept: cy.intercept('POST', '/api/orders').as('createOrder')
- E2E tests against staging: stub third-party calls only (Stripe, Twilio, analytics)
- Component tests: stub ALL network calls via cy.intercept, no real network
- Use cy.fixture() for response bodies; never inline large JSON in spec files
## Fixtures
- One file per domain entity: cypress/fixtures/order.json, user.json, product.json
- Fixtures represent the happy-path shape; edge-case overrides use Cypress._.merge()
- No dynamic data generation inside specs, use fixtures or custom commands
## Custom commands
- All reusable auth flows live in commands.ts as Cypress.Commands.add
- cy.login(email, password), always call this, never repeat the login flow inline
- cy.resetDb(), calls the app's reset endpoint before each test (only in E2E against local)
- cy.seedFixture(entity, fixture), POSTs fixture data to the seed endpoint
## Before/beforeEach discipline
- before(), one-time expensive setup (seed static reference data)
- beforeEach(), per-test setup (login, navigate, reset transient state)
- NEVER share mutable state between tests via global variables in describe blocks
- Each test must be independently runnable: cy.visit() in beforeEach, not once at describe level
## Hard rules
- NEVER cy.wait(number), this is a hard block, no exceptions
- NEVER cy.get('.css-class') for interactive elements
- NEVER nest describe more than 2 levels deep
- ALWAYS add cy.intercept before the triggering action, never after
- ALWAYS use .should() instead of .then() for simple existence/visibility checks
- Component specs: import the component, mount it, test it in isolation, no cy.visit()
- E2E specs: no component imports, test through the UI only
Three rules here prevent the majority of flaky test failures Claude generates without them.
The never-cy.wait(number) rule is the single most impactful entry. cy.wait(2000) passes when the system is fast and fails when the system is slow. There is no correct value because the correct value changes with network conditions, CI runner load, and data volume. The correct replacement is an assertion that retries until Cypress times out: .should('be.visible'), .should('have.text', 'Order confirmed'), or cy.wait('@createOrder') against an aliased intercept. Claude generates cy.wait(2000) as a default when it cannot determine how long an async operation takes. The rule removes that option and forces the correct pattern.
The data-cy selector rule matters because CSS classes are implementation details. A component refactor that renames .btn-primary to .button--primary breaks every test that uses that selector, even if the button's behaviour is unchanged. A data-cy="submit-btn" attribute is a contract: it exists because the test needs it, and any developer touching the component knows not to remove it. Claude will use whatever selectors are in the DOM snapshot it sees. Declaring the policy in CLAUDE.md makes it generate the data-cy attribute in both the component and the test.
The intercept-before-action rule is subtler. cy.intercept registers a route handler, but it only catches requests that fire after the registration. If Claude places cy.intercept('POST', '/api/orders').as('createOrder') after cy.get('[data-cy="submit-btn"]').click(), the click fires the request before the intercept is registered, and cy.wait('@createOrder') times out. The rule enforces the correct order, which Claude does not infer from the Cypress docs alone because the docs describe the API correctly but leave the timing implication implicit.
Page Object pattern in Cypress
The Page Object pattern in Cypress is lighter than in Selenium. There is no driver to wrap. A Page Object is a TypeScript class with methods that wrap groups of Cypress commands, nothing more. The value is that component-level selectors live in one place, and specs stay readable at the action level rather than the selector level.
Add a Page Object section to CLAUDE.md:
## Page Object structure (cypress/pages/)
### One class per route or feature area
export class CheckoutPage {
// Navigation
visit() {
cy.visit('/checkout');
return this;
}
// Selectors (private, data-cy only)
private get submitBtn() { return cy.get('[data-cy="checkout-submit"]'); }
private get orderSummary() { return cy.get('[data-cy="order-summary"]'); }
private get errorBanner() { return cy.get('[data-cy="checkout-error"]'); }
private get emailInput() { return cy.get('[data-cy="checkout-email"]'); }
// Actions (return this for chaining)
fillEmail(email: string) {
this.emailInput.type(email);
return this;
}
submit() {
this.submitBtn.click();
return this;
}
// Assertions (return this for chaining)
shouldShowSummary() {
this.orderSummary.should('be.visible');
return this;
}
shouldShowError(message: string) {
this.errorBanner.should('be.visible').and('contain.text', message);
return this;
}
}
### Usage in specs
import { CheckoutPage } from '../pages/CheckoutPage';
const checkout = new CheckoutPage();
it('completes a valid order', () => {
checkout.visit().fillEmail('user@example.com').submit().shouldShowSummary();
});
### Rules
- Page Object methods are the only place data-cy selectors appear
- Specs use Page Object methods only, no cy.get() calls inline in it() blocks
- Assertion methods are separate from action methods
- Each method returns this for chaining unless it returns a cy chain for further assertion
The payoff is visible in the spec. checkout.visit().fillEmail('user@example.com').submit().shouldShowSummary() reads like a requirement. When the checkout UI is redesigned and the data-cy attributes move, only CheckoutPage changes. The sixty tests that use it keep running without modification.
Claude generates flat specs by default because there is no pattern to copy. With the Page Object template in CLAUDE.md, Claude creates the class, populates the selectors as private getters, and writes specs that call the methods. The class structure matches Claude Code with TypeScript conventions: strict types, private selectors, no any.
cy.intercept and network stubbing
Network calls are the primary source of non-determinism in Cypress tests. A real API returns different data depending on database state, request ordering, and server load. cy.intercept replaces that uncertainty with a controlled response.
Add a network stubbing section to CLAUDE.md:
## cy.intercept patterns
### Happy path stub
cy.intercept('GET', '/api/products*', { fixture: 'product.json' }).as('getProducts');
cy.visit('/shop');
cy.wait('@getProducts');
cy.get('[data-cy="product-list"]').should('have.length.above', 0);
### Error response stub
cy.intercept('POST', '/api/orders', {
statusCode: 422,
body: { error: 'Insufficient stock' },
}).as('createOrderFail');
cy.get('[data-cy="checkout-submit"]').click();
cy.wait('@createOrderFail');
cy.get('[data-cy="checkout-error"]').should('contain.text', 'Insufficient stock');
### Delayed response (simulates slow network)
cy.intercept('GET', '/api/dashboard/stats', (req) => {
req.reply((res) => {
res.setDelay(1500);
});
}).as('getStats');
### Spy without stubbing (let the real call through, alias it for waiting)
cy.intercept('GET', '/api/user/profile').as('getProfile');
cy.visit('/settings');
cy.wait('@getProfile');
// now assert what the real API returned
### Conditional intercept (match body, not just method/URL)
cy.intercept('POST', '/api/orders', (req) => {
if (req.body.productId === 'PROD-001') {
req.reply({ fixture: 'order-confirmed.json' });
}
}).as('createOrder');
## Rules
- ALWAYS set the intercept before cy.visit() or the triggering action
- ALWAYS alias with .as(), cy.intercept without .as() cannot be waited on
- Fixture files live in cypress/fixtures/, never inline large JSON
- Spying (no stub, just alias) is fine for checking a real call was made
- Delay stubs are for testing loading states, not for making tests wait longer
The spy-without-stub pattern is one Claude misses most often. Sometimes you want to confirm a network call was made and check its request body, without caring what the server returns. Aliasing the intercept without providing a stub lets the real call go through while giving you cy.wait('@getProfile') to synchronise on. Without this pattern in CLAUDE.md, Claude either stubs every call (removing real integration coverage) or waits on nothing (producing a timing-dependent test).
The conditional intercept pattern handles tests that submit different products or users in the same spec. Claude defaults to one intercept per spec file, which works until a test needs two different responses for the same route. The request body matcher resolves it without multiple stubs overlapping.
Component testing with Cypress
Cypress 13 ships a component test runner that mounts React, Vue, or Svelte components in a real browser environment. This is different from a jsdom-based unit runner like Vitest or Jest. The component renders with real CSS, real event bubbling, and real browser APIs. The tradeoff is startup speed. The gain is that CSS-in-JS, portals, and browser-specific behaviour that jsdom silently wrong-answers are tested correctly.
Add a component testing section to CLAUDE.md:
## Component testing (cypress/component/)
## Setup (cypress.config.ts)
import { defineConfig } from 'cypress';
import { devServer } from '@cypress/vite-dev-server';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
specPattern: 'cypress/component/**/*.cy.tsx',
supportFile: 'cypress/support/component.ts',
},
e2e: {
baseUrl: 'http://localhost:5173',
specPattern: 'cypress/e2e/**/*.cy.ts',
supportFile: 'cypress/support/e2e.ts',
},
});
## Component spec structure
import { mount } from 'cypress/react18';
import { CheckoutForm } from '../../src/components/CheckoutForm';
describe('CheckoutForm', () => {
beforeEach(() => {
// Stub network calls before mounting
cy.intercept('POST', '/api/validate-coupon', {
body: { valid: true, discount: 10 },
}).as('validateCoupon');
});
it('shows discount when valid coupon is entered', () => {
mount(<CheckoutForm />);
cy.get('[data-cy="coupon-input"]').type('SAVE10');
cy.get('[data-cy="apply-coupon"]').click();
cy.wait('@validateCoupon');
cy.get('[data-cy="discount-line"]').should('contain.text', '10%');
});
});
## Rules
- Component specs: NEVER call cy.visit(), mount() replaces it
- Mount with minimal props, test the component, not the page that uses it
- Stub all network calls in beforeEach, component tests must be deterministic
- Use real browser events: cy.get('[data-cy="input"]').type() not fireEvent.type()
- Import and test the component directly, no global app wrapper unless testing context providers
The clean split between component specs and E2E specs is what Claude will blur without the explicit rule. Claude sometimes generates a component spec that calls cy.visit('/') and then tests the component through the full page, which is an E2E test written in the component runner. That misses the point of component isolation. The mount() rule keeps component specs focused and fast. For the jsdom-based unit testing layer that sits below component tests, Claude Code with Vitest covers the conventions that complement this setup, and Claude Code with Jest covers the same ground for Jest-based projects.
CI parallelisation and Cypress Cloud
A full Cypress E2E suite against a staging environment can take 15 to 45 minutes running serially on one machine. Parallelisation splits specs across runners and reduces that to 5 to 10 minutes. Cypress Cloud (formerly Cypress Dashboard) orchestrates the split automatically. Self-hosted parallelisation works via the --parallel flag and a shared CI instance key.
Add a CI section to CLAUDE.md:
## CI configuration (GitHub Actions)
## .github/workflows/cypress.yml
name: Cypress E2E
on:
push:
branches: [main, staging]
pull_request:
jobs:
cypress-e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
containers: [1, 2, 3, 4] # 4 parallel runners
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- run: npm run start:ci & # start app in background
- name: Run Cypress E2E
uses: cypress-io/github-action@v6
with:
wait-on: 'http://localhost:5173'
record: true
parallel: true
group: e2e-parallel
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
cypress-component:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Run Cypress Component
uses: cypress-io/github-action@v6
with:
component: true
## cypress.config.ts additions for Cloud
export default defineConfig({
projectId: 'your-project-id', // from Cypress Cloud dashboard
e2e: {
baseUrl: process.env.CYPRESS_BASE_URL ?? 'http://localhost:5173',
// ...
},
});
## Rules
- NEVER run e2e and component specs in the same job
- Set fail-fast: false so one runner failure does not cancel others mid-run
- CYPRESS_BASE_URL env var lets you target staging vs. local in the same workflow
- Component tests do not need the app running, no wait-on step needed
- Store CYPRESS_RECORD_KEY in GitHub secrets, never in code
The fail-fast: false setting prevents a flake on runner 2 from killing runners 1, 3, and 4 halfway through. Without it, a single timeout cancels the run and you lose the coverage from the remaining specs. Claude will set fail-fast: true by default because that is the GitHub Actions default. The explicit override belongs in CLAUDE.md.
The CYPRESS_BASE_URL override lets a pull request workflow test against a preview deployment URL by passing CYPRESS_BASE_URL=https://preview-123.staging.example.com. Claude will hardcode http://localhost:5173 in the config if the pattern is not shown. The env var approach requires no code change to switch targets.
Failure modes and what to review manually
Claude Code generates correct Cypress code in several areas when the CLAUDE.md template is in place. The data-cy selector on new components, the cy.intercept call before the action, the Page Object method structure, the beforeEach login call, and the component spec with mount() instead of cy.visit() are all consistently right.
Four areas warrant manual review after Claude generates tests.
The fixture shape. Claude will generate a fixture file that matches the component's props, but it cannot know whether the fixture represents a valid backend response. Check that cypress/fixtures/order.json matches what the real API returns, including nested objects, date formats, and nullable fields. A fixture that does not match the real response catches no real bugs.
The test isolation. Each it() block should be runnable in isolation. When Claude generates a suite where test 3 depends on state created by test 2, the suite passes in order and fails when Cypress runs tests in parallel or in a different sequence. Check that beforeEach resets all relevant state and that no it() block reads a variable written by a sibling it().
The intercept alias coverage. Claude sometimes adds a cy.intercept for the primary route but misses secondary calls the action triggers. A checkout flow might hit /api/orders, /api/inventory/reserve, and /api/notifications/send in sequence. If only /api/orders is aliased, the test may assert on UI that appears after all three calls complete, and the missing aliases make the assertion timing fragile. Review every network call a user action triggers and decide whether each needs an alias.
The component test scope. Claude will sometimes mount a component and then test behaviour that actually belongs to a parent component or a context provider. If CheckoutForm relies on a cart context, the test that does not provide that context will fail in a confusing way. Check that component specs provide all required context providers and that the assertions are testing the mounted component's own behaviour, not a child's.
The broader patterns for building a reliable test layer alongside Claude Code are in Claude Code testing and Claude Code best practices. Both compose with the Cypress-specific rules here.
Permission hooks for destructive test scripts
Cypress test suites accumulate scripts: database seeders, fixture generators, screenshot comparators, coverage reporters. Some are read-only. Some modify the database. Permission hooks gate the destructive ones. The mechanism is the same as any Claude Code project.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(npx cypress run --spec*)",
"Bash(npx cypress open*)",
"Bash(npm run test:e2e*)",
"Bash(npm run test:component*)",
"Bash(node scripts/generate-fixtures.js*)"
],
"deny": [
"Bash(node scripts/reset-staging-db.js*)",
"Bash(node scripts/delete-test-users.js*)",
"Bash(node scripts/wipe-snapshots.js*)"
]
}
}
Running specs and generating fixtures are allowed without prompting. Resetting the staging database or wiping snapshots require explicit confirmation. The deny list prevents Claude from invoking these scripts as part of an automated test-run workflow without the developer seeing the prompt.
Building tests that fail for the right reasons
The Cypress CLAUDE.md in this guide produces a test suite where selectors are stable because they are tied to data-cy attributes, assertions are stable because they use retry-able .should() chains instead of cy.wait(number), network calls are deterministic because cy.intercept stubs run before the triggering action, component tests are isolated because they use mount() with all required context, and CI runs complete faster because E2E specs are parallelised across four runners.
The underlying principle is the same as any framework integration with Claude Code. A Cypress suite without a CLAUDE.md produces tests that pass locally and fail in CI, because Claude reaches for timing-based patterns when state-based ones are not specified. A suite with the configuration above has a single failure mode: the application behaviour changed. Every failing test is a real signal, not a timing accident.
For the mechanics of how CLAUDE.md is read at session start and how to version it alongside your test suite, see CLAUDE.md explained. Claudify includes a Cypress-specific CLAUDE.md template, pre-configured for the retry-ability rules, Page Object pattern, cy.intercept policy, component testing setup, and CI parallelisation shown in this guide.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify