Cypress E2E skill for Claude Code
Cypress tests that wait on aliases, never on timers. Claude writes Cypress specs that respect the command queue: stable data-cy or role selectors, retrying should assertions, intercept aliases instead of fixed waits, and cy.session logins with validation so each spec logs in once through the API. It seeds data with cy.task, adds component tests with cy.mount, and parallelizes CI through Cypress Cloud or free spec splitting.
- name
cypress-e2e - size260 lines · ≈2,550 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and stabilizes Cypress end-to-end and component tests — data-cy and role-based selectors, retry-ability and the command queue, cy.intercept stubbing and waiting on aliases, cy.session login caching with validation, cy.request and cy.task for setup, cy.origin for cross-origin flows, component testing with cy.mount, and CI runs with retries, artifacts, and parallelization through Cypress Cloud or free spec splitting. Use when writing or fixing Cypress tests, when tests are flaky or slow, when replacing cy.wait with timeouts, setting up login reuse, or wiring Cypress into GitHub Actions. Try asking
- Replace every cy.wait(3000) in our Cypress suite with proper waits
- Set up cy.session so our 40 specs stop logging in through the UI
- Our Cypress run takes 25 minutes in CI. Split it across four machines
Use it when
- Writing or fixing Cypress e2e or component tests
- Specs are flaky, slow or full of fixed waits
- Login or data setup is repeated through the UI in every test
- Wiring Cypress into CI with retries, artifacts and parallel jobs
Skip it when
- The suite uses Playwright or you are choosing it: use playwright-e2e
- You already know a test is flaky and need to measure and quarantine it: use flaky-test-triage
How to check the result
The changed spec passes ten headless runs in a row, passes alone and in the full suite, and fails with a clear assertion when the feature is deliberately broken.
The common mistake it prevents
Storing the result of cy.get in a variable or asserting inside .then, which reads the DOM once and races the render instead of retrying.
A good Cypress suite drives a few critical journeys through the real UI, sets up state
through APIs instead of clicks, waits on network aliases and retrying assertions instead
of fixed delays, and logs in once per spec via cy.session. A cy.wait(2000) in a test
is a bug report against that test.
Ground rules
- Select with dedicated attributes (
data-cy,data-testid) or accessible roles/text viacy.containsor@testing-library/cypress. Never CSS classes or DOM structure. - Cypress commands are enqueued, not executed when called. Do not assign their results
to variables; use
.then(), aliases (.as()), or.should()callbacks. - Assert with retrying
.should(...). Assertions inside.then()run once and do not retry. - No
cy.wait(<number>). Wait forcy.wait('@alias')on an intercepted request or a.should()on visible state. - Every test can run alone (
it.only) and in any order.testIsolationis on by default in e2e mode; do not turn it off to share state. - Seed data with
cy.request/cy.task, not by clicking through admin screens. - Keep E2E for journeys. Use component tests and unit tests for branch-level logic.
Workflow
- [ ] 1. Check Cypress version, config, support files, Node version
- [ ] 2. Choose the journeys worth E2E coverage
- [ ] 3. Config baseline: baseUrl, retries, timeouts, artifacts
- [ ] 4. Login via cy.session in a custom command
- [ ] 5. Stub or alias network calls; seed via API/task
- [ ] 6. Write specs with stable selectors and retrying assertions
- [ ] 7. Run headless repeatedly, then wire CI with artifacts and splitting
1. Inspect first
npx cypress --version
cat cypress.config.* ; ls cypress/e2e cypress/component cypress/support 2>/dev/null
grep -rn "cy.wait([0-9]" cypress | wc -l # fixed waits to remove
grep -rnE "cy\.get\('\.[a-z]" cypress | head # class selectors
node --version # Cypress 15 needs Node 20+
Cypress 15 (current major since August 2025) dropped Node 18 and 23. cy.prompt
(natural-language steps) is experimental and invite-gated; do not depend on it in a team
suite, and rename any custom command called prompt.
3. Config baseline
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.ts',
setupNodeEvents(on, config) {
on('task', {
async 'db:seed'(scenario: string) {
const { seed } = await import('./cypress/tasks/seed');
return seed(scenario); // must return a value or null
},
});
return config;
},
},
component: {
devServer: { framework: 'react', bundler: 'vite' },
},
retries: { runMode: 2, openMode: 0 },
defaultCommandTimeout: 6000,
viewportWidth: 1280,
viewportHeight: 800,
video: false, // default off; screenshots on failure stay on
});
Start the app outside Cypress in CI (start-server-and-test or the GitHub Action’s
start/wait-on), not with cy.exec.
4. Login with cy.session
// cypress/support/commands.ts
Cypress.Commands.add('login', (role: 'admin' | 'member' = 'member') => {
cy.session(
['login', role],
() => {
cy.request('POST', '/api/test/login', { role }) // API login, no UI
.its('status').should('eq', 200);
},
{
validate() {
cy.request('/api/me').its('status').should('eq', 200);
},
cacheAcrossSpecs: true,
},
);
});
// cypress/support/index.d.ts
declare global {
namespace Cypress { interface Chainable { login(role?: 'admin' | 'member'): Chainable<void> } }
}
- The session id array must include everything that changes the session (role, tenant).
validatecatches expired or revoked sessions; without it a stale cache yields confusing failures.- Test the real login form in exactly one spec; everywhere else use
cy.login(). cy.sessionclears the page; callcy.visit()after it.- For SSO/OAuth redirects to another origin, wrap those steps in
cy.origin(url, () => ...), or better, use a test-only token endpoint.
5. Network: intercept, alias, wait
it('shows search results', () => {
cy.intercept('GET', '/api/search*').as('search'); // spy on real backend
cy.login();
cy.visit('/search');
cy.get('[data-cy=search-input]').type('invoice{enter}');
cy.wait('@search').its('response.statusCode').should('eq', 200);
cy.get('[data-cy=result-row]').should('have.length.greaterThan', 0);
});
it('renders the error state', () => {
cy.intercept('GET', '/api/search*', { statusCode: 500, body: { error: 'boom' } }).as('search');
cy.login();
cy.visit('/search?q=x');
cy.wait('@search');
cy.contains('[role=alert]', 'Something went wrong').should('be.visible');
});
| Goal | Pattern |
|---|---|
| Wait for the app’s own request | cy.intercept(...).as('x') before the action, then cy.wait('@x') |
| Force an edge case (500, empty, slow) | Static response { statusCode, body, delay } or fixture: 'file.json' |
| Modify a real response | cy.intercept(url, (req) => req.continue((res) => { res.body.items = [] })) |
| Assert the request payload | cy.wait('@save').its('request.body').should('deep.include', { name: 'A' }) |
| Mixed: real backend for the happy path, stubs for errors | Default; full stubbing turns E2E into a component test |
Register intercepts before cy.visit or the action that fires the request. Intercepts
registered later miss requests already sent.
6. Writing specs that do not flake
// Bad: reads once, races the render
cy.get('[data-cy=total]').then(($el) => expect($el.text()).to.eq('$42.00'));
// Good: retries until true or timeout
cy.get('[data-cy=total]').should('have.text', '$42.00');
// Bad: clicks whatever row is first while the list is still loading
cy.get('[data-cy=row]').first().find('button').click();
// Good: target the row by content, assert it is ready, then act
cy.contains('[data-cy=row]', 'Acme').should('be.visible').within(() => {
cy.get('[data-cy=edit]').click();
});
- Query chains (
get,find,contains,first) re-run together on retry since Cypress 12; break the chain with.then()and you lose that. - Use
cy.clock()/cy.tick(ms)for debounce, polling and countdowns instead of real waiting; callcy.clock()beforecy.visit. {force: true}hides real bugs (overlays, disabled buttons). Fix the cause instead.- Uncaught app exceptions fail the test. Only suppress specific known third-party errors
with
Cypress.on('uncaught:exception', (err) => !err.message.includes('ResizeObserver')). - File downloads: assert via
cy.readFile('cypress/downloads/report.csv'). - Iframes and new tabs are limited; remove
target=_blankin the test or assert the href.
Component testing
npx cypress open --component # first run scaffolds support/component.ts
npx cypress run --component
import { PriceTag } from './PriceTag';
it('formats currency', () => {
cy.mount(<PriceTag amount={4200} currency="EUR" />);
cy.contains('42,00').should('be.visible');
});
Use component tests for visual states and interactions of one component in a real
browser; wrap providers (router, query client, theme) in a custom mount command.
CI
# .github/workflows/e2e.yml (excerpt)
jobs:
cypress:
runs-on: ubuntu-latest
strategy: { fail-fast: false, matrix: { containers: [1, 2, 3, 4] } }
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6 # check for a newer major
with:
build: npm run build
start: npm run start:test
wait-on: 'http://localhost:3000/health'
browser: chrome
record: true
parallel: true
group: e2e
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
if: failure()
with: { name: cypress-artifacts-${{ matrix.containers }}, path: cypress/screenshots }
| Parallelization | Use when |
|---|---|
Cypress Cloud --record --parallel | Paid plan available; load-balances specs by history, gives flake analytics |
Free spec splitting (e.g. the cypress-split plugin, or a matrix passing --spec lists) | No Cloud; split by file count or recorded timings |
| Single job | Under ~5 minutes total; not worth the complexity |
Keep specs small (under ~2 minutes each) so splitting balances. Set
numTestsKeptInMemory: 0 in CI for long specs to reduce memory.
Retries: runMode: 2 absorbs rare infrastructure noise, but every test that passes only on
retry must be logged and fixed (see flaky-test-triage).
Gotchas
cy.get(...)returns a chainable, not an element;const btn = cy.get(...)followed by later use is a classic bug.before()hooks with state plustestIsolation: truemeans later tests start on a blank page; usebeforeEach.cy.requestbypassescy.intercept; it hits the server directly.- Environment variables:
CYPRESS_prefixed env vars land inCypress.env(); do not put secrets incypress.configcommitted to git. - Chrome-only features in tests fail on Firefox/WebKit runs; pin the browser in CI.
- Large fixtures slow every intercept; keep them minimal and typed.
- Upgrading majors: read the migration guide for each skipped major; 12 changed query retry and test isolation, 13 turned video off by default, 15 raised the Node floor.
Verification
- Run the changed spec 10 times headless:
for i in $(seq 10); do npx cypress run --spec cypress/e2e/search.cy.ts || break; done. - Run it with
it.onlyalone and as part of the full suite in random spec order. - Break the feature on purpose (wrong total, 500 from API): the test fails with a clear message, not a timeout on an unrelated selector.
grep -rn "cy.wait([0-9]" cypressreturns nothing new; no new{force: true}.- CI shows the same pass/fail as local and uploads screenshots for failures.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.