cypress-ops
Cypress end-to-end and component testing operations - selector/retry-ability strategy, cy.intercept network stubbing, cy.session auth, component vs e2e, flake diagnosis, CI, Test Replay. Use for: cypress, e2e test, component test, cy.get, cy.intercept, cy.session, data-cy, data-t
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/cypress-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Cypress Operations
Facts verified as of 2026-07.
Version context (verified against docs.cypress.io, 2026-06): Cypress 14.x, Test
Replay (v13+), cy.session with cacheAcrossSpecs. APIs move — confirm against the live
docs when a detail is load-bearing.
End-to-end and component testing with Cypress (cypress, TS/JS). The runner executes
tests inside a real browser via the Cypress App (cypress open) or headlessly
(cypress run). The defining mental model: cy.* commands are not promises — they
enqueue onto an async command chain that Cypress drains for you. Internalise that and the
agentic gotchas below disappear.
Quick Start
npm install -D cypress
npx cypress open # launch the Cypress App: pick E2E or Component, real browser
npx cypress run # headless run, all specs (CI default)
npx cypress run --spec "cypress/e2e/auth/*.cy.ts"
npx cypress run --component # run component specs
npx cypress run --browser chrome --headed
npx cypress run --record --key <k> # upload to Cypress Cloud (enables Test Replay, v13+)
Specs live in cypress/e2e/**/*.cy.ts (E2E) and beside components or cypress/component/
(component). Config is a single cypress.config.ts at the repo root.
The Async Command Queue (read this first)
cy.get(...) returns a Chainer, not the element and not a Promise. Commands are
scheduled, then run in order after the test function returns. This is the source of
nearly every Cypress mistake an agent makes.
// WRONG — cy.get does not return a value; `el` is a Chainer, this is meaningless
const el = cy.get('[data-test=total]');
if (el.text() === '$0') { /* never works */ }
// WRONG — async/await does nothing useful; cy commands aren't awaitable promises
const text = await cy.get('[data-test=total]'); // do NOT do this
// RIGHT — yield the value into a callback; assertions inside .should() retry
cy.get('[data-test=total]').should('have.text', '$0');
// RIGHT — need the raw value? use .then() (but it does NOT retry — see below)
cy.get('[data-test=total]').invoke('text').then((text) => {
// text is a string here; runs after the queue reaches this point
});
Rules that follow from this:
- No
const/letto "store" a command result. Use.as()aliases +cy.get('@alias'). - No
async/awaitoncy.*. The queue handles ordering. Mixing in real promises? wrap them withcy.then(() => promise)orcy.wrap(promise). - No
if/elseon element state read synchronously. Conditional testing is an anti-pattern in Cypress (the DOM may not have settled); make the app deterministic, or drive the branch off a server/cy.interceptstate you control. Deep dive: references/network-and-auth.md.
Retry-ability (why you almost never need waits)
Cypress retries queries and assertions until they pass or the command times out
(default 4s). It does not retry actions (.click(), .type(), .select()) —
those fire once, though the queries leading up to them retry until the element is
actionable (visible, not disabled, not animating).
| Construct | Retries? | Use for |
|---|---|---|
cy.get / .find / .contains / .its / .invoke (queries) |
Yes — whole chain re-queries | Locating/reading DOM that may not be ready |
.should(...) / expect inside it |
Yes — the callback re-runs | Assertions; conditional waits on settled state |
.click / .type / .select (actions) |
No — fire once | Interactions (leading queries still retry) |
.then(cb) |
No — runs once, no retry protection | Extracting a value; NOT for assertions |
// .should(callback) retries the whole callback — safe for racy DOM
cy.get('[data-test=rows] li').should(($li) => {
expect($li).to.have.length(3);
expect($li.first()).to.contain('Alice');
});
// .then() does NOT retry — capturing $el here then asserting later races the render
If you reach for cy.wait(3000), you're missing an assertion or an aliased intercept.
The only legitimate cy.wait takes an alias (cy.wait('@getUsers')), never a number.
Selector Strategy
Prefer a dedicated test attribute over CSS classes, IDs, or tag names — the latter are
brittle and change with styling/refactors. Cypress recommends data-cy or data-test
(the Cypress Real World App standardises on data-test); pick one and enforce it.
// GOOD — decoupled from styling and structure
cy.get('[data-test=submit]').click();
// AVOID — couples the test to CSS/markup that changes for non-test reasons
cy.get('.btn-primary').click();
cy.get('#submit').click();
Wrap the convention in a custom command so specs stay terse:
// cypress/support/commands.ts
Cypress.Commands.add('getBySel', (sel, ...args) =>
cy.get(`[data-test=${sel}]`, ...args));
Cypress.Commands.add('getBySelLike', (sel, ...args) =>
cy.get(`[data-test*=${sel}]`, ...args)); // substring match
// usage: cy.getBySel('submit').click();
Reserve cy.contains('Log In') for when the visible text itself is what you're
asserting; otherwise it couples tests to copy.
Network Stubbing — cy.intercept
cy.intercept is the single API for spying on and stubbing network traffic. Set it up
before the action that triggers the request, alias it, then wait on the alias.
// Stub with a fixture, alias, wait
cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers');
cy.visit('/users');
cy.wait('@getUsers'); // resolves when the request fires
// Inline body / status
cy.intercept('POST', '/api/login', { statusCode: 401, body: { error: 'nope' } }).as('login');
// routeMatcher object (method + glob/regex url) + dynamic reply
cy.intercept({ method: 'GET', url: '/api/orders/*' }, (req) => {
req.reply((res) => { res.body.hasMore = false; }); // tweak the real response
}).as('orders');
// Assert against the captured request/response
cy.wait('@login').its('response.statusCode').should('eq', 401);
// Wait on several at once
cy.wait(['@getUsers', '@orders']);
Stub what you don't own, exercise what you do. Stubbing third-party/slow endpoints
makes tests fast and deterministic; hitting your real backend (seeded via cy.request)
verifies the client↔server contract. Decide per endpoint. GraphQL, request modification,
and seed-via-cy.request patterns: references/network-and-auth.md.
Authentication — cy.session
Log in once, cache the session, restore it across tests (and optionally specs). This is the biggest suite-speed win after stubbing.
// cypress/support/commands.ts
Cypress.Commands.add('login', (username: string, password: string) => {
cy.session(
[username, password], // cache key — array/object is stringified
() => { // setup: runs only on cache miss
cy.visit('/login');
cy.get('[data-test=name]').type(username);
cy.get('[data-test=password]').type(password);
cy.get('form').contains('Log In').click();
cy.url().should('contain', '/dashboard'); // assert logged-in before caching!
},
{
validate() { // runs after setup AND after each restore
cy.getCookie('auth_token').should('exist'); // invalid -> setup re-runs
},
cacheAcrossSpecs: true, // default false; true = reuse in every spec
},
);
});
Critical behaviour: cookies, localStorage, and sessionStorage across all domains are
cleared before setup runs, regardless of testIsolation. Faster still: skip the UI and
log in via cy.request inside setup, persisting the token. Patterns (API login, token
priming, cy.origin for cross-origin SSO): references/network-and-auth.md.
Component vs E2E Testing
Same runner, two testing types. E2E drives a deployed app through cy.visit.
Component mounts a single component in a real browser via cy.mount — no server, no
navigation, props/events under direct control.
| E2E | Component | |
|---|---|---|
| Entry | cy.visit('/path') |
cy.mount(<Comp/>) |
| Needs running app server | Yes | No (bundler dev server only) |
| Spec location | cypress/e2e/**/*.cy.ts |
beside the component / cypress/component/ |
| Support file | cypress/support/e2e.ts |
cypress/support/component.ts (registers cy.mount) |
| Best for | User flows, integration, auth | Props/events/slots, edge states, visual |
// cypress/support/component.ts (React example)
import { mount } from 'cypress/react';
Cypress.Commands.add('mount', mount);
// Button.cy.tsx
cy.mount(<Button label="Save" onClick={cy.stub().as('onClick')} />);
cy.get('[data-test=button]').click();
cy.get('@onClick').should('have.been.calledOnce');
Frameworks: React 18–19, Vue 3, Angular 18–21, Svelte 5. Bundlers: Vite 5–8 (React/Vue/
Svelte) or webpack 5 (all + Next.js). Configured under component.devServer.{framework,bundler}.
Mounting per framework, store/router mocking, slots: references/component-testing.md.
Test Isolation, Fixtures, Custom Commands
testIsolation: true(default, E2E) clears cookies/storage and resets toabout:blankbefore each test. Each test must pass run in isolation (it.onlyto verify) — never rely on a previous test's state. Reset server-side state inbeforeEach, notafterEach(anafterhook may not run if you refresh mid-test).- Multiple assertions per test are fine — don't split into one-assertion tests; state reset between tests costs more than extra assertions.
- Fixtures are static JSON in
cypress/fixtures/, loaded bycy.fixture('users.json')or referenced directly incy.intercept(..., { fixture: 'users.json' }). - Custom commands (
Cypress.Commands.add) live incypress/support/commands.ts; add thecypress/react(etc.) types and adeclare globalblock for TS autocomplete.
CI
# GitHub Actions — the official cypress-io/github-action handles install + cache + run
- uses: actions/checkout@v5
- uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm start # boots app, waits on baseUrl before running
wait-on: 'http://localhost:3000'
browser: chrome
record: true # upload to Cypress Cloud (Test Replay)
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
| Decision | Guidance |
|---|---|
| Start the app | Start it before Cypress (start + wait-on), kill after — never cy.exec a server mid-test |
| Parallelism | cypress run --record --parallel splits specs across machines — requires Cypress Cloud (paid). Free alternative: shard specs manually across matrix jobs with --spec |
| Retries | Config retries: { runMode: 2, openMode: 0 } — surface flakes as a queue, don't paper over them |
| Debugging CI failures | Test Replay (v13+, Chromium-only) over video: captures DOM, network, console, errors for time-travel debugging in Cloud |
Full workflows (matrix sharding, containers, artifact upload): references/ci-and-flake.md.
Flake Diagnosis
Most Cypress flake traces to one of: an action chained where a query/assertion belonged, a
missing aliased cy.wait, conditional logic on un-settled DOM, or leaked state between tests.
| Symptom | Likely cause | Fix |
|---|---|---|
| "element detached from DOM" | re-render between query and action | split the chain; let the action's leading query retry |
| passes alone, fails in suite | inter-test state coupling | reset server state in beforeEach; it.only to confirm |
cy.wait(number) "fixes" it |
racing the network | replace with cy.intercept(...).as() + cy.wait('@alias') |
value read with .then() is stale |
.then doesn't retry |
move the assertion into .should(cb) |
Diagnosis tooling (Test Replay, cypress run --headed, time-travel in the App, screenshots/
video), retry config, and a systematic playbook: references/ci-and-flake.md.
Cypress vs Playwright (one-table decision)
| Factor | Cypress | Playwright |
|---|---|---|
| Execution model | In-browser, async command queue (no await) |
Out-of-process, real async/await |
| Browsers | Chrome-family, Firefox, Electron; WebKit experimental | Chromium, Firefox, WebKit (real Safari) |
| Parallelism | Cypress Cloud (paid) or manual sharding | Free, built-in, shardable |
| Multi-tab / multi-origin | Constrained (cy.origin for cross-origin) |
Native |
| Component testing | Mature, first-class | Experimental |
| Interactive DX | The original benchmark (Cypress App, time-travel) | UI mode (excellent) |
| API testing | cy.request / cy.intercept |
Built-in request context |
Reach for Cypress when component-testing maturity, an existing Cypress investment, or its
in-browser DX dominate. Default to Playwright for new E2E needing WebKit, free parallelism,
or heavy multi-tab/multi-origin work. Sibling skill: playwright-ops.
Config Skeleton
Full commented production template: assets/cypress.config.template.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000', // cy.visit('/path') resolves against this
specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}',
retries: { runMode: 2, openMode: 0 }, // retry in CI only
setupNodeEvents(on, config) { return config; },
},
component: {
devServer: { framework: 'react', bundler: 'vite' },
},
// testIsolation defaults true; viewportWidth/Height, defaultCommandTimeout tunable here
});
References
| File | Contents |
|---|---|
| references/network-and-auth.md | cy.intercept matching/modifying/GraphQL, cy.session deep dive, API login, cy.origin, seed-via-request |
| references/component-testing.md | Per-framework cy.mount, store/router/context mocking, slots/events, Vite vs webpack config |
| references/ci-and-flake.md | Full GH Actions workflows, sharding, Test Replay, retry config, systematic flake playbook |
| assets/cypress.config.template.ts | Commented production config template (E2E + component) |
Files (claude-mods)
-
assets
-
cypress.config.template.ts 3.4 KB
/** * Production Cypress config template (E2E + Component). * * Copy to cypress.config.ts at the repo root and adjust the marked sections. * Conventions baked in: * - baseUrl set so specs use cy.visit('/relative') and relative cy.request * - retries only in runMode (CI) — feel flakes immediately in openMode (local) * - testIsolation left at its default (true) — each test starts from a clean slate * - Test Replay assumed for CI debugging, so video is off (saves CI time) * - one shared test-selector convention: [data-test=...] */ import { defineConfig } from 'cypress'; export default defineConfig({ // Project-wide defaults (apply to both e2e and component unless overridden) ---------- // Standard 1280x720; bump for wide-layout apps. viewportWidth: 1280, viewportHeight: 720, // Default 4000ms retry budget for queries/assertions. Raise only for genuinely slow // apps — a high global timeout masks real perf problems and slows failure feedback. defaultCommandTimeout: 4000, // Test Replay (v13+, Cloud, Chromium-only) is the better CI debugging artefact than // video and captures DOM/network/console. Turn video off when recording to Cloud. video: false, screenshotOnRunFailure: true, // Retries are flake telemetry, not a fix: a retried-then-passed test shows as "flaky". // 0 locally so you feel flakes the instant they appear; up to 2 in CI to keep PRs green // while you triage the flaky queue. retries: { runMode: 2, // cypress run (CI) openMode: 0, // cypress open (local) }, // Fill from CI secrets via CYPRESS_RECORD_KEY env var; never hard-code it here. // projectId: 'abc123', // set when recording to Cypress Cloud e2e: { // cy.visit('/login') and relative cy.request resolve against this. Override per // environment with the CYPRESS_BASE_URL env var. baseUrl: 'http://localhost:3000', specPattern: 'cypress/e2e/**/*.cy.{ts,tsx,js,jsx}', supportFile: 'cypress/support/e2e.ts', // testIsolation: true is the default — cookies/storage cleared and page reset to // about:blank before each test. Leave it on; reset SERVER state in beforeEach. // testIsolation: true, setupNodeEvents(on, config) { // Register Node-side plugins/tasks here, e.g. DB reset tasks, code coverage, // or env-specific config. Return config if you mutate it. // // on('task', { resetDb() { /* ... */ return null; } }); return config; }, }, component: { // framework: which UI library; bundler: 'vite' or 'webpack'. Cypress infers the rest // of the dev-server wiring. See references/component-testing.md for the support matrix. devServer: { framework: 'react', // 'react' | 'vue' | 'angular' | 'svelte' | 'next' | 'nuxt' bundler: 'vite', // 'vite' | 'webpack' }, // Co-locate component specs with the components, or point at cypress/component/. specPattern: 'src/**/*.cy.{ts,tsx,js,jsx}', supportFile: 'cypress/support/component.ts', // must register cy.mount (see refs) }, }); // Notes: // - Add cypress/screenshots/, cypress/videos/, and cypress/downloads/ to .gitignore. // - For a multi-server app, start each server (app + api) before `cypress run` and use // wait-on; never start servers inside a test via cy.exec/cy.task. // - The default selector convention here is [data-test=...] — wrap it in a getBySel // custom command (see SKILL.md Selector Strategy) and enforce data-test on the frontend.
-
-
references
-
ci-and-flake.md 5.3 KB
# CI & Flake Hunting ## GitHub Actions — the official action `cypress-io/github-action` wraps install, dependency caching, app boot, and the run. It is the path of least resistance. ```yaml name: e2e on: [push, pull_request] jobs: cypress: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: cypress-io/github-action@v6 with: build: npm run build start: npm start # boots the app wait-on: 'http://localhost:3000' # polls until the app answers — no `sleep` wait-on-timeout: 120 browser: chrome env: CYPRESS_BASE_URL: http://localhost:3000 ``` Key point: **start the app outside the test run** (`start` + `wait-on`), never `cy.exec` a server inside a test. Port conflicts and lost stdout follow from in-test servers. ## Recording to Cypress Cloud (Test Replay) ```yaml - uses: cypress-io/github-action@v6 with: start: npm start wait-on: 'http://localhost:3000' record: true env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` **Test Replay** (Cypress v13+) replaces video as the CI debugging artefact. It captures DOM mutations, network requests, JS errors, console logs, CSS, SVG, iframes, and shadow DOM — then lets you time-travel through the failed run in Cloud. Caveats: **Chromium-family browsers only** (Chrome, Edge, Electron — not Firefox/WebKit), and it does **not** capture video/audio elements, websockets, `localStorage`/cookies, or `cy.request` traffic. With Test Replay on, disable video (`video: false`) to save CI time. ## Parallelism ```yaml cypress: strategy: fail-fast: false matrix: containers: [1, 2, 3, 4] # 4 machines steps: - uses: cypress-io/github-action@v6 with: record: true parallel: true # Cloud balances specs across the 4 group: 'e2e' env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} ``` `--parallel` **requires Cypress Cloud** (paid) — Cloud does the spec balancing. Without Cloud, shard manually by globbing distinct spec sets per matrix job: ```yaml with: spec: cypress/e2e/group-${{ matrix.shard }}/**/*.cy.ts ``` This is cruder (no load balancing, you partition by hand) but free. ## Container image ```yaml container: image: cypress/browsers:node-22.11.0-chrome-131-ff-133 ``` `cypress/browsers` and `cypress/included` images pin browser + OS — the right choice when screenshot/visual stability matters or to avoid installing system deps each run. ## Retry configuration ```ts // cypress.config.ts export default defineConfig({ retries: { runMode: 2, // cypress run (CI): retry a failing test up to 2x openMode: 0, // cypress open (local): never retry — feel flakes immediately }, }); ``` Retries are **flake telemetry, not a cure**. A test that only passes on retry is a bug in the queue — Cypress flags it as flaky. Treat the flaky list as work, not noise. --- ## Flake playbook Most Cypress flake reduces to four root causes. Diagnose in this order. ### 1. Action chained where a query/assertion belonged ```ts // FLAKY — re-render between .find and .click detaches the element cy.get('[data-test=row]').find('[data-test=edit]').click().should('be.disabled'); // STABLE — split so the action's leading query retries; assert separately cy.get('[data-test=row]').find('[data-test=edit]').click(); cy.get('[data-test=edit]').should('be.disabled'); ``` "Element is detached from the DOM" almost always means this. ### 2. Racing the network with a numeric wait ```ts cy.wait(2000); // FLAKY — guesses at timing // → cy.intercept('GET', '/api/data').as('getData'); // STABLE — wait on the actual request cy.get('[data-test=load]').click(); cy.wait('@getData'); ``` ### 3. Stale value captured with `.then()` ```ts // FLAKY — .then doesn't retry; $count snapshot may be pre-update cy.get('[data-test=count]').then(($count) => { expect($count.text()).to.eq('5'); }); // → cy.get('[data-test=count]').should('have.text', '5'); // STABLE — .should retries ``` ### 4. Inter-test state leakage A test that passes alone but fails in the suite is coupled to another test's state. - Verify with `it.only` — does it pass in isolation? If yes, it's coupling. - Reset **server-side** state in `beforeEach` (not `afterEach` — an `after` hook may be skipped if the run is interrupted or refreshed mid-test). - `testIsolation: true` (default) already clears browser state per test; don't disable it to "fix" a leak — that hides the real coupling. ### Diagnosis tooling | Tool | How | Use | |------|-----|-----| | Test Replay | `cypress run --record` → Cloud | Post-mortem a CI failure: DOM/network/console time-travel | | Cypress App | `cypress open` | Local time-travel: hover each command to see DOM snapshot | | Headed CI repro | `cypress run --headed --no-exit` | Watch the failing run locally | | Repeat to surface | `cypress run --spec <flaky> --env repeat=20` (loop in script) | Force intermittent flake to reproduce | | Screenshots | automatic on failure in `cypress/screenshots/` | Quick "what did the page look like" | -
component-testing.md 3.9 KB
# Component Testing Cypress Component Testing mounts a single component in a **real browser** (not jsdom) via a bundler dev server — no app server, no navigation. You get the same time-travel debugging, real CSS, and DevTools as E2E, scoped to one component. ## Supported matrix | Framework | Versions | Bundlers | |-----------|----------|----------| | React | 18–19 | Vite 5–8, webpack 5 | | Vue | 3 | Vite 5–8, webpack 5 | | Angular | 18–21 | webpack 5 | | Svelte | 5 | Vite 5–8, webpack 5 | | Next.js | 14–16 | webpack 5 | Configure under `component.devServer` — Cypress infers most of the dev-server wiring: ```ts // cypress.config.ts import { defineConfig } from 'cypress'; export default defineConfig({ component: { devServer: { framework: 'react', // 'react' | 'vue' | 'angular' | 'svelte' | 'next' | 'nuxt' | ... bundler: 'vite', // 'vite' | 'webpack' }, specPattern: 'src/**/*.cy.{ts,tsx,js,jsx}', }, }); ``` ## Registering cy.mount `cy.mount` is **not** built in — register it once in the component support file so every spec gets it (and so types resolve): ```ts // cypress/support/component.ts (React) import { mount } from 'cypress/react'; import './commands'; Cypress.Commands.add('mount', mount); declare global { namespace Cypress { interface Chainable { mount: typeof mount; } } } ``` Swap the import per framework: `cypress/react`, `cypress/vue`, `cypress/angular`, `cypress/svelte`. ## Mounting per framework ```tsx // React — JSX, props inline cy.mount(<Stepper initial={5} onChange={cy.stub().as('onChange')} />); // Vue 3 — props/slots via options object cy.mount(Stepper, { props: { count: 100 }, slots: { default: 'Label text' }, }); // Angular — component class + config object cy.mount(StepperComponent, { componentProperties: { count: 100 }, }); // Svelte 5 cy.mount(Stepper, { props: { count: 100 } }); ``` ## Asserting props, events, slots Spy on callbacks with `cy.stub().as(...)`, drive the component through the DOM, assert the spy fired: ```tsx it('emits on increment', () => { cy.mount(<Stepper initial={0} onChange={cy.stub().as('onChange')} />); cy.get('[data-test=increment]').click(); cy.get('[data-test=count]').should('have.text', '1'); cy.get('@onChange').should('have.been.calledWith', 1); }); ``` ## Mocking stores / router / context Components that consume a store, router, or context need a provider wrapper at mount. Compose it in a local helper so specs stay clean: ```tsx // React — wrap in providers function mountWithProviders(ui: React.ReactNode, { route = '/' } = {}) { window.history.pushState({}, '', route); return cy.mount( <MemoryRouter initialEntries={[route]}> <QueryClientProvider client={new QueryClient()}>{ui}</QueryClientProvider> </MemoryRouter>, ); } ``` ```ts // Vue — global plugins/stubs cy.mount(UserCard, { global: { plugins: [createTestingPinia()], stubs: { RouterLink: true }, }, }); ``` ## Network in component tests `cy.intercept` works in component tests exactly as in E2E — stub the component's data fetches: ```tsx cy.intercept('GET', '/api/user/1', { fixture: 'user.json' }).as('getUser'); cy.mount(<UserProfile id={1} />); cy.wait('@getUser'); cy.get('[data-test=name]').should('have.text', 'Alice'); ``` ## When component vs E2E | Test it as a **component** when… | Test it **E2E** when… | |----------------------------------|------------------------| | Verifying props/events/slots in isolation | Verifying a multi-page user flow | | Exercising many edge states (loading/error/empty) cheaply | Auth, routing, real backend integration | | Visual states of one component | Cross-component / cross-page behaviour | | No server or navigation required | The app must actually be running | A healthy suite uses **component tests for breadth** (many states, fast) and **E2E for the critical user journeys** — not E2E for everything. -
network-and-auth.md 6.1 KB
# Network Stubbing & Authentication Deep dive on `cy.intercept` and `cy.session`. The SKILL.md body covers the 80% path; this file is the rest. ## cy.intercept — matching ```ts // String shorthand: method + url glob cy.intercept('GET', '/api/users'); cy.intercept('POST', '/api/users', { statusCode: 201 }); // routeMatcher object — match on more than method+url cy.intercept({ method: 'GET', url: '/api/orders/*', query: { status: 'open' }, // match only when ?status=open headers: { 'x-tenant': 'acme' }, }); // Regex urls cy.intercept(/\/api\/orders\/\d+$/); ``` Glob (`*` one segment, `**` many) is the default for string URLs; pass a `RegExp` for precise control. A later `cy.intercept` for the same route **overrides** an earlier one within the same test — define the most specific matcher first if both could match. ## cy.intercept — stubbing variants ```ts // Fixture file (cypress/fixtures/users.json) cy.intercept('GET', '/api/users', { fixture: 'users.json' }); // Inline body + status + headers cy.intercept('GET', '/api/users', { statusCode: 200, body: [{ id: 1, name: 'Alice' }], headers: { 'cache-control': 'no-store' }, }); // Force network failure / latency / offline cy.intercept('GET', '/api/slow', { forceNetworkError: true }); cy.intercept('GET', '/api/slow', (req) => { req.reply({ delay: 2000, body: {} }); }); ``` ## cy.intercept — spying & modifying (the function form) The function form gives the request (`req`) for inspection/mutation; `req.reply` / `req.continue` control the response. ```ts // Spy only (no stub) — let it hit the server, just observe cy.intercept('POST', '/api/cart').as('addToCart'); cy.get('[data-test=add]').click(); cy.wait('@addToCart').then(({ request, response }) => { expect(request.body).to.deep.equal({ sku: 'ABC', qty: 1 }); expect(response?.statusCode).to.eq(200); }); // Mutate the outgoing request cy.intercept('GET', '/api/me', (req) => { req.headers['authorization'] = 'Bearer test-token'; req.continue(); // pass through to the real server }); // Mutate the real response before it reaches the app cy.intercept('GET', '/api/feed', (req) => { req.reply((res) => { res.body.items = res.body.items.slice(0, 1); // truncate for a deterministic test }); }); // Conditionally stub vs passthrough cy.intercept('GET', '/api/flags', (req) => { if (req.query.exp === 'B') req.reply({ body: { variant: 'B' } }); else req.continue(); }); ``` ## GraphQL (single endpoint, many operations) GraphQL POSTs everything to one URL, so match on the **operation name** in the body: ```ts const hasOperation = (req, name) => req.body?.operationName === name; cy.intercept('POST', '/graphql', (req) => { if (hasOperation(req, 'GetUser')) { req.reply({ fixture: 'gql/getUser.json' }); } if (hasOperation(req, 'ListOrders')) { req.alias = 'gqlListOrders'; // dynamic alias per operation req.reply({ fixture: 'gql/listOrders.json' }); } }); cy.wait('@gqlListOrders'); ``` ## Waiting on multiple / counted requests ```ts cy.wait(['@getUsers', '@getOrders']); // both must fire // Nth occurrence of a repeated request cy.wait('@getUsers'); // 1st cy.wait('@getUsers'); // 2nd ``` --- ## cy.session — full mechanics ```ts cy.session(id, setup); cy.session(id, setup, options); ``` | Param / option | Behaviour | |----------------|-----------| | `id` | String / Array / Object cache key. Arrays & objects are deterministically stringified. Same id → same cached session | | `setup` | Runs **only on cache miss** (or when `validate` fails). Establishes the session (login) | | `validate()` | Runs after setup **and** after every restore. Throw / failing assertion → session invalid. After-restore failure re-runs `setup`; after-setup failure fails the test | | `cacheAcrossSpecs` | `false` (default) = session lives for the spec. `true` = global, restorable in any spec in the run | **Always-cleared invariant:** cookies, `localStorage`, and `sessionStorage` in **all domains** are cleared before `setup` runs, *regardless of `testIsolation`*. So `setup` starts from a clean slate every cache miss. **Assert a logged-in signal inside `setup` before it returns** — otherwise Cypress caches a half-authenticated state and every restore is broken. ## Faster auth: skip the UI UI login is slow and re-tests the login form on every session. Prefer a programmatic login in `setup`: ```ts Cypress.Commands.add('loginByApi', (username, password) => { cy.session([username, password], () => { cy.request('POST', '/api/login', { username, password }).then(({ body }) => { window.localStorage.setItem('auth_token', body.token); // or set a cookie }); }, { validate() { cy.window().its('localStorage.auth_token').should('exist'); }, cacheAcrossSpecs: true, }); }); ``` Keep **one** UI-driven login test that exercises the real form; everything else uses the API path. ## Cross-origin: cy.origin Cypress confines a test to one superdomain. To interact with another origin (SSO provider, OAuth consent screen on a domain you control), wrap those steps in `cy.origin`: ```ts cy.origin('https://auth.example.com', () => { cy.get('[data-test=email]').type('user@example.com'); cy.get('[data-test=password]').type('secret'); cy.get('[data-test=submit]').click(); }); ``` Caveats: variables must be passed in via the `args` option (the callback runs in a separate context — no closure access); `data-test` selectors and commands work, but custom commands need re-registering inside or via `Cypress.require`. Avoid testing third-party social-login UIs you don't control (captchas, A/B tests, throttling, bans) — stub them or use `cy.request` against the provider's API instead. ## Seed-via-request, assert-via-UI The fastest way to set up state: create it through the API, verify through the UI. ```ts beforeEach(() => { cy.request('POST', '/api/test/reset'); // reset server state cy.request('POST', '/api/projects', { name: 'Apollo' }); // seed }); it('shows the seeded project', () => { cy.visit('/projects'); cy.contains('[data-test=project]', 'Apollo').should('be.visible'); }); ```
-
-
scripts
-
.gitkeep 0 B · in bundle
-
-
SKILL.md 15.1 KB
--- name: cypress-ops description: "Cypress end-to-end and component testing operations - selector/retry-ability strategy, cy.intercept network stubbing, cy.session auth, component vs e2e, flake diagnosis, CI, Test Replay. Use for: cypress, e2e test, component test, cy.get, cy.intercept, cy.session, data-cy, data-test, retry-ability, flake, flaky test, cypress.config, cy.mount, Test Replay, custom commands, fixtures." when_to_use: "Use when writing or fixing Cypress e2e/component tests — e.g. 'pick stable selectors and data-cy', 'stub network with cy.intercept', 'share login with cy.session', 'diagnose a flaky retry-ability test'. For Playwright or other runners, use playwright-ops / testing-ops." license: MIT allowed-tools: "Read Write Bash" metadata: author: claude-mods related-skills: "playwright-ops, testing-ops, ci-cd-ops" --- # Cypress Operations > Facts verified as of 2026-07. **Version context (verified against docs.cypress.io, 2026-06):** Cypress 14.x, Test Replay (v13+), `cy.session` with `cacheAcrossSpecs`. APIs move — confirm against the live docs when a detail is load-bearing. End-to-end and component testing with Cypress (`cypress`, TS/JS). The runner executes tests *inside* a real browser via the **Cypress App** (`cypress open`) or headlessly (`cypress run`). The defining mental model: **`cy.*` commands are not promises** — they enqueue onto an async command chain that Cypress drains for you. Internalise that and the agentic gotchas below disappear. ## Quick Start ```bash npm install -D cypress npx cypress open # launch the Cypress App: pick E2E or Component, real browser npx cypress run # headless run, all specs (CI default) npx cypress run --spec "cypress/e2e/auth/*.cy.ts" npx cypress run --component # run component specs npx cypress run --browser chrome --headed npx cypress run --record --key <k> # upload to Cypress Cloud (enables Test Replay, v13+) ``` Specs live in `cypress/e2e/**/*.cy.ts` (E2E) and beside components or `cypress/component/` (component). Config is a single `cypress.config.ts` at the repo root. ## The Async Command Queue (read this first) `cy.get(...)` returns a **Chainer**, not the element and not a Promise. Commands are *scheduled*, then run in order after the test function returns. This is the source of nearly every Cypress mistake an agent makes. ```ts // WRONG — cy.get does not return a value; `el` is a Chainer, this is meaningless const el = cy.get('[data-test=total]'); if (el.text() === '$0') { /* never works */ } // WRONG — async/await does nothing useful; cy commands aren't awaitable promises const text = await cy.get('[data-test=total]'); // do NOT do this // RIGHT — yield the value into a callback; assertions inside .should() retry cy.get('[data-test=total]').should('have.text', '$0'); // RIGHT — need the raw value? use .then() (but it does NOT retry — see below) cy.get('[data-test=total]').invoke('text').then((text) => { // text is a string here; runs after the queue reaches this point }); ``` Rules that follow from this: - **No `const`/`let` to "store" a command result.** Use `.as()` aliases + `cy.get('@alias')`. - **No `async/await` on `cy.*`.** The queue handles ordering. Mixing in real promises? wrap them with `cy.then(() => promise)` or `cy.wrap(promise)`. - **No `if/else` on element state read synchronously.** Conditional testing is an anti-pattern in Cypress (the DOM may not have settled); make the app deterministic, or drive the branch off a server/`cy.intercept` state you control. Deep dive: [references/network-and-auth.md](references/network-and-auth.md). ## Retry-ability (why you almost never need waits) Cypress retries **queries** and **assertions** until they pass or the command times out (default 4s). It does **not** retry **actions** (`.click()`, `.type()`, `.select()`) — those fire once, though the queries *leading up to* them retry until the element is actionable (visible, not disabled, not animating). | Construct | Retries? | Use for | |-----------|----------|---------| | `cy.get` / `.find` / `.contains` / `.its` / `.invoke` (queries) | Yes — whole chain re-queries | Locating/reading DOM that may not be ready | | `.should(...)` / `expect` inside it | Yes — the callback re-runs | Assertions; conditional waits on settled state | | `.click` / `.type` / `.select` (actions) | No — fire once | Interactions (leading queries still retry) | | `.then(cb)` | **No** — runs once, no retry protection | Extracting a value; NOT for assertions | ```ts // .should(callback) retries the whole callback — safe for racy DOM cy.get('[data-test=rows] li').should(($li) => { expect($li).to.have.length(3); expect($li.first()).to.contain('Alice'); }); // .then() does NOT retry — capturing $el here then asserting later races the render ``` If you reach for `cy.wait(3000)`, you're missing an assertion or an aliased intercept. The only legitimate `cy.wait` takes an **alias** (`cy.wait('@getUsers')`), never a number. ## Selector Strategy **Prefer a dedicated test attribute over CSS classes, IDs, or tag names** — the latter are brittle and change with styling/refactors. Cypress recommends `data-cy` **or** `data-test` (the Cypress Real World App standardises on **`data-test`**); pick one and enforce it. ```ts // GOOD — decoupled from styling and structure cy.get('[data-test=submit]').click(); // AVOID — couples the test to CSS/markup that changes for non-test reasons cy.get('.btn-primary').click(); cy.get('#submit').click(); ``` Wrap the convention in a custom command so specs stay terse: ```ts // cypress/support/commands.ts Cypress.Commands.add('getBySel', (sel, ...args) => cy.get(`[data-test=${sel}]`, ...args)); Cypress.Commands.add('getBySelLike', (sel, ...args) => cy.get(`[data-test*=${sel}]`, ...args)); // substring match // usage: cy.getBySel('submit').click(); ``` Reserve `cy.contains('Log In')` for when the **visible text itself** is what you're asserting; otherwise it couples tests to copy. ## Network Stubbing — `cy.intercept` `cy.intercept` is the single API for spying on and stubbing network traffic. **Set it up before the action that triggers the request**, alias it, then wait on the alias. ```ts // Stub with a fixture, alias, wait cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers'); cy.visit('/users'); cy.wait('@getUsers'); // resolves when the request fires // Inline body / status cy.intercept('POST', '/api/login', { statusCode: 401, body: { error: 'nope' } }).as('login'); // routeMatcher object (method + glob/regex url) + dynamic reply cy.intercept({ method: 'GET', url: '/api/orders/*' }, (req) => { req.reply((res) => { res.body.hasMore = false; }); // tweak the real response }).as('orders'); // Assert against the captured request/response cy.wait('@login').its('response.statusCode').should('eq', 401); // Wait on several at once cy.wait(['@getUsers', '@orders']); ``` **Stub what you don't own, exercise what you do.** Stubbing third-party/slow endpoints makes tests fast and deterministic; hitting your real backend (seeded via `cy.request`) verifies the client↔server contract. Decide per endpoint. GraphQL, request modification, and seed-via-`cy.request` patterns: [references/network-and-auth.md](references/network-and-auth.md). ## Authentication — `cy.session` Log in **once**, cache the session, restore it across tests (and optionally specs). This is the biggest suite-speed win after stubbing. ```ts // cypress/support/commands.ts Cypress.Commands.add('login', (username: string, password: string) => { cy.session( [username, password], // cache key — array/object is stringified () => { // setup: runs only on cache miss cy.visit('/login'); cy.get('[data-test=name]').type(username); cy.get('[data-test=password]').type(password); cy.get('form').contains('Log In').click(); cy.url().should('contain', '/dashboard'); // assert logged-in before caching! }, { validate() { // runs after setup AND after each restore cy.getCookie('auth_token').should('exist'); // invalid -> setup re-runs }, cacheAcrossSpecs: true, // default false; true = reuse in every spec }, ); }); ``` Critical behaviour: **cookies, `localStorage`, and `sessionStorage` across all domains are cleared before `setup` runs, regardless of `testIsolation`.** Faster still: skip the UI and log in via `cy.request` inside `setup`, persisting the token. Patterns (API login, token priming, `cy.origin` for cross-origin SSO): [references/network-and-auth.md](references/network-and-auth.md). ## Component vs E2E Testing Same runner, two testing types. **E2E** drives a deployed app through `cy.visit`. **Component** mounts a single component in a real browser via `cy.mount` — no server, no navigation, props/events under direct control. | | E2E | Component | |---|---|---| | Entry | `cy.visit('/path')` | `cy.mount(<Comp/>)` | | Needs running app server | Yes | No (bundler dev server only) | | Spec location | `cypress/e2e/**/*.cy.ts` | beside the component / `cypress/component/` | | Support file | `cypress/support/e2e.ts` | `cypress/support/component.ts` (registers `cy.mount`) | | Best for | User flows, integration, auth | Props/events/slots, edge states, visual | ```ts // cypress/support/component.ts (React example) import { mount } from 'cypress/react'; Cypress.Commands.add('mount', mount); // Button.cy.tsx cy.mount(<Button label="Save" onClick={cy.stub().as('onClick')} />); cy.get('[data-test=button]').click(); cy.get('@onClick').should('have.been.calledOnce'); ``` Frameworks: React 18–19, Vue 3, Angular 18–21, Svelte 5. Bundlers: Vite 5–8 (React/Vue/ Svelte) or webpack 5 (all + Next.js). Configured under `component.devServer.{framework,bundler}`. Mounting per framework, store/router mocking, slots: [references/component-testing.md](references/component-testing.md). ## Test Isolation, Fixtures, Custom Commands - **`testIsolation: true`** (default, E2E) clears cookies/storage and resets to `about:blank` before each test. Each test must pass run **in isolation** (`it.only` to verify) — never rely on a previous test's state. Reset *server-side* state in `beforeEach`, not `afterEach` (an `after` hook may not run if you refresh mid-test). - **Multiple assertions per test are fine** — don't split into one-assertion tests; state reset between tests costs more than extra assertions. - **Fixtures** are static JSON in `cypress/fixtures/`, loaded by `cy.fixture('users.json')` or referenced directly in `cy.intercept(..., { fixture: 'users.json' })`. - **Custom commands** (`Cypress.Commands.add`) live in `cypress/support/commands.ts`; add the `cypress/react` (etc.) types and a `declare global` block for TS autocomplete. ## CI ```yaml # GitHub Actions — the official cypress-io/github-action handles install + cache + run - uses: actions/checkout@v5 - uses: cypress-io/github-action@v6 with: build: npm run build start: npm start # boots app, waits on baseUrl before running wait-on: 'http://localhost:3000' browser: chrome record: true # upload to Cypress Cloud (Test Replay) env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} ``` | Decision | Guidance | |----------|----------| | Start the app | Start it **before** Cypress (`start` + `wait-on`), kill after — never `cy.exec` a server mid-test | | Parallelism | `cypress run --record --parallel` splits specs across machines — **requires Cypress Cloud** (paid). Free alternative: shard specs manually across matrix jobs with `--spec` | | Retries | Config `retries: { runMode: 2, openMode: 0 }` — surface flakes as a queue, don't paper over them | | Debugging CI failures | **Test Replay** (v13+, Chromium-only) over video: captures DOM, network, console, errors for time-travel debugging in Cloud | Full workflows (matrix sharding, containers, artifact upload): [references/ci-and-flake.md](references/ci-and-flake.md). ## Flake Diagnosis Most Cypress flake traces to one of: an action chained where a query/assertion belonged, a missing aliased `cy.wait`, conditional logic on un-settled DOM, or leaked state between tests. | Symptom | Likely cause | Fix | |---------|-------------|-----| | "element detached from DOM" | re-render between query and action | split the chain; let the action's leading query retry | | passes alone, fails in suite | inter-test state coupling | reset server state in `beforeEach`; `it.only` to confirm | | `cy.wait(number)` "fixes" it | racing the network | replace with `cy.intercept(...).as()` + `cy.wait('@alias')` | | value read with `.then()` is stale | `.then` doesn't retry | move the assertion into `.should(cb)` | Diagnosis tooling (Test Replay, `cypress run --headed`, time-travel in the App, screenshots/ video), retry config, and a systematic playbook: [references/ci-and-flake.md](references/ci-and-flake.md). ## Cypress vs Playwright (one-table decision) | Factor | Cypress | Playwright | |--------|---------|-----------| | Execution model | In-browser, async command queue (no `await`) | Out-of-process, real `async/await` | | Browsers | Chrome-family, Firefox, Electron; WebKit experimental | Chromium, Firefox, **WebKit (real Safari)** | | Parallelism | Cypress Cloud (paid) or manual sharding | Free, built-in, shardable | | Multi-tab / multi-origin | Constrained (`cy.origin` for cross-origin) | Native | | Component testing | **Mature, first-class** | Experimental | | Interactive DX | The original benchmark (Cypress App, time-travel) | UI mode (excellent) | | API testing | `cy.request` / `cy.intercept` | Built-in `request` context | Reach for **Cypress** when component-testing maturity, an existing Cypress investment, or its in-browser DX dominate. Default to **Playwright** for new E2E needing WebKit, free parallelism, or heavy multi-tab/multi-origin work. Sibling skill: `playwright-ops`. ## Config Skeleton Full commented production template: [assets/cypress.config.template.ts](assets/cypress.config.template.ts) ```ts import { defineConfig } from 'cypress'; export default defineConfig({ e2e: { baseUrl: 'http://localhost:3000', // cy.visit('/path') resolves against this specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}', retries: { runMode: 2, openMode: 0 }, // retry in CI only setupNodeEvents(on, config) { return config; }, }, component: { devServer: { framework: 'react', bundler: 'vite' }, }, // testIsolation defaults true; viewportWidth/Height, defaultCommandTimeout tunable here }); ``` ## References | File | Contents | |------|----------| | [references/network-and-auth.md](references/network-and-auth.md) | `cy.intercept` matching/modifying/GraphQL, `cy.session` deep dive, API login, `cy.origin`, seed-via-request | | [references/component-testing.md](references/component-testing.md) | Per-framework `cy.mount`, store/router/context mocking, slots/events, Vite vs webpack config | | [references/ci-and-flake.md](references/ci-and-flake.md) | Full GH Actions workflows, sharding, Test Replay, retry config, systematic flake playbook | | [assets/cypress.config.template.ts](assets/cypress.config.template.ts) | Commented production config template (E2E + component) |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.