Skip to content
QA Workflow Assistant

Blog

Cypress End-to-End Testing: A Practical Guide for QA

A practical Cypress end-to-end testing guide for QA teams: architecture, selectors, assertions, network stubbing, flake prevention, CI, and honest tradeoffs versus Playwright.

QA Workflow Assistant10 min read
  • cypress
  • e2e
  • test-automation
  • javascript
  • qa

Cypress is a JavaScript end-to-end runner that many QA and frontend-heavy teams adopt because the local debugging experience is strong and the API encourages readable user-flow tests. This guide focuses on how Cypress actually behaves in CI and how to keep suites maintainable—not on collecting every plugin name.

For strategy context, see the automation testing guide. For a Playwright-oriented counterpart, see the Playwright testing tutorial.

Cypress architecture in plain terms

Cypress runs in the same run-loop as your application in the browser (with a Node process orchestrating). Practically, that means:

  • Commands are queued and executed with automatic retries on many assertions and DOM queries.
  • Time-travel snapshots in the interactive runner help you see each step’s DOM.
  • Network traffic can be stubbed or waited on through cy.intercept.
  • The model historically centered on a single tab inside Chromium-family browsers; multi-tab and multi-origin flows need careful patterns and current Cypress features.

You are not sending WebDriver protocol commands from a remote language binding the way classic Selenium does. That difference explains both Cypress’s excellent “watch the test run” DX and some of its limitations around multiple native windows or certain cross-origin setups.

When Cypress fits—and when it does not

Good fit

  • App under test is a web UI your team already builds with JS/TS tooling
  • Engineers want interactive debugging while authoring
  • Most critical journeys stay in one origin/tab
  • You want first-class network stubbing for front-end contract tests

Weaker fit

  • You must exercise Safari/WebKit parity as a hard gate (evaluate current Cypress browser support for your versions before committing)
  • Flows depend on many native OS dialogs, multiple real windows, or complex desktop-like behavior
  • Your org standardizes on Java/Python automation with an existing Selenium grid and no Node appetite

Neither “Cypress is dead” nor “Cypress always wins” is useful advice. Match the runner to constraints.

Setup

npm install -D cypress
npx cypress open

cypress open scaffolds a project and launches the interactive runner. For CI:

npx cypress run

A minimal cypress.config.js:

const { defineConfig } = require("cypress");
 
module.exports = defineConfig({
  e2e: {
    baseUrl: "http://localhost:3000",
    specPattern: "cypress/e2e/**/*.cy.js",
    supportFile: "cypress/support/e2e.js",
    video: true,
    screenshotOnRunFailure: true,
    retries: {
      runMode: 2,
      openMode: 0,
    },
  },
});

Put secrets and environment URLs in Cypress env config or CI variables—not in committed specs.

Your first test

Create cypress/e2e/login.cy.js:

describe("Login", () => {
  it("valid user reaches dashboard", () => {
    cy.visit("/login");
    cy.get('[data-testid="email"]').type("qa.user@example.com");
    cy.get('[data-testid="password"]').type("CorrectHorse-Battery1");
    cy.get('[data-testid="submit"]').click();
    cy.url().should("include", "/dashboard");
    cy.contains("h1", "Dashboard").should("be.visible");
  });
});

Run headed for authoring (npx cypress open) and headless for pipelines (npx cypress run). Name tests after behavior the same way you title entries in a QA test case template.

Selectors that stay stable

Cypress encourages cy.get with CSS selectors. Stability still depends on what you select.

Prefer:

cy.get('[data-testid="checkout-submit"]');
cy.contains("button", "Place order");
cy.get("nav").contains("Orders");

Avoid:

cy.get("div.container > div:nth-child(3) button");
cy.get(".css-1a2b3c"); // generated class hashes

cy.contains is powerful and dangerous: scope it. Prefer roles and test IDs agreed with frontend. When you introduce page objects or custom commands, keep them thin—see page object model for structure without burying the Cypress command queue in cleverness.

Custom command example for login:

// cypress/support/commands.js
Cypress.Commands.add("loginByUi", (email, password) => {
  cy.visit("/login");
  cy.get('[data-testid="email"]').type(email);
  cy.get('[data-testid="password"]').type(password, { log: false });
  cy.get('[data-testid="submit"]').click();
  cy.url().should("include", "/dashboard");
});

Prefer API login or session caching (cy.session) when UI login is slow or rate-limited, and keep a few UI login tests for the auth surface itself.

Assertions and the command queue

Cypress assertions use Chai-style .should / .and and retry until timeout:

cy.get('[data-testid="toast"]')
  .should("be.visible")
  .and("contain", "Saved");
 
cy.get('[data-testid="submit"]').should("be.disabled");

Mental model that prevents confusion:

  • Cypress commands are asynchronous but chained; you rarely need raw async/await in classic Cypress tests.
  • Do not mix Cypress commands with non-Cypress async code carelessly—race conditions follow.
  • Assertions retry; arbitrary JavaScript you run in .then does not automatically retry unless you structure it correctly.

Timeouts belong in config or per-command options. Raising the global default to hide slow selectors creates long red builds instead of clear failures.

Network stubbing with cy.intercept

cy.intercept lets you observe or stub HTTP calls:

cy.intercept("GET", "/api/orders*", {
  statusCode: 200,
  body: {
    orders: [{ id: "ord_1", totalCents: 2500, status: "open" }],
  },
}).as("listOrders");
 
cy.visit("/orders");
cy.wait("@listOrders");
cy.contains("ord_1").should("be.visible");

Waiting on aliases is preferable to fixed sleeps after navigation.

Stubbing a failure path:

cy.intercept("POST", "/api/checkout", {
  statusCode: 502,
  body: { message: "Upstream unavailable" },
}).as("checkoutFail");
 
cy.visit("/checkout");
cy.get('[data-testid="place-order"]').click();
cy.wait("@checkoutFail");
cy.contains("Try again").should("be.visible");

Real backend vs mocks: choose deliberately

ApproachStrengthRisk
Stubbed UI testsFast, deterministic UI statesCan drift from real API contracts
Real backend E2ETrue integration confidenceData collisions, env instability
MixedStub edge failures; hit real happy pathRequires discipline so stubs do not silently dominate

A healthy pattern: use real environments for a thin smoke set (see smoke testing vs sanity testing), and use stubs for hard-to-reproduce error states and front-end-only regressions. Contract tests or API suites should own payload shape; Cypress stubs should not become the only place “truth” lives.

Flake prevention habits

Cypress retries help, but retries without triage create false confidence. Common Cypress-specific flake sources:

  • Animations and overlapping elements—assert visibility/enabled, or disable animations in test builds
  • Flaky selectors tied to timing of client-side routing
  • Stub/real mix-ups (forgot to remove a stub)
  • Shared users in parallel cypress run against one environment
  • Relying on wall-clock cy.wait(5000) instead of route aliases or DOM conditions

Align with the general flaky tests playbook: isolate data, wait on conditions, keep smoke green, quarantine with visibility.

cy.session example for faster auth reuse:

cy.session(
  email,
  () => {
    cy.visit("/login");
    cy.get('[data-testid="email"]').type(email);
    cy.get('[data-testid="password"]').type(password, { log: false });
    cy.get('[data-testid="submit"]').click();
    cy.url().should("include", "/dashboard");
  },
  {
    validate() {
      cy.request("/api/me").its("status").should("eq", 200);
    },
  }
);

Cypress vs Playwright: tradeoffs, not marketing

TopicCypressPlaywright
Authoring DXExcellent interactive runnerStrong UI mode and trace viewer
LanguagesJS/TS-firstJS/TS plus other official bindings
BrowsersStrong Chromium story; check current Safari/Firefox support for your needsChromium, Firefox, WebKit as first-class targets
Tabs / contextsImproving, historically constrainedMultiple contexts and pages feel natural
Network controlcy.intercept is ergonomicRoute interception + APIRequestContext
Auto-waitingCommand retries and assertionsActionability checks + web-first expect

Choose Cypress when your team’s daily driver is JS and your journeys fit its browser model. Choose Playwright when cross-browser engines, multi-context flows, or mixed API+UI seeding are central. Switching frameworks mid-suite is expensive—fix waits and data first.

CI patterns

A typical job installs dependencies, starts the app, waits for a health URL, then runs cypress run. Artifact videos and screenshots on failure. For pipeline structure ideas, see GitHub Actions for test automation.

Principles that matter:

  • Record video selectively if storage is costly; always keep failure screenshots
  • Use retries.runMode modestly; investigate systemic flake
  • Parallelization (Cypress Cloud or DIY sharding) needs isolated test data
  • Keep PR smoke small; expand nightly

Example fragment:

- name: Cypress run
  uses: cypress-io/github-action@v6
  with:
    build: npm run build
    start: npm run start
    wait-on: "http://localhost:3000"
    browser: chrome

Adjust to your app’s start commands. Pin action versions and Node versions deliberately.

Organizing specs as the suite grows

Early Cypress projects dump everything into one folder. That works until the run exceeds a coffee break. Prefer risk-based grouping:

cypress/e2e/
  smoke/
    login.cy.js
    checkout-happy.cy.js
  regression/
    orders/
    settings/
  edge/
    checkout-errors.cy.js

Tag or folder-filter smoke on pull requests; run regression on schedule. Mirror the same thinking you use for smoke testing vs sanity testing: protect the merge gate with a thin, trusted path.

Keep fixtures under cypress/fixtures/ for static JSON only when the data is truly static. Dynamic uniqueness (emails, order IDs) belongs in the spec or a factory helper so parallel runs do not collide.

const email = `qa+${Date.now()}@example.com`;

Shared mutable users are a leading cause of intermittent auth and permission failures.

Testability partnerships with frontend

Cypress cannot fix an inaccessible, animation-heavy UI by itself. Agree on:

  • Stable data-testid values for critical controls
  • Deterministic test environments (seed scripts, feature-flag defaults)
  • A “reduce motion” or animation-disable flag for test builds when transitions cause flake
  • Documented deep links so tests do not click through five setup screens every time

When a control is an icon-only button, insist on an accessible name or test id. When a flow requires MFA, provide a test bypass in non-prod rather than automating SMS. These are product decisions; capture them as preconditions in your QA test case template so manual and automated runs stay honest.

From Jira story to Cypress spec

A maintainable path looks like this:

  1. Story acceptance criteria are numbered and reviewable
  2. Structured cases cover positive, negative, and edge paths
  3. Automation candidates are tagged (stable UI, high risk, cheap to assert)
  4. Cypress implements the tagged subset with clear names matching case titles
  5. Failures link back to case IDs in reports or PR comments

Skip step 2 and specs become a second, undocumented requirements language. QA Workflow Assistant helps with that drafting step by generating structured test cases from Jira stories; your team still reviews which ones deserve cy.intercept stubs versus a real backend path. Setup guidance lives in Docs.

Example of turning one case into a focused spec title:

  • Case: “Buyer with valid cart completes checkout and sees order confirmation”
  • Spec: it("buyer completes checkout and sees order confirmation", ...)

Avoid packing five acceptance criteria into one it. When the assertion list grows past one user-visible outcome, split the test.

Debugging failures without guessing

Interactive mode (cypress open) is for authoring. In CI, lean on:

  • Failure screenshots (default on)
  • Videos for the failing spec (storage permitting)
  • cy.debug() and .pause() locally—not left in merged code
  • Command logs: which query retried until timeout?

Ask a fixed triage sequence: Did the route return the expected status? Was the element detached? Did a stub still apply from a previous experiment? Was the user data wrong? That sequence maps cleanly onto the flaky tests root-cause categories and keeps “just bump the timeout” from becoming culture.

Limitations to plan around

  • Multi-tab / native window flows may need workarounds or a different tool.
  • Some cross-origin journeys require cy.origin and careful session design—budget learning time.
  • Very large suites without tagging become slow; adopt smoke vs regression split early.
  • Non-JS teams pay a language tax; training cost is real.
  • Over-mocking produces green CI and red production.
  • Heavy reliance on cy.wait(ms) recreates the sleep-driven flake you left Selenium to escape.

None of these are reasons to avoid Cypress outright. They are reasons to scope the suite honestly and invest in testability with frontend partners.

FAQ

Should QA own Cypress or should developers?

Shared ownership works best: QA designs coverage and risk; developers keep selectors and testability healthy. A QA test case template keeps manual and automated intent aligned.

Do we need page objects in Cypress?

Not on day one. Introduce helpers or page modules when duplication hurts. Avoid deep inheritance. See page object model.

How many retries are acceptable?

Enough to absorb rare infra blips; not enough to hide broken selectors. Track retry rate as a quality signal per the flaky tests guidance.

How do stories become Cypress specs?

Draft structured cases from acceptance criteria, mark automation candidates, then implement. QA Workflow Assistant generates structured test cases from Jira stories so the backlog is reviewable before anyone opens cypress open. Product setup is in Docs; plan differences are on Pricing.

Closing

Cypress shines when you write focused journeys, stable selectors, intentional stubs, and a CI job that publishes failure evidence. Keep the suite curated—smoke on every PR, deeper coverage on a schedule—and keep case design sharp. When Jira stories move faster than drafting time, generate structured cases with QA Workflow Assistant, review them, and automate only the paths that deserve a browser.

Stay ahead in QA

Get practical QA guides, Jira & Xray tutorials, testing checklists, AI testing insights, and occasional product updates.

No spam. Unsubscribe anytime.