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.

  • namecypress-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.

~/.claude/skills/cypress-e2e/SKILL.md

--- name: cypress-e2e description: Writes and stabilizes Cypress end-to-end and component tests — data-cy and role-based selectors, retry-ability and the command queue,… license: MIT ---

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 via cy.contains or @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 for cy.wait('@alias') on an intercepted request or a .should() on visible state.
  • Every test can run alone (it.only) and in any order. testIsolation is 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).
  • validate catches 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.session clears the page; call cy.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');
});
GoalPattern
Wait for the app’s own requestcy.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 responsecy.intercept(url, (req) => req.continue((res) => { res.body.items = [] }))
Assert the request payloadcy.wait('@save').its('request.body').should('deep.include', { name: 'A' })
Mixed: real backend for the happy path, stubs for errorsDefault; 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; call cy.clock() before cy.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=_blank in 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 }
ParallelizationUse when
Cypress Cloud --record --parallelPaid 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 jobUnder ~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 plus testIsolation: true means later tests start on a blank page; use beforeEach.
  • cy.request bypasses cy.intercept; it hits the server directly.
  • Environment variables: CYPRESS_ prefixed env vars land in Cypress.env(); do not put secrets in cypress.config committed 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

  1. Run the changed spec 10 times headless: for i in $(seq 10); do npx cypress run --spec cypress/e2e/search.cy.ts || break; done.
  2. Run it with it.only alone and as part of the full suite in random spec order.
  3. Break the feature on purpose (wrong total, 500 from API): the test fails with a clear message, not a timeout on an unrelated selector.
  4. grep -rn "cy.wait([0-9]" cypress returns nothing new; no new {force: true}.
  5. 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.

esc