Introduction
Most Cypress frameworks do not fail because Cypress is a weak tool. They fail because the framework around Cypress was never designed — it accumulated. A team starts with three spec files, adds a fourth, copies a login block into it, hardcodes a URL "just for now," and eighteen months later there are 900 specs, a 40-minute pipeline, a 12% flake rate, and nobody willing to touch commands.js because it is 2,300 lines long and every spec depends on it.
Framework design is the discipline of making that outcome impossible by construction.
Why Framework Design Matters
A test automation framework is production software. It has consumers (engineers writing tests), a public API (custom commands, page objects, fixtures), a deployment pipeline (CI), an SLA (pipeline duration and reliability), and a total cost of ownership. Teams that treat it as "just test code" pay for that mistake in three currencies:
- Trust. A suite that fails randomly is worse than no suite. Engineers begin re-running jobs reflexively, then ignoring red builds, then merging over them. The moment a red build stops blocking a merge, the entire investment is written off.
- Velocity. When a single UI change breaks 60 specs because the selector was duplicated 60 times, the automation team becomes a bottleneck rather than a multiplier.
- Headcount. Poorly architected suites scale linearly in maintenance cost with test count. Well-architected suites scale logarithmically. That difference is the difference between one SDET supporting a team and four SDETs falling behind.
Why Enterprise Teams Need Scalable Frameworks
An enterprise automation framework must satisfy constraints a personal project never encounters:
- Multiple teams, one repository (or many). Twelve squads writing tests against a shared design system need shared abstractions, or they will build twelve incompatible ones.
- Multiple environments. DEV, QA, UAT, STAGING, and a read-only PROD smoke suite, each with different base URLs, credentials, feature flags, and data seeds.
- Compliance. Secrets cannot appear in source control. Test data touching PII must be synthetic. Audit trails of what was tested, when, and against which build are frequently mandatory in regulated industries.
- Release gating. The suite is a gate in a deployment pipeline. Its runtime is a direct tax on lead time; its reliability is a direct input to deployment frequency.
- Turnover. The person who wrote the framework will leave. The structure must teach the next engineer how to use it correctly without a knowledge transfer session.
Why Folder Structure Impacts Maintainability
Folder structure is not cosmetic. It is the physical encoding of your architecture's boundaries. A structure does three things:
-
It makes the correct action the easiest action. If there is an obvious
pageObjects/checkout/directory, an engineer will put checkout selectors there instead of inlining them. - It localizes change. When a folder maps cleanly to a domain, a change to that domain touches one folder. Change radius is the single best predictor of maintenance cost.
- It communicates intent. A newcomer reading a directory tree should be able to describe the architecture before reading a line of code.
Conversely, a flat e2e/ folder with 400 spec files communicates nothing, localizes nothing, and makes every action equally easy — including the wrong ones.
Common Beginner Mistakes
Before the deep dive, here is the pattern of failure repeated across nearly every struggling suite:
- Hardcoded URLs, credentials, and test data scattered through specs.
- Selectors based on generated CSS classes or brittle XPath-style chains.
-
cy.wait(5000)used as a universal synchronization mechanism. - Tests that depend on execution order or on state created by a previous test.
- UI-driven setup: logging in through the login form before every one of 400 tests.
- One giant
commands.jsacting as a dumping ground. - Assertions inside page objects, mixing "how to interact" with "what is correct."
- No environment abstraction — switching from QA to STAGING means editing files.
- Screenshots, videos, and reports committed to version control.
- No CI integration until "the framework is ready," which never arrives.
The Difference Between Beginner and Enterprise Automation
- Beginner automation asks: does this test pass? Enterprise automation asks: when this test fails at 3 a.m., how long does it take an on-call engineer to determine whether the product is broken or the test is?
- Beginner automation optimizes for writing. Enterprise automation optimizes for reading, debugging, and deleting.
- Beginner automation treats flakiness as noise. Enterprise automation treats a flaky test as a defect with a ticket, an owner, and a due date.
- Beginner automation has tests. Enterprise automation has a framework, a contract, a pipeline, an observability story, and a deprecation policy.
Everything that follows is an implementation of that second column.
🎁 MEGA SALE — Flat 90% OFF on ALL 100+ eBooks & Bundles
Use coupon code: JUPITER90
📚 Explore the complete HimanshuAI Digital Playbook Store:
👉 https://himanshuai.gumroad.com/
If you're planning to master Playwright, TypeScript, AI Testing, Salesforce, API Testing, Java, Python, Cypress, Selenium, System Design, DevOps, Cloud Testing, Performance Testing, or Software Test Architecture, this is the best opportunity to build your complete technical library at a fraction of the regular price.
What is Cypress?
Cypress is a JavaScript-based end-to-end testing framework that executes test code inside the same run loop as the application under test. That single architectural decision explains nearly every strength and every limitation it has.
Architecture
Traditional WebDriver-based tools run outside the browser and communicate with it over a wire protocol. Every command is a network round trip, and the test process has no direct visibility into the application's runtime.
Cypress inverts this. It runs a Node.js process (the "Cypress server") that manages the browser, proxies all network traffic, and handles file system and system-level operations. Inside the browser, Cypress injects your spec code into an iframe alongside the application. Test code and application code therefore share the same DOM, the same window, and the same event loop.
Practical consequences:
- Cypress can synchronously read and modify application state, stub
window.fetch, control timers, and inspect the DOM without serialization. - Cypress can intercept and modify network traffic at the proxy layer, enabling deterministic stubbing.
- Cypress commands are asynchronous and enqueued, not executed immediately.
cy.get()returns a chainer, not an element. This is the single most common source of confusion for engineers arriving from Selenium. - Node-level work (file system, databases, secrets) must cross the browser/Node boundary via tasks — hence
cy.task().
The Execution Model
Understanding the command queue is non-negotiable for building a stable framework.
// This does NOT work as written.
it('demonstrates the queue', () => {
let value = 'initial';
cy.get('[data-test="username"]').then(($el) => {
value = $el.val(); // runs later, when the queue reaches it
});
expect(value).to.equal('admin'); // runs NOW — value is still 'initial'
});
Every cy.* call appends a command to a queue. The queue is executed after the test function body finishes running. Assertions using expect outside a .then() therefore evaluate against pre-queue state.
The correct patterns:
// Pattern 1: keep work inside the chain
cy.get('[data-test="username"]')
.invoke('val')
.should('equal', 'admin');
// Pattern 2: use aliases to carry values across the queue
cy.get('[data-test="order-id"]').invoke('text').as('orderId');
cy.get('@orderId').then((orderId) => {
cy.request(`/api/orders/${orderId}`).its('status').should('eq', 200);
});
Cypress also retries assertions. cy.get('...').should('be.visible') re-queries the DOM and re-runs the assertion until it passes or the timeout expires. This built-in retryability is what makes explicit sleeps unnecessary — and what makes their presence a code smell.
Advantages
- Automatic waiting. Commands retry until actionability conditions are met (existence, visibility, non-covered, non-disabled, non-animating).
- Time-travel debugging. The Test Runner snapshots the DOM at each command.
-
First-class network control.
cy.intercept()allows stubbing, spying, delaying, and mutating requests and responses. - No flaky driver layer. No WebDriver binary version mismatches.
- Excellent developer experience. Fast feedback loop, readable errors, integrated debugger.
-
Unified API and UI testing.
cy.request()makes API-driven setup trivial. - Component testing. React, Vue, Angular, and Svelte components can be mounted and tested in a real browser.
Limitations
Be honest about these during architecture reviews:
-
Single browser tab per test. Cypress cannot drive multiple tabs or windows. Popups must be handled by asserting the
targetattribute and visiting the URL directly, or by stubbingwindow.open. -
Single origin per test (with caveats). Cross-origin navigation requires
cy.origin(), which runs code in a separate context with strict serialization rules. - No native browser dialogs beyond stubs. File pickers, print dialogs, and OS-level windows are out of reach.
- No true mobile device testing. Viewport emulation is not device testing.
- JavaScript/TypeScript only. A Java- or C#-centric organization pays a language tax.
- Parallelization requires orchestration. Parallel runs need Cypress Cloud or a third-party orchestrator, or hand-rolled spec sharding.
-
Memory pressure on long runs. Large suites in a single run can exhaust browser memory;
numTestsKeptInMemoryand spec-level isolation matter.
Browser Support
Cypress supports Chrome, Chromium, Edge, Electron (bundled), Firefox, and WebKit (experimental, approximating Safari). A pragmatic enterprise policy:
- Run the full regression suite on Chrome (or Electron in CI for speed and stability).
- Run a targeted cross-browser subset — critical flows, layout-sensitive pages — on Firefox and WebKit nightly.
- Do not run everything on every browser. It multiplies cost and rarely finds proportionate defects.
Best Use Cases
- Modern single-page applications (React, Angular, Vue, Svelte).
- Teams where developers and SDETs share a JavaScript/TypeScript toolchain.
- Suites requiring heavy network stubbing and deterministic states.
- Component and integration testing alongside E2E.
- Fast, developer-facing feedback loops on pull requests.
When NOT to Use Cypress
- Native mobile applications — use Appium, Espresso, or XCUITest.
- Desktop applications — use WinAppDriver or Playwright's Electron support.
- Test scenarios genuinely requiring multiple browser tabs or multiple simultaneous user sessions in the same test.
- Broad cross-browser matrix testing across legacy browsers.
- Load and performance testing — use k6, JMeter, or Gatling. Cypress measures nothing meaningful about throughput.
- Organizations mandating a JVM or .NET-only toolchain for compliance reasons.
Note: "When not to use Cypress" belongs in your architecture decision record. Documenting the boundary prevents the suite from being stretched into scenarios it cannot serve, which is a common root cause of unfixable flakiness.
Enterprise Framework Architecture
The structure below is the reference architecture used throughout the rest of this article. It assumes TypeScript, Cypress 13+, and a monorepo-friendly layout.
cypress-enterprise-framework/
├── cypress/
│ ├── e2e/
│ │ ├── api/
│ │ │ ├── orders/
│ │ │ │ ├── createOrder.api.cy.ts
│ │ │ │ └── orderLifecycle.api.cy.ts
│ │ │ └── auth/
│ │ │ └── tokenIssuance.api.cy.ts
│ │ ├── ui/
│ │ │ ├── authentication/
│ │ │ │ ├── login.cy.ts
│ │ │ │ └── passwordReset.cy.ts
│ │ │ ├── checkout/
│ │ │ │ ├── cart.cy.ts
│ │ │ │ └── payment.cy.ts
│ │ │ └── admin/
│ │ │ └── userManagement.cy.ts
│ │ ├── smoke/
│ │ │ └── criticalPath.smoke.cy.ts
│ │ └── contract/
│ │ └── ordersSchema.contract.cy.ts
│ ├── fixtures/
│ │ ├── common/
│ │ │ └── currencies.json
│ │ ├── users/
│ │ │ ├── users.qa.json
│ │ │ └── users.staging.json
│ │ ├── orders/
│ │ │ └── orderPayload.json
│ │ └── schemas/
│ │ └── order.schema.json
│ ├── support/
│ │ ├── e2e.ts
│ │ ├── component.ts
│ │ ├── commands/
│ │ │ ├── index.ts
│ │ │ ├── auth.commands.ts
│ │ │ ├── api.commands.ts
│ │ │ ├── db.commands.ts
│ │ │ ├── ui.commands.ts
│ │ │ └── assertion.commands.ts
│ │ ├── pageObjects/
│ │ │ ├── BasePage.ts
│ │ │ ├── authentication/
│ │ │ │ └── LoginPage.ts
│ │ │ ├── checkout/
│ │ │ │ ├── CartPage.ts
│ │ │ │ └── PaymentPage.ts
│ │ │ └── components/
│ │ │ ├── HeaderComponent.ts
│ │ │ └── DataGridComponent.ts
│ │ ├── services/
│ │ │ ├── BaseApiService.ts
│ │ │ ├── AuthService.ts
│ │ │ └── OrderService.ts
│ │ ├── utils/
│ │ │ ├── apiHelper.ts
│ │ │ ├── dateUtil.ts
│ │ │ ├── randomGenerator.ts
│ │ │ ├── encryptionUtil.ts
│ │ │ ├── fileHelper.ts
│ │ │ ├── schemaValidator.ts
│ │ │ └── logger.ts
│ │ └── types/
│ │ ├── global.d.ts
│ │ └── models.ts
│ ├── constants/
│ │ ├── endpoints.ts
│ │ ├── messages.ts
│ │ ├── timeouts.ts
│ │ ├── roles.ts
│ │ └── index.ts
│ ├── downloads/
│ ├── screenshots/
│ └── videos/
├── config/
│ ├── cypress.dev.json
│ ├── cypress.qa.json
│ ├── cypress.uat.json
│ ├── cypress.staging.json
│ └── cypress.prod.json
├── plugins/
│ ├── dbPlugin.ts
│ ├── filePlugin.ts
│ └── reportPlugin.ts
├── reports/
│ ├── mochawesome/
│ ├── allure-results/
│ └── junit/
├── scripts/
│ ├── mergeReports.ts
│ ├── cleanArtifacts.ts
│ └── seedTestData.ts
├── docker/
│ ├── Dockerfile
│ └── docker-compose.yml
├── .github/
│ └── workflows/
│ └── e2e.yml
├── cypress.config.ts
├── tsconfig.json
├── .eslintrc.json
├── .prettierrc
├── .env.example
├── package.json
└── README.md
cypress/
The root of the Cypress runtime. Everything Cypress loads at execution time lives here. Configuration, CI definitions, and Node-side scripts deliberately live outside it so that build tooling and runtime concerns do not blur.
-
Best practice: Keep
cypress/free of anything not loaded by the browser or the support file. Node-only scripts belong inscripts/. -
Common mistake: Placing report merging scripts inside
cypress/support/, where they get bundled and break the browser build.
e2e/
Holds all test specs. Organize by domain first, layer second — or layer first if your organization splits API and UI ownership.
Responsibilities:
- One spec = one cohesive feature area.
- Tags or directory placement drive suite selection (
smoke,regression,contract). - Specs contain scenarios and assertions only. No selectors, no data construction, no HTTP plumbing.
Best practices:
- Name specs after business capability, not implementation:
checkoutWithSavedCard.cy.ts, nottest3.cy.ts. - Keep specs under roughly 300 lines. Beyond that, split by scenario family.
- Use a consistent suffix convention so glob patterns are trivial:
*.cy.ts,*.api.cy.ts,*.smoke.cy.ts. - Never share mutable state between specs. Cypress resets browser state between specs by default — rely on it.
Common mistakes:
- A single
regression.cy.tscontaining 200 tests. It cannot be parallelized, and one failure blocks visibility into the rest. - Ordering dependencies:
it('01 - create user')followed byit('02 - edit user'). The second test cannot run alone, cannot be retried independently, and cannot be parallelized.
fixtures/
Static and semi-static test data loaded via cy.fixture().
Responsibilities:
- Canonical request/response payloads.
- Environment-scoped reference data (user handles, not passwords).
- JSON Schema definitions for contract validation.
Best practices:
- Subfolder by domain, never a flat pile of JSON.
- Suffix environment-specific fixtures:
users.qa.json,users.staging.json, and resolve them dynamically. - Keep fixtures small and single-purpose. A 4,000-line fixture is a database, not test data.
- Treat fixtures as immutable inputs. Deep-clone before mutating in a test.
Warning: Never store real passwords, API keys, or production PII in fixtures. Fixtures are committed to source control and are readable by anyone with repository access. Store identifiers in fixtures and secrets in the environment.
support/
The framework's own source code — the layer your specs import. This is the part that must be engineered like a library.
Contains commands, page objects, services, utilities, and type definitions. The e2e.ts support file is the global entry point, loaded before every spec.
// cypress/support/e2e.ts
import './commands';
import 'cypress-real-events';
import { logger } from './utils/logger';
// Fail fast on unexpected application errors, but allow known third-party noise.
Cypress.on('uncaught:exception', (err) => {
const ignorable = [
'ResizeObserver loop limit exceeded',
'ResizeObserver loop completed with undelivered notifications',
];
if (ignorable.some((pattern) => err.message.includes(pattern))) {
return false; // do not fail the test
}
logger.error(`Uncaught application exception: ${err.message}`);
return true; // fail the test — this is a real defect
});
// Structured logging of each test boundary for CI log correlation.
beforeEach(function () {
logger.info(`START :: ${Cypress.spec.relative} :: ${this.currentTest?.title}`);
});
afterEach(function () {
const state = this.currentTest?.state ?? 'unknown';
logger.info(`END :: ${this.currentTest?.title} :: ${state.toUpperCase()}`);
});
-
Best practice: Keep
e2e.tsthin. It wires things together; it does not implement them. -
Common mistake: Adding global
beforeEachhooks that log in every test through the UI. This turns a 200-test suite into 200 redundant login flows.
commands/
Custom Cypress commands, split by concern and re-exported from an index barrel.
-
Purpose: Extend the
cynamespace with domain-meaningful verbs. -
Best practice: One file per concern (
auth,api,db,ui). A command file over 200 lines is a signal that a service class is a better fit. -
Common mistake: Turning every helper into a custom command. Commands are for things that need to participate in the Cypress command queue. Pure functions belong in
utils/.
pageObjects/
Encapsulation of selectors and page interactions. Discussed in depth in the next section.
utils/
Framework-agnostic, mostly synchronous helper modules: date math, random data, encryption, schema validation, logging. These should be unit-testable in isolation with no Cypress dependency where possible.
- Best practice: Prefer pure functions. A pure function is trivially testable and trivially reusable.
-
Common mistake: Utilities that call
cy.*internally and therefore only work inside the command queue, defeating their reusability.
constants/
Every magic string and magic number in one place: endpoints, timeouts, error messages, role names, feature flags.
// cypress/constants/timeouts.ts
export const TIMEOUTS = {
INSTANT: 1_000,
SHORT: 4_000,
DEFAULT: 10_000,
LONG: 30_000,
REPORT_GENERATION: 90_000,
} as const;
// cypress/constants/endpoints.ts
export const ENDPOINTS = {
AUTH: {
TOKEN: '/api/v1/auth/token',
REFRESH: '/api/v1/auth/refresh',
LOGOUT: '/api/v1/auth/logout',
},
ORDERS: {
BASE: '/api/v1/orders',
BY_ID: (id: string) => `/api/v1/orders/${id}`,
CANCEL: (id: string) => `/api/v1/orders/${id}/cancel`,
},
USERS: {
BASE: '/api/v1/users',
BY_ID: (id: string) => `/api/v1/users/${id}`,
},
} as const;
-
Best practice: Use
as constso TypeScript narrows literal types and typos become compile errors. - Common mistake: Constants files that grow into a god-module. Split by domain once past ~150 lines.
config/
Per-environment configuration files, consumed by cypress.config.ts at startup. Non-secret values only.
reports/
Generated output: Mochawesome JSON and HTML, Allure results, JUnit XML. Git-ignored. Created and destroyed by the pipeline.
downloads/
Cypress writes downloaded files here. Cleared before every run so assertions on file existence are meaningful.
screenshots/ and videos/
Failure artifacts. Git-ignored, uploaded to CI artifact storage, retained on a schedule.
plugins/
Node-side extensions. Since Cypress 10, plugins are registered in setupNodeEvents inside cypress.config.ts, but keeping their implementations in a separate plugins/ folder keeps the config file readable.
// plugins/dbPlugin.ts
import { Pool, QueryResult } from 'pg';
let pool: Pool | null = null;
function getPool(connectionString: string): Pool {
if (!pool) {
pool = new Pool({ connectionString, max: 5, idleTimeoutMillis: 10_000 });
}
return pool;
}
export interface DbQueryArgs {
connectionString: string;
sql: string;
params?: unknown[];
}
export async function runQuery({
connectionString,
sql,
params = [],
}: DbQueryArgs): Promise<QueryResult['rows']> {
const client = await getPool(connectionString).connect();
try {
const result = await client.query(sql, params);
return result.rows;
} finally {
client.release();
}
}
export async function closePool(): Promise<null> {
if (pool) {
await pool.end();
pool = null;
}
return null;
}
Recommendation: Node-side plugins are the only place your framework can touch databases, the file system, or secret managers. Treat them as a privileged boundary and keep the surface area small and audited.
Page Object Model (POM)
The Page Object Model exists to answer one question: when the UI changes, how many files must change? A correct POM makes the answer "one."
Architecture
Three layers, strictly separated:
- Page Objects — know where things are and how to interact with them. They expose intent-revealing methods and return chainable values or other page objects.
- Component Objects — reusable UI fragments (headers, modals, data grids, date pickers) shared across pages.
- Specs — know what should happen. They call page objects and make assertions.
The critical rule: page objects contain no assertions about business correctness. They may assert actionability as part of an interaction, but "the order total is $42.00" belongs in the spec, where the reader can see the business rule being verified.
A Production BasePage
// cypress/support/pageObjects/BasePage.ts
import { TIMEOUTS } from '../../constants/timeouts';
export abstract class BasePage {
protected abstract readonly path: string;
protected abstract readonly readyIndicator: string;
visit(options: Partial<Cypress.VisitOptions> = {}): this {
cy.visit(this.path, { ...options });
this.waitUntilReady();
return this;
}
waitUntilReady(timeout: number = TIMEOUTS.DEFAULT): this {
cy.get(this.readyIndicator, { timeout }).should('be.visible');
return this;
}
/**
* Central selector accessor. Every page object queries through this method,
* which means the day the team migrates from data-test to data-testid,
* exactly one line changes.
*/
protected el(testId: string, options: Partial<Cypress.Timeoutable> = {}) {
return cy.get(`[data-test="${testId}"]`, options);
}
protected elWithin(parentTestId: string, childTestId: string) {
return cy.get(`[data-test="${parentTestId}"]`).find(`[data-test="${childTestId}"]`);
}
assertUrlContains(fragment: string): this {
cy.url().should('include', fragment);
return this;
}
}
A Production Page Object
// cypress/support/pageObjects/authentication/LoginPage.ts
import { BasePage } from '../BasePage';
import { MESSAGES } from '../../../constants/messages';
export class LoginPage extends BasePage {
protected readonly path = '/login';
protected readonly readyIndicator = '[data-test="login-form"]';
private readonly selectors = {
username: 'login-username',
password: 'login-password',
submit: 'login-submit',
rememberMe: 'login-remember-me',
errorBanner: 'login-error',
forgotPasswordLink: 'login-forgot-password',
} as const;
enterUsername(username: string): this {
this.el(this.selectors.username).clear().type(username);
return this;
}
enterPassword(password: string): this {
// { log: false } prevents the secret from appearing in the command log,
// screenshots, and video artifacts.
this.el(this.selectors.password).clear().type(password, { log: false });
return this;
}
checkRememberMe(): this {
this.el(this.selectors.rememberMe).check();
return this;
}
submit(): this {
this.el(this.selectors.submit).click();
return this;
}
loginAs(username: string, password: string): this {
return this.enterUsername(username).enterPassword(password).submit();
}
/** Returns a chainable so the SPEC decides what the message should be. */
getErrorMessage(): Cypress.Chainable<string> {
return this.el(this.selectors.errorBanner).invoke('text');
}
goToForgotPassword(): void {
this.el(this.selectors.forgotPasswordLink).click();
}
}
export const loginPage = new LoginPage();
A Reusable Component Object
// cypress/support/pageObjects/components/DataGridComponent.ts
export class DataGridComponent {
constructor(private readonly root: string) {}
private grid() {
return cy.get(`[data-test="${this.root}"]`);
}
rows(): Cypress.Chainable<JQuery<HTMLElement>> {
return this.grid().find('[data-test="grid-row"]');
}
rowCount(): Cypress.Chainable<number> {
return this.rows().its('length');
}
rowByText(text: string): Cypress.Chainable<JQuery<HTMLElement>> {
return this.rows().contains('[data-test="grid-row"]', text);
}
cellValue(rowText: string, column: string): Cypress.Chainable<string> {
return this.rowByText(rowText)
.find(`[data-test="grid-cell-${column}"]`)
.invoke('text')
.then((t) => t.trim());
}
sortBy(column: string, direction: 'asc' | 'desc' = 'asc'): this {
this.grid().find(`[data-test="grid-header-${column}"]`).click();
if (direction === 'desc') {
this.grid().find(`[data-test="grid-header-${column}"]`).click();
}
return this;
}
applyFilter(column: string, value: string): this {
this.grid().find(`[data-test="grid-filter-${column}"]`).clear().type(`${value}{enter}`);
return this;
}
}
Folder Organization and Naming Conventions
- Folders are lowercase domain names:
authentication/,checkout/,admin/. - Page object files are
PascalCaseand suffixedPage:PaymentPage.ts. - Component object files are
PascalCaseand suffixedComponent:HeaderComponent.ts. - Selector keys are kebab-case strings matching the
data-testattribute exactly. - Methods are verbs describing user intent:
submitPayment(), notclickButton3(). - Query methods that return values are prefixed
get:getOrderTotal(). - Navigation methods that return a different page object are prefixed
goTo.
Advantages
- Single point of change for selectors.
- Specs read like documentation.
- Onboarding is faster because the API is discoverable through IntelliSense.
- Enables static analysis: unused page methods surface dead tests.
Disadvantages
- Indirection. A junior engineer debugging a failure must jump between files.
- Risk of over-abstraction, where a method wraps a single
click()for no benefit. - Page objects can drift into god-objects if the underlying page is genuinely huge.
Anti-Patterns
- Assertions inside page objects. Hides the business rule and prevents reuse in negative-path tests.
-
Page objects returning
voideverywhere. Kills fluent chaining and forces verbose specs. - Static-only classes with no state. If nothing is instance-specific, a module of functions is simpler; use classes when inheritance or parameterized roots add value.
- Storing test data in page objects. Data belongs in fixtures or factories.
- One page object per URL, mechanically. Model screens and components, not routes. A wizard with five steps may be one page object with five methods, or five component objects — decided by what changes together.
-
Selectors leaking into specs. The moment a spec contains
cy.get('.btn-primary'), the POM has failed. - Hard waits inside page objects. They silently slow the whole suite.
Real-World Implementation: A Spec Using the POM
// cypress/e2e/ui/authentication/login.cy.ts
import { loginPage } from '../../../support/pageObjects/authentication/LoginPage';
import { MESSAGES } from '../../../constants/messages';
describe('Authentication :: Login', { tags: ['@smoke', '@auth'] }, () => {
beforeEach(() => {
cy.clearAllCookies();
loginPage.visit();
});
it('grants access to a valid standard user', () => {
cy.fixture('users/users').then((users) => {
loginPage.loginAs(users.standard.username, Cypress.env('STANDARD_USER_PASSWORD'));
});
cy.url().should('include', '/dashboard');
cy.get('[data-test="user-menu"]').should('be.visible');
});
it('rejects an invalid password without revealing which field was wrong', () => {
loginPage.loginAs('valid.user@corp.test', 'DefinitelyWrongPassword!1');
loginPage.getErrorMessage().should('eq', MESSAGES.AUTH.INVALID_CREDENTIALS);
cy.url().should('include', '/login');
});
it('locks the account after the configured number of failed attempts', () => {
const attempts = Number(Cypress.env('MAX_LOGIN_ATTEMPTS') ?? 5);
for (let i = 0; i < attempts; i += 1) {
loginPage.loginAs('lockout.user@corp.test', `WrongPassword${i}`);
}
loginPage.getErrorMessage().should('eq', MESSAGES.AUTH.ACCOUNT_LOCKED);
});
});
Notice: zero selectors, zero URLs, zero hardcoded messages. The spec is a readable specification of behaviour.
Custom Commands
Why Custom Commands
Custom commands exist for one reason: to add domain verbs to the Cypress command queue. Use them when the operation must be retryable, chainable, or globally available. Use a plain function or a service class when it does not.
Decision rule:
- Does it need to run inside the command queue and yield a subject? → Custom command.
- Is it used across many specs by many teams? → Custom command.
- Is it a pure transformation of data? → Utility function.
- Is it a group of related HTTP calls? → Service class.
Authentication Commands
The single highest-leverage optimization in any enterprise Cypress suite is not logging in through the UI. Authenticate once via API, cache the session, and restore it.
// cypress/support/commands/auth.commands.ts
import { ENDPOINTS } from '../../constants/endpoints';
import { logger } from '../utils/logger';
interface LoginResult {
accessToken: string;
refreshToken: string;
userId: string;
}
Cypress.Commands.add('loginByApi', (username: string, password: string) => {
return cy
.request({
method: 'POST',
url: `${Cypress.env('apiBaseUrl')}${ENDPOINTS.AUTH.TOKEN}`,
body: { username, password, grantType: 'password' },
failOnStatusCode: false,
log: false,
})
.then((response) => {
expect(response.status, `Auth failed for ${username}`).to.eq(200);
const { accessToken, refreshToken, userId } = response.body as LoginResult;
window.localStorage.setItem('access_token', accessToken);
window.localStorage.setItem('refresh_token', refreshToken);
Cypress.env('currentAccessToken', accessToken);
Cypress.env('currentUserId', userId);
logger.info(`Authenticated ${username} (userId=${userId}) via API`);
return cy.wrap({ accessToken, refreshToken, userId }, { log: false });
});
});
/**
* cy.session caches cookies, localStorage and sessionStorage per key.
* A 400-test suite performs ONE real authentication per unique role.
*/
Cypress.Commands.add('loginAs', (role: 'admin' | 'standard' | 'readonly' | 'finance') => {
const username = Cypress.env(`${role.toUpperCase()}_USERNAME`);
const password = Cypress.env(`${role.toUpperCase()}_PASSWORD`);
if (!username || !password) {
throw new Error(
`Missing credentials for role "${role}". Expected env vars ` +
`${role.toUpperCase()}_USERNAME and ${role.toUpperCase()}_PASSWORD.`,
);
}
cy.session(
['user', role, Cypress.env('environment')],
() => {
cy.loginByApi(username, password);
},
{
cacheAcrossSpecs: true,
validate() {
// If the cached session is stale, Cypress re-runs the setup function.
cy.request({
url: `${Cypress.env('apiBaseUrl')}${ENDPOINTS.USERS.BASE}/me`,
headers: { Authorization: `Bearer ${window.localStorage.getItem('access_token')}` },
failOnStatusCode: false,
log: false,
})
.its('status')
.should('eq', 200);
},
},
);
});
Cypress.Commands.add('logout', () => {
const token = Cypress.env('currentAccessToken');
if (token) {
cy.request({
method: 'POST',
url: `${Cypress.env('apiBaseUrl')}${ENDPOINTS.AUTH.LOGOUT}`,
headers: { Authorization: `Bearer ${token}` },
failOnStatusCode: false,
log: false,
});
}
cy.clearAllLocalStorage();
cy.clearAllSessionStorage();
cy.clearAllCookies();
Cypress.env('currentAccessToken', null);
});
Note:
cy.sessionwithcacheAcrossSpecs: trueis the difference between a 45-minute suite and a 12-minute suite for most enterprise applications. Keep at least one dedicated test that exercises the real UI login form so the login page itself remains covered.
API Helper Commands
// cypress/support/commands/api.commands.ts
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
interface AuthedRequestOptions {
method: HttpMethod;
url: string;
body?: unknown;
headers?: Record<string, string>;
qs?: Record<string, string | number | boolean>;
failOnStatusCode?: boolean;
timeout?: number;
}
Cypress.Commands.add('apiRequest', (options: AuthedRequestOptions) => {
const token = Cypress.env('currentAccessToken');
const base = Cypress.env('apiBaseUrl');
return cy.request({
method: options.method,
url: options.url.startsWith('http') ? options.url : `${base}${options.url}`,
body: options.body,
qs: options.qs,
timeout: options.timeout ?? 30_000,
failOnStatusCode: options.failOnStatusCode ?? false,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Correlation-Id': `cy-${Cypress.spec.name}-${Date.now()}`,
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
});
Cypress.Commands.add('expectStatus', { prevSubject: true }, (response: Cypress.Response<unknown>, expected: number) => {
expect(
response.status,
`Expected ${expected} but got ${response.status}. Body: ${JSON.stringify(response.body).slice(0, 500)}`,
).to.eq(expected);
return cy.wrap(response, { log: false });
});
Database Helper Commands
Database access must cross into Node via cy.task.
// cypress/support/commands/db.commands.ts
Cypress.Commands.add('dbQuery', (sql: string, params: unknown[] = []) => {
return cy.task('db:query', {
connectionString: Cypress.env('DB_CONNECTION_STRING'),
sql,
params,
});
});
Cypress.Commands.add('dbSeedUser', (email: string, role: string) => {
return cy
.dbQuery(
`INSERT INTO users (email, role, status, created_at)
VALUES ($1, $2, 'ACTIVE', NOW())
ON CONFLICT (email) DO UPDATE SET role = EXCLUDED.role
RETURNING id, email, role`,
[email, role],
)
.then((rows: Array<{ id: string }>) => rows[0]);
});
Cypress.Commands.add('dbCleanupTestUsers', () => {
return cy.dbQuery(`DELETE FROM users WHERE email LIKE 'cy-test-%@corp.test'`);
});
Warning: Never point database commands at production. Guard them explicitly. A single line —
if (Cypress.env('environment') === 'prod') throw new Error('DB writes are forbidden in prod');— has saved more careers than any framework feature.
Reusable UI Commands
// cypress/support/commands/ui.commands.ts
import { TIMEOUTS } from '../../constants/timeouts';
Cypress.Commands.add('getByTestId', (testId: string, options = {}) => {
return cy.get(`[data-test="${testId}"]`, options);
});
Cypress.Commands.add('selectFromDropdown', (dropdownTestId: string, optionLabel: string) => {
cy.getByTestId(dropdownTestId).click();
cy.get('[role="listbox"]').should('be.visible').contains('[role="option"]', optionLabel).click();
cy.get('[role="listbox"]').should('not.exist');
});
Cypress.Commands.add('waitForSpinner', () => {
cy.get('body').then(($body) => {
if ($body.find('[data-test="global-spinner"]').length > 0) {
cy.getByTestId('global-spinner', { timeout: TIMEOUTS.LONG }).should('not.exist');
}
});
});
Cypress.Commands.add('typeSecret', { prevSubject: 'element' }, ($el: JQuery<HTMLElement>, secret: string) => {
cy.wrap($el, { log: false }).clear({ log: false }).type(secret, { log: false });
});
Registering Commands and Types
// cypress/support/commands/index.ts
import './auth.commands';
import './api.commands';
import './db.commands';
import './ui.commands';
import './assertion.commands';
// cypress/support/types/global.d.ts
declare global {
namespace Cypress {
interface Chainable {
loginByApi(username: string, password: string): Chainable<{ accessToken: string; userId: string }>;
loginAs(role: 'admin' | 'standard' | 'readonly' | 'finance'): Chainable<void>;
logout(): Chainable<void>;
apiRequest(options: {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
url: string;
body?: unknown;
headers?: Record<string, string>;
qs?: Record<string, string | number | boolean>;
failOnStatusCode?: boolean;
timeout?: number;
}): Chainable<Cypress.Response<any>>;
expectStatus(expected: number): Chainable<Cypress.Response<any>>;
dbQuery(sql: string, params?: unknown[]): Chainable<any>;
dbSeedUser(email: string, role: string): Chainable<{ id: string }>;
dbCleanupTestUsers(): Chainable<any>;
getByTestId(testId: string, options?: Partial<Cypress.Loggable & Cypress.Timeoutable>): Chainable<JQuery<HTMLElement>>;
selectFromDropdown(dropdownTestId: string, optionLabel: string): Chainable<void>;
waitForSpinner(): Chainable<void>;
typeSecret(secret: string): Chainable<JQuery<HTMLElement>>;
}
}
}
export {};
Typing custom commands is not optional in an enterprise framework. It is what turns "read the source to find out what exists" into "press Ctrl+Space."
Fixtures
JSON Fixtures
{
"standard": {
"username": "standard.user@corp.test",
"displayName": "Standard User",
"role": "STANDARD",
"defaultWarehouse": "WH-EAST-01"
},
"admin": {
"username": "admin.user@corp.test",
"displayName": "Admin User",
"role": "ADMIN",
"defaultWarehouse": "WH-CENTRAL"
},
"finance": {
"username": "finance.user@corp.test",
"displayName": "Finance Approver",
"role": "FINANCE_APPROVER",
"approvalLimit": 250000
}
}
Loading with type safety:
import type { UserFixture } from '../support/types/models';
beforeEach(() => {
cy.fixture<Record<string, UserFixture>>('users/users').as('users');
});
it('shows the approval limit for finance users', function () {
const finance = (this.users as Record<string, UserFixture>).finance;
cy.loginAs('finance');
cy.visit('/profile');
cy.getByTestId('approval-limit').should('contain', finance.approvalLimit.toLocaleString());
});
Environment-Based Fixtures
// cypress/support/utils/fixtureResolver.ts
export function envFixture(name: string): string {
const env = Cypress.env('environment') ?? 'qa';
return `${name}.${env}`;
}
// Usage: cy.fixture(envFixture('users/users')) → users/users.staging.json
Best practices:
- Keep the fixture shape identical across environments so specs never branch on environment.
- Validate fixture shape at load time in CI with a JSON Schema check — a malformed fixture should fail fast, not mid-suite.
- Store only identifiers and non-sensitive attributes. Passwords come from
Cypress.env().
Dynamic Test Data with Faker
Static fixtures cannot express uniqueness. Any test that creates a record needs a fresh identity, or the second run collides with the first.
// cypress/support/utils/factories.ts
import { faker } from '@faker-js/faker';
export interface CustomerPayload {
firstName: string;
lastName: string;
email: string;
phone: string;
addressLine1: string;
city: string;
postalCode: string;
country: string;
}
const RUN_ID = Date.now().toString(36);
export function buildCustomer(overrides: Partial<CustomerPayload> = {}): CustomerPayload {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
// Prefixed so cleanup jobs can identify and purge automation data safely.
email: `cy-test-${RUN_ID}-${faker.string.alphanumeric(8).toLowerCase()}@corp.test`,
phone: faker.phone.number('+1##########'),
addressLine1: faker.location.streetAddress(),
city: faker.location.city(),
postalCode: faker.location.zipCode('#####'),
country: 'US',
...overrides,
};
}
export function buildOrder(overrides: Partial<Record<string, unknown>> = {}) {
return {
externalReference: `CY-${RUN_ID}-${faker.string.numeric(6)}`,
currency: 'USD',
lineItems: [
{
sku: faker.helpers.arrayElement(['SKU-1001', 'SKU-1002', 'SKU-1003']),
quantity: faker.number.int({ min: 1, max: 5 }),
unitPrice: Number(faker.commerce.price({ min: 10, max: 500 })),
},
],
...overrides,
};
}
Recommendation: Seed Faker deterministically in CI (
faker.seed(Number(process.env.BUILD_NUMBER))) when you need reproducible failures, and leave it random locally to surface edge cases. Always keep the unique-prefix strategy so cleanup remains possible either way.
Data-Driven Testing with Fixtures
// cypress/fixtures/orders/discountScenarios.json — loaded at build time via require
import scenarios from '../../fixtures/orders/discountScenarios.json';
interface DiscountScenario {
description: string;
subtotal: number;
couponCode: string;
customerTier: string;
expectedDiscount: number;
expectedTotal: number;
}
describe('Pricing :: Discount engine', () => {
(scenarios as DiscountScenario[]).forEach((scenario) => {
it(`applies ${scenario.expectedDiscount} discount for ${scenario.description}`, () => {
cy.apiRequest({
method: 'POST',
url: '/api/v1/pricing/quote',
body: {
subtotal: scenario.subtotal,
couponCode: scenario.couponCode,
customerTier: scenario.customerTier,
},
})
.expectStatus(200)
.then((response) => {
expect(response.body.discount).to.eq(scenario.expectedDiscount);
expect(response.body.total).to.eq(scenario.expectedTotal);
});
});
});
});
Note the use of a static import rather than cy.fixture(). cy.fixture() is asynchronous and resolves inside the command queue — it cannot generate it() blocks, because Mocha collects tests synchronously during the describe phase. This trips up almost every team once.
Utilities
Logger
// cypress/support/utils/logger.ts
type Level = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
const LEVEL_ORDER: Record<Level, number> = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
function currentThreshold(): number {
const configured = (Cypress.env('LOG_LEVEL') as Level) ?? 'INFO';
return LEVEL_ORDER[configured] ?? LEVEL_ORDER.INFO;
}
function emit(level: Level, message: string, context?: Record<string, unknown>): void {
if (LEVEL_ORDER[level] < currentThreshold()) return;
const payload = {
ts: new Date().toISOString(),
level,
spec: Cypress.spec?.relative,
message,
...context,
};
// Node-side sink keeps CI logs machine-parseable and survives browser reloads.
cy.task('log:write', payload, { log: false });
Cypress.log({ name: level, message, consoleProps: () => payload });
}
export const logger = {
debug: (m: string, c?: Record<string, unknown>) => emit('DEBUG', m, c),
info: (m: string, c?: Record<string, unknown>) => emit('INFO', m, c),
warn: (m: string, c?: Record<string, unknown>) => emit('WARN', m, c),
error: (m: string, c?: Record<string, unknown>) => emit('ERROR', m, c),
};
Date Utility
// cypress/support/utils/dateUtil.ts
const PAD = (n: number) => String(n).padStart(2, '0');
export const DateUtil = {
isoDate(date: Date = new Date()): string {
return `${date.getUTCFullYear()}-${PAD(date.getUTCMonth() + 1)}-${PAD(date.getUTCDate())}`;
},
isoTimestamp(date: Date = new Date()): string {
return date.toISOString();
},
addDays(days: number, from: Date = new Date()): Date {
const d = new Date(from);
d.setUTCDate(d.getUTCDate() + days);
return d;
},
addBusinessDays(days: number, from: Date = new Date()): Date {
const d = new Date(from);
let remaining = days;
while (remaining > 0) {
d.setUTCDate(d.getUTCDate() + 1);
const day = d.getUTCDay();
if (day !== 0 && day !== 6) remaining -= 1;
}
return d;
},
/** Formats for UI assertions, e.g. "24 Jul 2026". */
displayFormat(date: Date, locale = 'en-GB'): string {
return new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
}).format(date);
},
diffInDays(a: Date, b: Date): number {
const MS_PER_DAY = 86_400_000;
return Math.round((b.getTime() - a.getTime()) / MS_PER_DAY);
},
};
Warning: Never assert a date rendered by the application against
new Date()computed in the test without pinning the timezone. Timezone drift between CI runners and application servers is one of the most common causes of tests that fail only between 00:00 and 05:00 UTC.
Random Generator
// cypress/support/utils/randomGenerator.ts
const ALPHA = 'abcdefghijklmnopqrstuvwxyz';
const NUMERIC = '0123456789';
function pick(source: string, length: number): string {
const bytes = new Uint32Array(length);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => source[b % source.length]).join('');
}
export const Random = {
alpha: (len = 8) => pick(ALPHA, len),
numeric: (len = 6) => pick(NUMERIC, len),
alphanumeric: (len = 10) => pick(ALPHA + NUMERIC, len),
email: (domain = 'corp.test') => `cy-test-${Date.now().toString(36)}-${pick(ALPHA, 6)}@${domain}`,
intBetween: (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min,
fromList: <T>(items: readonly T[]): T => items[Math.floor(Math.random() * items.length)],
uuid: () => crypto.randomUUID(),
};
Encryption Utility
Credentials in CI variables are fine. Credentials that must live in the repository (rare, but real in some regulated environments) must be encrypted at rest and decrypted at runtime with a key supplied by the pipeline.
// plugins/cryptoPlugin.ts (Node side — crypto is not available in the browser bundle)
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
function deriveKey(secret: string): Buffer {
return crypto.createHash('sha256').update(secret).digest();
}
export function encrypt({ plaintext, secret }: { plaintext: string; secret: string }): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, deriveKey(secret), iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return [iv.toString('base64'), authTag.toString('base64'), encrypted.toString('base64')].join(':');
}
export function decrypt({ payload, secret }: { payload: string; secret: string }): string {
const [ivB64, tagB64, dataB64] = payload.split(':');
const decipher = crypto.createDecipheriv(ALGORITHM, deriveKey(secret), Buffer.from(ivB64, 'base64'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
}
// Usage from a spec
cy.task('crypto:decrypt', {
payload: Cypress.env('ENCRYPTED_SERVICE_PASSWORD'),
secret: Cypress.env('VAULT_KEY'),
}).then((password) => {
cy.loginByApi('service.account@corp.test', password as string);
});
Recommendation: Prefer a real secret manager — HashiCorp Vault, AWS Secrets Manager, Azure Key Vault — resolved in
setupNodeEvents. Repository-level encryption is a fallback, not a target state.
File Helper
// cypress/support/utils/fileHelper.ts
export const FileHelper = {
downloadPath(fileName: string): string {
return `${Cypress.config('downloadsFolder')}/${fileName}`;
},
assertDownloaded(fileName: string, timeout = 20_000): Cypress.Chainable<string> {
const path = FileHelper.downloadPath(fileName);
return cy.readFile(path, { timeout }).should((content) => {
expect(content, `Expected ${fileName} to be downloaded and non-empty`).to.not.be.empty;
});
},
assertCsvRowCount(fileName: string, expectedRows: number): void {
cy.readFile(FileHelper.downloadPath(fileName)).then((csv: string) => {
const rows = csv.trim().split('\n').filter(Boolean);
expect(rows.length - 1, 'data row count excluding header').to.eq(expectedRows);
});
},
clearDownloads(): void {
cy.task('file:clearDirectory', Cypress.config('downloadsFolder'));
},
};
Schema Validator
// cypress/support/utils/schemaValidator.ts
import Ajv, { type ErrorObject } from 'ajv';
import addFormats from 'ajv-formats';
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
export function validateSchema(schema: object, data: unknown, context = 'response'): void {
const validate = ajv.compile(schema);
const valid = validate(data);
if (!valid) {
const details = (validate.errors as ErrorObject[])
.map((e) => ` - ${e.instancePath || '/'} ${e.message}`)
.join('\n');
throw new Error(`Schema validation failed for ${context}:\n${details}`);
}
}
API Helper
// cypress/support/services/BaseApiService.ts
export abstract class BaseApiService {
protected abstract readonly basePath: string;
protected get<T = unknown>(path = '', qs?: Record<string, string | number | boolean>) {
return cy.apiRequest({ method: 'GET', url: `${this.basePath}${path}`, qs }) as Cypress.Chainable<Cypress.Response<T>>;
}
protected post<T = unknown>(path = '', body?: unknown) {
return cy.apiRequest({ method: 'POST', url: `${this.basePath}${path}`, body }) as Cypress.Chainable<Cypress.Response<T>>;
}
protected put<T = unknown>(path = '', body?: unknown) {
return cy.apiRequest({ method: 'PUT', url: `${this.basePath}${path}`, body }) as Cypress.Chainable<Cypress.Response<T>>;
}
protected patch<T = unknown>(path = '', body?: unknown) {
return cy.apiRequest({ method: 'PATCH', url: `${this.basePath}${path}`, body }) as Cypress.Chainable<Cypress.Response<T>>;
}
protected delete<T = unknown>(path = '') {
return cy.apiRequest({ method: 'DELETE', url: `${this.basePath}${path}` }) as Cypress.Chainable<Cypress.Response<T>>;
}
}
Environment Configuration
A framework that cannot switch environments with a single flag is not an enterprise framework.
The Five Environments
- DEV — developer sandbox. Unstable by design. Run smoke and component tests only; full regression here produces noise, not signal.
- QA — the primary automation target. Stable schema, seeded data, feature flags controllable, database access permitted.
- UAT — business acceptance. Data mirrors real business scenarios. Run regression, but treat data as shared and precious; prefer create-and-clean-up over mutate-existing.
- STAGING — production-identical infrastructure. Full regression plus performance-sensitive checks. Often the last gate before release.
- PRODUCTION — read-only smoke tests only. Synthetic monitoring of critical paths: can users log in, can the catalog load, can checkout reach the payment step (without completing it).
Configuration Files
{
"environment": "qa",
"baseUrl": "https://qa.shop.corp.test",
"env": {
"apiBaseUrl": "https://qa-api.shop.corp.test",
"authUrl": "https://qa-auth.corp.test",
"featureFlags": {
"newCheckout": true,
"loyaltyProgram": false
},
"MAX_LOGIN_ATTEMPTS": 5,
"LOG_LEVEL": "INFO",
"allowDbWrites": true
},
"retries": { "runMode": 2, "openMode": 0 },
"defaultCommandTimeout": 10000,
"video": false
}
{
"environment": "prod",
"baseUrl": "https://shop.corp.com",
"env": {
"apiBaseUrl": "https://api.shop.corp.com",
"authUrl": "https://auth.corp.com",
"LOG_LEVEL": "WARN",
"allowDbWrites": false
},
"retries": { "runMode": 1, "openMode": 0 },
"defaultCommandTimeout": 15000,
"video": true,
"specPattern": "cypress/e2e/smoke/**/*.smoke.cy.ts"
}
Notice that the production config restricts specPattern at the configuration layer. Safety enforced by configuration is stronger than safety enforced by convention.
cypress.config.ts
import { defineConfig } from 'cypress';
import fs from 'fs';
import path from 'path';
import { runQuery, closePool } from './plugins/dbPlugin';
import { encrypt, decrypt } from './plugins/cryptoPlugin';
import { clearDirectory, appendLog } from './plugins/filePlugin';
function loadEnvironmentConfig(envName: string): Record<string, any> {
const configPath = path.resolve('config', `cypress.${envName}.json`);
if (!fs.existsSync(configPath)) {
throw new Error(
`Unknown environment "${envName}". Expected a file at ${configPath}. ` +
`Available: ${fs.readdirSync('config').join(', ')}`,
);
}
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
export default defineConfig({
projectId: 'corp-shop-e2e',
viewportWidth: 1440,
viewportHeight: 900,
video: true,
videoCompression: 32,
screenshotOnRunFailure: true,
trashAssetsBeforeRuns: true,
numTestsKeptInMemory: 20,
watchForFileChanges: false,
chromeWebSecurity: false,
experimentalMemoryManagement: true,
retries: { runMode: 2, openMode: 0 },
reporter: 'cypress-multi-reporters',
reporterOptions: {
reporterEnabled: 'mochawesome, mocha-junit-reporter',
mochawesomeReporterOptions: {
reportDir: 'reports/mochawesome',
overwrite: false,
html: false,
json: true,
},
mochaJunitReporterReporterOptions: {
mochaFile: 'reports/junit/results-[hash].xml',
toConsole: false,
},
},
e2e: {
specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}',
supportFile: 'cypress/support/e2e.ts',
downloadsFolder: 'cypress/downloads',
screenshotsFolder: 'cypress/screenshots',
videosFolder: 'cypress/videos',
setupNodeEvents(on, config) {
const envName = config.env.environment || process.env.CY_ENV || 'qa';
const envConfig = loadEnvironmentConfig(envName);
// Precedence: CLI/CI env vars > environment file > defaults.
const merged = {
...config,
...envConfig,
env: {
...envConfig.env,
...config.env,
environment: envName,
// Secrets NEVER live in files. They arrive from the process environment.
ADMIN_USERNAME: process.env.ADMIN_USERNAME,
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
STANDARD_USERNAME: process.env.STANDARD_USERNAME,
STANDARD_PASSWORD: process.env.STANDARD_PASSWORD,
DB_CONNECTION_STRING: process.env.DB_CONNECTION_STRING,
VAULT_KEY: process.env.VAULT_KEY,
},
};
on('task', {
'db:query': (args) => {
if (merged.env.allowDbWrites === false && /INSERT|UPDATE|DELETE|DROP/i.test(args.sql)) {
throw new Error(`Write query blocked in environment "${envName}".`);
}
return runQuery(args);
},
'db:close': () => closePool(),
'crypto:encrypt': encrypt,
'crypto:decrypt': decrypt,
'file:clearDirectory': clearDirectory,
'log:write': appendLog,
});
require('@cypress/grep/src/plugin')(merged);
return merged;
},
},
component: {
devServer: { framework: 'react', bundler: 'vite' },
supportFile: 'cypress/support/component.ts',
specPattern: 'src/**/*.cy.tsx',
},
});
Environment Variables and Secrets
Precedence model, highest to lowest:
-
--env key=valueon the command line. -
CYPRESS_*process environment variables (injected by CI from a secret store). -
config/cypress.<env>.json. - Defaults in
cypress.config.ts.
Rules:
- Secrets only ever arrive via level 1 or 2.
-
.envfiles exist locally and are git-ignored;.env.exampleis committed with keys and empty values so onboarding is self-documenting. - Never
console.loga secret. Nevercy.logone. Use{ log: false }on any command that handles one. - Rotate automation credentials on the same schedule as human credentials.
npm Scripts
{
"scripts": {
"cy:open:qa": "cypress open --env environment=qa",
"cy:run:qa": "cypress run --env environment=qa --browser chrome",
"cy:run:uat": "cypress run --env environment=uat --browser chrome",
"cy:run:staging": "cypress run --env environment=staging --browser chrome",
"cy:run:prod:smoke": "cypress run --env environment=prod,grepTags=@smoke",
"cy:run:smoke": "cypress run --env environment=qa,grepTags=@smoke",
"cy:run:regression": "cypress run --env environment=qa,grepTags=@regression",
"report:merge": "ts-node scripts/mergeReports.ts",
"clean": "ts-node scripts/cleanArtifacts.ts",
"lint": "eslint . --ext .ts,.tsx --max-warnings=0",
"typecheck": "tsc --noEmit"
}
}
API Testing
API tests are faster, more stable, and cheaper than UI tests. In a healthy enterprise pyramid, they outnumber UI tests by a wide margin and they carry the bulk of validation logic.
cy.request() Fundamentals
cy.request() bypasses the browser entirely. It is not subject to CORS, does not execute in the application context, and does not carry the browser's cookies unless the domain matches. That makes it perfect for setup, teardown, and pure API verification.
cy.request({
method: 'GET',
url: '/api/v1/orders',
qs: { status: 'PENDING', limit: 50 },
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.duration).to.be.lessThan(2000);
expect(response.headers['content-type']).to.include('application/json');
});
Note:
failOnStatusCode: falseshould be your default in a service layer. Otherwise Cypress throws before your assertion runs, and the failure message tells you far less than a cleanexpected 201, got 422with the body attached.
Authentication: JWT and OAuth 2.0
// cypress/support/services/AuthService.ts
import { BaseApiService } from './BaseApiService';
import { ENDPOINTS } from '../../constants/endpoints';
interface TokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: 'Bearer';
}
export class AuthService extends BaseApiService {
protected readonly basePath = ENDPOINTS.AUTH.TOKEN;
passwordGrant(username: string, password: string) {
return cy.request<TokenResponse>({
method: 'POST',
url: `${Cypress.env('authUrl')}/oauth2/token`,
form: true,
body: {
grant_type: 'password',
client_id: Cypress.env('OAUTH_CLIENT_ID'),
client_secret: Cypress.env('OAUTH_CLIENT_SECRET'),
scope: 'orders:read orders:write profile',
username,
password,
},
log: false,
});
}
clientCredentials(scope: string) {
return cy.request<TokenResponse>({
method: 'POST',
url: `${Cypress.env('authUrl')}/oauth2/token`,
form: true,
body: {
grant_type: 'client_credentials',
client_id: Cypress.env('OAUTH_CLIENT_ID'),
client_secret: Cypress.env('OAUTH_CLIENT_SECRET'),
scope,
},
log: false,
});
}
refresh(refreshToken: string) {
return cy.request<TokenResponse>({
method: 'POST',
url: `${Cypress.env('authUrl')}/oauth2/token`,
form: true,
body: {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: Cypress.env('OAUTH_CLIENT_ID'),
},
log: false,
});
}
/** Decodes a JWT payload without verification — for asserting claims only. */
static decodeJwt(token: string): Record<string, unknown> {
const [, payload] = token.split('.');
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
}
}
export const authService = new AuthService();
it('issues a token with the correct claims and expiry', () => {
authService.clientCredentials('orders:read').then((res) => {
expect(res.status).to.eq(200);
expect(res.body.token_type).to.eq('Bearer');
expect(res.body.expires_in).to.be.within(300, 3600);
const claims = AuthService.decodeJwt(res.body.access_token);
expect(claims.iss).to.eq(Cypress.env('authUrl'));
expect(claims.scope).to.include('orders:read');
expect(claims.scope).to.not.include('orders:write');
expect(Number(claims.exp) * 1000).to.be.greaterThan(Date.now());
});
});
A Reusable Domain Service Layer
// cypress/support/services/OrderService.ts
import { BaseApiService } from './BaseApiService';
import { ENDPOINTS } from '../../constants/endpoints';
export interface OrderResponse {
id: string;
status: 'DRAFT' | 'PENDING' | 'CONFIRMED' | 'SHIPPED' | 'CANCELLED';
total: number;
currency: string;
lineItems: Array<{ sku: string; quantity: number; unitPrice: number }>;
createdAt: string;
}
export class OrderService extends BaseApiService {
protected readonly basePath = ENDPOINTS.ORDERS.BASE;
create(payload: unknown) {
return this.post<OrderResponse>('', payload);
}
getById(id: string) {
return this.get<OrderResponse>(`/${id}`);
}
list(params: { status?: string; page?: number; size?: number } = {}) {
return this.get<{ items: OrderResponse[]; total: number }>('', params as Record<string, string | number>);
}
replace(id: string, payload: unknown) {
return this.put<OrderResponse>(`/${id}`, payload);
}
updateStatus(id: string, status: OrderResponse['status']) {
return this.patch<OrderResponse>(`/${id}`, { status });
}
cancel(id: string, reason: string) {
return this.post<OrderResponse>(`/${id}/cancel`, { reason });
}
remove(id: string) {
return this.delete<void>(`/${id}`);
}
uploadAttachment(id: string, fileName: string, mimeType: string) {
return cy.fixture(`attachments/${fileName}`, 'binary').then((binary) => {
const blob = Cypress.Blob.binaryStringToBlob(binary, mimeType);
const formData = new FormData();
formData.append('file', blob, fileName);
formData.append('documentType', 'INVOICE');
return cy.request({
method: 'POST',
url: `${Cypress.env('apiBaseUrl')}${ENDPOINTS.ORDERS.BY_ID(id)}/attachments`,
body: formData,
headers: {
Authorization: `Bearer ${Cypress.env('currentAccessToken')}`,
'Content-Type': 'multipart/form-data',
},
failOnStatusCode: false,
});
});
}
}
export const orderService = new OrderService();
A Full Enterprise Lifecycle Test
// cypress/e2e/api/orders/orderLifecycle.api.cy.ts
import { orderService } from '../../../support/services/OrderService';
import { buildOrder } from '../../../support/utils/factories';
import { validateSchema } from '../../../support/utils/schemaValidator';
import orderSchema from '../../../fixtures/schemas/order.schema.json';
describe('Orders API :: full lifecycle', { tags: ['@api', '@regression'] }, () => {
let orderId: string;
before(() => {
cy.loginAs('standard');
});
it('POST creates an order in DRAFT and returns a conforming payload', () => {
orderService
.create(buildOrder())
.expectStatus(201)
.then((res) => {
validateSchema(orderSchema, res.body, 'POST /orders');
expect(res.body.status).to.eq('DRAFT');
expect(res.body.id).to.match(/^ord_[a-z0-9]{16}$/);
expect(res.headers).to.have.property('location');
orderId = res.body.id;
});
});
it('GET retrieves the created order idempotently', () => {
orderService.getById(orderId).expectStatus(200).its('body.id').should('eq', orderId);
});
it('PATCH transitions DRAFT to PENDING', () => {
orderService.updateStatus(orderId, 'PENDING').expectStatus(200).its('body.status').should('eq', 'PENDING');
});
it('PUT replaces the order and recalculates the total', () => {
const replacement = buildOrder({
lineItems: [{ sku: 'SKU-1001', quantity: 3, unitPrice: 100 }],
});
orderService
.replace(orderId, replacement)
.expectStatus(200)
.then((res) => {
expect(res.body.total).to.eq(300);
});
});
it('rejects an invalid status transition with 409 and a machine-readable error', () => {
orderService
.updateStatus(orderId, 'SHIPPED')
.expectStatus(409)
.then((res) => {
expect(res.body.errorCode).to.eq('INVALID_STATE_TRANSITION');
expect(res.body.detail).to.be.a('string').and.not.be.empty;
});
});
it('POST /cancel moves the order to CANCELLED and records the reason', () => {
orderService
.cancel(orderId, 'Customer requested cancellation')
.expectStatus(200)
.its('body.status')
.should('eq', 'CANCELLED');
});
it('DELETE removes a cancelled order and returns 404 thereafter', () => {
orderService.remove(orderId).expectStatus(204);
orderService.getById(orderId).expectStatus(404);
});
after(() => {
if (orderId) {
orderService.remove(orderId);
}
});
});
Schema Validation
JSON Schema turns "the API returned something" into "the API returned exactly the contract." Store schemas in fixtures/schemas/ and validate every response your suite depends on.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "status", "total", "currency", "lineItems", "createdAt"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^ord_[a-z0-9]{16}$" },
"status": { "enum": ["DRAFT", "PENDING", "CONFIRMED", "SHIPPED", "CANCELLED"] },
"total": { "type": "number", "minimum": 0 },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
"createdAt": { "type": "string", "format": "date-time" },
"lineItems": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "quantity", "unitPrice"],
"properties": {
"sku": { "type": "string", "pattern": "^SKU-\\d{4}$" },
"quantity": { "type": "integer", "minimum": 1 },
"unitPrice": { "type": "number", "exclusiveMinimum": 0 }
}
}
}
}
}
"additionalProperties": false is the setting that catches accidental field leakage — including PII a backend team added without telling anyone.
UI Testing
Locators
Selector strategy is the single largest determinant of UI test stability. Ranked, best to worst:
-
[data-test="..."](ordata-testid,data-cy) — a contract with the developers. Immune to styling and copy changes. - Accessible roles and names —
cy.findByRole('button', { name: /submit order/i }). Doubles as an accessibility check. - Stable semantic attributes —
[name="email"],[type="submit"]. - Visible text via
cy.contains()— acceptable for unique, stable copy; breaks on i18n. - Structural CSS —
.card > div:nth-child(3). Avoid. - XPath — avoid entirely in Cypress. It requires a plugin and encodes DOM structure.
Recommendation: Make
data-testattributes a definition-of-done item for developers, and strip them in production builds with a Babel or SWC plugin if bundle purity matters. The negotiation with the development team is a one-time cost; brittle selectors are a recurring one.
Assertions
Cypress bundles Chai, Chai-jQuery, and Sinon-Chai. Three styles:
// 1. Implicit — retried automatically until timeout. Prefer this.
cy.getByTestId('order-total').should('have.text', '$300.00');
// 2. Chained multiple assertions — all must pass in the same retry cycle.
cy.getByTestId('submit')
.should('be.visible')
.and('be.enabled')
.and('have.attr', 'type', 'submit');
// 3. Explicit inside .should(callback) — retried as a block.
cy.getByTestId('grid-row').should(($rows) => {
expect($rows).to.have.length(10);
expect($rows.first()).to.contain('ORD-');
});
// AVOID: expect() outside a retryable context — runs once, no retry.
cy.getByTestId('order-total').then(($el) => {
expect($el.text()).to.eq('$300.00'); // fails immediately if async render is late
});
Wait Strategies
There are exactly three legitimate synchronization mechanisms:
-
Assertion-driven waiting.
cy.get(...).should('be.visible')— the default and correct answer 90% of the time. - Network-driven waiting. Alias an intercept and wait for it:
cy.intercept('GET', '**/api/v1/orders*').as('loadOrders');
cy.visit('/orders');
cy.wait('@loadOrders').its('response.statusCode').should('eq', 200);
cy.getByTestId('grid-row').should('have.length.greaterThan', 0);
-
State-driven waiting. Poll application state through a stable indicator, such as a spinner disappearing or a
data-loaded="true"attribute appearing.
cy.wait(5000) is not on this list. It is a bet that the application is slower than X and faster than X, and it is wrong in both directions: slow on fast runs, flaky on slow ones.
Retry Mechanism
Cypress retries at two levels:
-
Command/assertion retry — built-in, automatic, bounded by
defaultCommandTimeout. Applies to queries (get,find,contains) and assertions. Does not apply to action commands (click,type) — those run once against the resolved subject. -
Test-level retry — configured via
retries. A failed test re-runs from itsbeforeEach.
// Suite-level override for a genuinely eventually-consistent flow.
describe('Search indexing', { retries: { runMode: 3, openMode: 1 } }, () => {
it('surfaces a newly created product once indexed', () => {
// ...
});
});
Warning: Test retries hide flakiness; they do not fix it. Configure them, then instrument them. If a test passes only on attempt two or three, it must generate a report entry and a ticket. A suite with retries and no flake tracking is a suite that is quietly rotting.
Network Intercepts
cy.intercept() is the most powerful stability tool in Cypress.
// Spy — observe without modifying.
cy.intercept('POST', '**/api/v1/orders').as('createOrder');
// Stub with a fixture — deterministic, fast, no backend dependency.
cy.intercept('GET', '**/api/v1/orders*', { fixture: 'orders/orderList.json' }).as('orders');
// Stub an error to test the failure path — nearly impossible to trigger otherwise.
cy.intercept('POST', '**/api/v1/payments', {
statusCode: 502,
body: { errorCode: 'GATEWAY_TIMEOUT', detail: 'Payment provider unreachable' },
}).as('paymentFailure');
// Delay to test loading states and skeleton screens.
cy.intercept('GET', '**/api/v1/dashboard', (req) => {
req.reply({ delay: 3000, fixture: 'dashboard/summary.json' });
}).as('slowDashboard');
// Mutate a real response — keep the backend contract, change one field.
cy.intercept('GET', '**/api/v1/user/profile', (req) => {
req.continue((res) => {
res.body.entitlements = ['ADMIN', 'BILLING'];
});
}).as('elevatedProfile');
// Assert on the outgoing request body — verifies what the UI actually sent.
cy.wait('@createOrder').then(({ request, response }) => {
expect(request.body.lineItems).to.have.length(2);
expect(request.headers).to.have.property('x-correlation-id');
expect(response?.statusCode).to.eq(201);
});
File Upload
// Native Cypress (13+), no plugin required.
cy.getByTestId('invoice-upload').selectFile('cypress/fixtures/attachments/invoice.pdf');
// Multiple files
cy.getByTestId('bulk-upload').selectFile([
'cypress/fixtures/attachments/a.csv',
'cypress/fixtures/attachments/b.csv',
]);
// Drag-and-drop zones
cy.getByTestId('dropzone').selectFile('cypress/fixtures/attachments/invoice.pdf', { action: 'drag-drop' });
// Generated content without a physical fixture file
cy.getByTestId('csv-upload').selectFile(
{
contents: Cypress.Buffer.from('sku,quantity\nSKU-1001,5\nSKU-1002,3'),
fileName: 'bulk-order.csv',
mimeType: 'text/csv',
},
{ force: true },
);
Download Validation
describe('Reports :: export', () => {
beforeEach(() => {
cy.task('file:clearDirectory', Cypress.config('downloadsFolder'));
cy.loginAs('finance');
});
it('exports the settlement report as a CSV with the expected row count', () => {
cy.intercept('GET', '**/api/v1/reports/settlement*').as('export');
cy.visit('/reports/settlement');
cy.getByTestId('export-csv').click();
cy.wait('@export').its('response.statusCode').should('eq', 200);
cy.readFile(`${Cypress.config('downloadsFolder')}/settlement-report.csv`, { timeout: 20000 })
.should('not.be.empty')
.then((csv: string) => {
const lines = csv.trim().split('\n');
expect(lines[0]).to.eq('transactionId,date,amount,currency,status');
expect(lines.length - 1).to.be.greaterThan(0);
});
});
});
Iframes
Cypress has no native iframe API. The reliable pattern wraps the iframe body once it has loaded.
// cypress/support/commands/ui.commands.ts
Cypress.Commands.add('iframeBody', (iframeSelector: string) => {
return cy
.get(iframeSelector, { timeout: 20000 })
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap);
});
// Usage — a hosted payment field
cy.iframeBody('iframe[data-test="card-number-frame"]')
.find('input[name="cardnumber"]')
.type('4242424242424242');
Note: Cross-origin iframes are subject to browser security.
chromeWebSecurity: falseallows access in Chromium-family browsers but not in Firefox or WebKit. If payment providers are a core flow, decide early whether you stub them (recommended for regression) or test them for real in a nightly integration suite.
Shadow DOM
// Per-command
cy.get('order-widget').shadow().find('[data-test="confirm"]').click();
// Globally, via configuration
// includeShadowDom: true in cypress.config.ts
cy.getByTestId('confirm').click(); // pierces shadow boundaries automatically
Enabling includeShadowDom globally is convenient but slows every query. Prefer per-command .shadow() unless your application is built entirely on web components.
Multiple Tabs Strategy
Cypress cannot control a second tab. Three sanctioned workarounds:
// 1. Remove the target attribute and navigate in the same tab.
cy.getByTestId('terms-link').invoke('removeAttr', 'target').click();
cy.url().should('include', '/terms');
// 2. Assert the link contract, then visit the URL directly.
cy.getByTestId('invoice-link')
.should('have.attr', 'target', '_blank')
.and('have.attr', 'href')
.then((href) => {
cy.request(String(href)).its('status').should('eq', 200);
});
// 3. Stub window.open and assert the call — verifies intent without a second tab.
cy.window().then((win) => {
cy.stub(win, 'open').as('windowOpen');
});
cy.getByTestId('export-portal').click();
cy.get('@windowOpen').should('have.been.calledWithMatch', /\/portal\/export/);
Data-Driven Testing
JSON-Driven
Covered above. The key constraint bears repeating: use a static import (or require) when the data must generate it() blocks, and cy.fixture() when the data is consumed inside a test body.
CSV-Driven
CSV parsing happens in Node, at config time, so the data is available synchronously to the spec.
// scripts/loadCsv.ts — invoked from setupNodeEvents or imported directly in a spec via a bundler
import fs from 'fs';
import { parse } from 'csv-parse/sync';
export function loadCsv<T = Record<string, string>>(filePath: string): T[] {
const content = fs.readFileSync(filePath, 'utf-8');
return parse(content, { columns: true, skip_empty_lines: true, trim: true }) as T[];
}
// Registered as a task
on('task', {
'data:csv': (filePath: string) => loadCsv(filePath),
});
// Consumed in a spec for in-test iteration
it('validates every tax jurisdiction in the reference file', () => {
cy.task<Array<{ region: string; rate: string }>>('data:csv', 'cypress/fixtures/tax/rates.csv').then((rows) => {
rows.forEach((row) => {
cy.apiRequest({ method: 'GET', url: `/api/v1/tax/${row.region}` })
.expectStatus(200)
.its('body.rate')
.should('eq', Number(row.rate));
});
});
});
Excel-Driven
Business analysts maintain test matrices in Excel. Meet them where they are.
// plugins/excelPlugin.ts
import XLSX from 'xlsx';
export function readSheet({ filePath, sheetName }: { filePath: string; sheetName: string }) {
const workbook = XLSX.readFile(filePath);
const sheet = workbook.Sheets[sheetName];
if (!sheet) {
throw new Error(`Sheet "${sheetName}" not found. Available: ${workbook.SheetNames.join(', ')}`);
}
return XLSX.utils.sheet_to_json(sheet, { defval: null });
}
cy.task('excel:read', {
filePath: 'cypress/fixtures/matrix/pricing.xlsx',
sheetName: 'DiscountRules',
}).then((rows: any[]) => {
rows.forEach((rule) => {
cy.apiRequest({ method: 'POST', url: '/api/v1/pricing/quote', body: rule.input })
.expectStatus(200)
.its('body.total')
.should('eq', rule.expectedTotal);
});
});
API-Sourced Test Data
The most robust pattern for large enterprises: query the system for data that matches criteria, rather than depending on a fixed seed.
before(() => {
cy.loginAs('admin');
cy.apiRequest({ method: 'GET', url: '/api/v1/orders', qs: { status: 'PENDING', size: 1 } })
.expectStatus(200)
.then((res) => {
const order = res.body.items[0];
if (!order) {
// Self-healing: create the precondition instead of failing.
return orderService.create(buildOrder()).its('body').as('targetOrder');
}
return cy.wrap(order).as('targetOrder');
});
});
Parameterized Execution
const roles = ['admin', 'standard', 'readonly', 'finance'] as const;
const permissionMatrix = {
admin: { canCreateOrder: true, canApprove: true, canDelete: true },
standard: { canCreateOrder: true, canApprove: false, canDelete: false },
readonly: { canCreateOrder: false, canApprove: false, canDelete: false },
finance: { canCreateOrder: false, canApprove: true, canDelete: false },
} as const;
describe('Authorization matrix', { tags: ['@security', '@regression'] }, () => {
roles.forEach((role) => {
describe(`as ${role}`, () => {
beforeEach(() => {
cy.loginAs(role);
cy.visit('/orders');
});
it(`${permissionMatrix[role].canCreateOrder ? 'shows' : 'hides'} the create action`, () => {
const assertion = permissionMatrix[role].canCreateOrder ? 'be.visible' : 'not.exist';
cy.getByTestId('create-order').should(assertion);
});
it(`${permissionMatrix[role].canApprove ? 'permits' : 'denies'} approval via API`, () => {
cy.apiRequest({ method: 'POST', url: '/api/v1/orders/ord_0000000000000001/approve' }).then((res) => {
if (permissionMatrix[role].canApprove) {
expect(res.status).to.be.oneOf([200, 409]);
} else {
expect(res.status).to.eq(403);
}
});
});
});
});
});
This single block replaces sixteen hand-written tests and guarantees the matrix stays consistent as roles evolve.
Reporting
A report is not a nice-to-have. It is the interface between the automation suite and everyone who does not read CI logs.
Mochawesome
Mochawesome produces per-spec JSON that is merged into a single HTML report after the run.
npm install --save-dev mochawesome mochawesome-merge mochawesome-report-generator cypress-multi-reporters mocha-junit-reporter
The reporter configuration was shown in cypress.config.ts. The merge step:
// scripts/mergeReports.ts
import { merge } from 'mochawesome-merge';
import { create } from 'mochawesome-report-generator';
import fs from 'fs';
import path from 'path';
async function run(): Promise<void> {
const reportDir = path.resolve('reports/mochawesome');
if (!fs.existsSync(reportDir)) {
throw new Error(`No report directory at ${reportDir}. Did the Cypress run produce output?`);
}
const report = await merge({ files: [`${reportDir}/*.json`] });
await create(report, {
reportDir: 'reports/html',
reportFilename: 'e2e-report',
reportTitle: `E2E Regression — ${process.env.CY_ENV ?? 'qa'} — build ${process.env.BUILD_NUMBER ?? 'local'}`,
reportPageTitle: 'Enterprise Cypress Report',
charts: true,
inline: true, // single self-contained HTML file — critical for CI artifact upload
saveJson: true,
});
const { passes, failures, pending, skipped, duration } = report.stats;
const summary = { passes, failures, pending, skipped, durationSeconds: Math.round(duration / 1000) };
fs.writeFileSync('reports/summary.json', JSON.stringify(summary, null, 2));
console.log(`Report generated. ${passes} passed, ${failures} failed, ${pending} pending.`);
if (failures > 0) process.exitCode = 1;
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
inline: true matters more than it looks. A report split across dozens of asset files is unusable when downloaded from an artifact store; a single HTML file opens anywhere.
Allure
Allure adds history, trends, flakiness detection, severity, and requirement traceability — the features enterprise QA leadership actually asks for.
npm install --save-dev @shelex/cypress-allure-plugin allure-commandline
// cypress/support/e2e.ts
import '@shelex/cypress-allure-plugin';
// cypress.config.ts — inside setupNodeEvents
allureWriter(on, config);
it('processes a high-value order through manual review', () => {
cy.allure()
.epic('Order Management')
.feature('Manual Review')
.story('High-value threshold')
.severity('critical')
.owner('payments-squad')
.tms('JIRA-4821')
.issue('BUG-1190', 'https://jira.corp.test/browse/BUG-1190');
// ... test body
});
Generating and serving:
npx allure generate reports/allure-results --clean -o reports/allure-report
npx allure open reports/allure-report
To get trend graphs, copy the previous run's history folder into allure-results before generating. Without that step, every report shows a single data point and leadership concludes the tool is broken.
JUnit XML
JUnit XML is the lingua franca of CI systems. Jenkins, Azure DevOps, GitLab, and TeamCity all parse it natively to produce test tabs, trend graphs, and per-test failure history. Emit it always, even if you also emit Allure.
The [hash] token in mochaFile: 'reports/junit/results-[hash].xml' prevents parallel workers from overwriting one another's output.
HTML Reports and Publishing
- GitHub Actions — upload as an artifact, or publish to GitHub Pages for a permanent URL per build.
-
Jenkins — use the HTML Publisher plugin, plus
junitfor the native test trend. -
Azure DevOps —
PublishTestResults@2for JUnit,PublishPipelineArtifact@1for HTML. -
GitLab CI —
artifacts:reports:junitrenders results directly in the merge request widget.
Report Merging Across Parallel Machines
When four containers each produce partial results, the pipeline needs a fan-in stage:
- Each worker uploads
reports/mochawesome/*.json,reports/junit/*.xml, andreports/allure-results/*as artifacts, namespaced by worker index. - A dedicated
reportjob downloads all artifacts into a common directory. - The merge script runs once, producing a single HTML report and a single summary.
- Notifications fire from the merge job, not from individual workers — otherwise Slack receives four contradictory messages.
Notifications
// scripts/notify.ts
import fs from 'fs';
interface Summary {
passes: number;
failures: number;
pending: number;
durationSeconds: number;
}
async function notify(): Promise<void> {
const summary: Summary = JSON.parse(fs.readFileSync('reports/summary.json', 'utf-8'));
const total = summary.passes + summary.failures + summary.pending;
const passRate = total > 0 ? ((summary.passes / total) * 100).toFixed(1) : '0.0';
const status = summary.failures === 0 ? 'PASSED' : 'FAILED';
const text = [
`*E2E Regression ${status}* — ${process.env.CY_ENV ?? 'qa'}`,
`Pass rate: ${passRate}% (${summary.passes}/${total})`,
`Failures: ${summary.failures} | Duration: ${Math.round(summary.durationSeconds / 60)} min`,
`Report: ${process.env.REPORT_URL ?? 'see build artifacts'}`,
].join('\n');
await fetch(process.env.SLACK_WEBHOOK_URL as string, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
}
notify().catch((e) => console.error('Notification failed (non-fatal):', e));
Recommendation: Notify on failure and on recovery, not on every success. Teams mute channels that produce a green message every twenty minutes, and then they miss the red one.
Screenshot and Video Strategy
Automatic and Failure Screenshots
Cypress captures a screenshot automatically on test failure in cypress run mode when screenshotOnRunFailure is true. This is the default and should stay on.
Manual capture for evidence-gathering:
cy.screenshot('payment-step-before-submit', { capture: 'viewport', overwrite: true });
cy.getByTestId('order-summary').screenshot('order-summary-element');
Enriching failure screenshots with context:
// cypress/support/e2e.ts
Cypress.on('fail', (error, runnable) => {
const context = {
spec: Cypress.spec.relative,
test: runnable.fullTitle(),
url: cy.state('window')?.location?.href,
viewport: `${Cypress.config('viewportWidth')}x${Cypress.config('viewportHeight')}`,
environment: Cypress.env('environment'),
};
cy.task('log:write', { level: 'ERROR', message: error.message, ...context }, { log: false });
throw error; // rethrow — never swallow a failure
});
Video Recording
Video is expensive: it adds CPU cost during the run and a compression step after every spec. A pragmatic policy:
- Local development — video off. The Test Runner already provides time travel.
- Pull request pipelines — video off, screenshots on. Speed matters most; failures are reproduced locally.
- Nightly regression and release pipelines — video on, but delete videos for passing specs.
// cypress.config.ts — inside setupNodeEvents
on('after:spec', (spec, results) => {
if (results && results.video) {
const failed = results.tests.some((t) => t.attempts.some((a) => a.state === 'failed'));
if (!failed) {
fs.unlinkSync(results.video); // discard video for a fully passing spec
}
}
});
This one hook typically reduces artifact storage by 90–95%.
Storage Strategy
- Never commit
screenshots/,videos/,downloads/, orreports/to version control. Add all four to.gitignoreon day one. - Upload to CI artifact storage with an explicit retention policy: 7 days for PR builds, 30 days for nightly, 1 year for release-gate runs if audit requirements demand it.
- For long-term retention, push to object storage (S3, Azure Blob) with a lifecycle rule, and store only the URL in the report.
- Name artifacts with build number, environment, and worker index so a failure can be traced to an exact run.
Cleanup
// scripts/cleanArtifacts.ts
import fs from 'fs';
import path from 'path';
const TARGETS = [
'cypress/screenshots',
'cypress/videos',
'cypress/downloads',
'reports/mochawesome',
'reports/junit',
'reports/allure-results',
'reports/html',
];
for (const target of TARGETS) {
const dir = path.resolve(target);
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
console.log(`Cleared ${target}`);
}
fs.mkdirSync(dir, { recursive: true });
}
Run this as a pretest script. trashAssetsBeforeRuns: true handles Cypress's own folders, but not your report directories.
🎁 MEGA SALE — Flat 90% OFF on ALL 100+ eBooks & Bundles
Use coupon code: JUPITER90
📚 Explore the complete HimanshuAI Digital Playbook Store:
👉 https://himanshuai.gumroad.com/
If you're planning to master Playwright, TypeScript, AI Testing, Salesforce, API Testing, Java, Python, Cypress, Selenium, System Design, DevOps, Cloud Testing, Performance Testing, or Software Test Architecture, this is the best opportunity to build your complete technical library at a fraction of the regular price.
Parallel Execution
Suite duration is the number that determines whether automation is a gate or an obstacle. Parallelism is how you control it.
The Unit of Parallelism
Cypress parallelizes at the spec file level, never below it. This has a direct architectural consequence: a suite of 20 specs cannot use more than 20 machines, and a single spec containing 300 tests will pin one machine for the entire run while others idle.
Design implication: many small specs beat few large ones. Aim for specs that run in 60–180 seconds.
Multi-Browser Execution
cypress run --browser chrome --env environment=qa
cypress run --browser firefox --env environment=qa,grepTags=@cross-browser
cypress run --browser webkit --env environment=qa,grepTags=@cross-browser
Run the full suite on one browser and a curated subset on the others. Tag the subset explicitly (@cross-browser) so the selection is intentional and reviewable rather than accidental.
Parallel Machines with Cypress Cloud
cypress run --record --key $CYPRESS_RECORD_KEY --parallel --ci-build-id $BUILD_ID --group "regression-chrome"
Cypress Cloud's orchestrator assigns specs to machines dynamically based on historical duration, which yields near-optimal balancing automatically. --ci-build-id must be identical across all machines in the same run and unique across runs.
Parallelism Without Cypress Cloud
Sharding by spec index works with any CI system:
// scripts/shardSpecs.ts
import { globSync } from 'glob';
const total = Number(process.env.SHARD_TOTAL ?? 1);
const index = Number(process.env.SHARD_INDEX ?? 0);
const specs = globSync('cypress/e2e/**/*.cy.ts').sort();
const shard = specs.filter((_, i) => i % total === index);
process.stdout.write(shard.join(','));
SPECS=$(SHARD_TOTAL=4 SHARD_INDEX=$INDEX ts-node scripts/shardSpecs.ts)
npx cypress run --spec "$SPECS"
Round-robin distribution by filename is naive; upgrade to duration-weighted sharding by persisting per-spec timings from previous runs and using a greedy bin-packing algorithm. That change alone typically cuts wall-clock time by 20–30% versus naive sharding.
Docker-Based Parallelism
Each parallel worker is a container running the same image with a different shard index. This guarantees identical Node version, browser version, and font rendering across workers — eliminating a whole class of "passes on machine 2, fails on machine 3" failures.
Practical Limits
- Do not parallelize past the point where the application under test becomes the bottleneck. Ten workers hammering a single QA instance will produce timeouts that look like test bugs.
- Data collisions multiply with parallelism. Every worker must create its own data with unique identifiers, never share a fixed seed record.
- Coordinate rate limits with the platform team before scaling workers.
CI/CD Integration
GitHub Actions
name: E2E Regression
on:
pull_request:
branches: [main, develop]
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
inputs:
environment:
description: Target environment
required: true
default: qa
type: choice
options: [qa, uat, staging]
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint and typecheck
run: |
npm run lint
npm run typecheck
- name: Cache Cypress binary
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
e2e:
needs: install
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Run Cypress
uses: cypress-io/github-action@v6
with:
browser: chrome
record: true
parallel: true
group: regression-chrome
ci-build-id: ${{ github.run_id }}-${{ github.run_attempt }}
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
CYPRESS_environment: ${{ inputs.environment || 'qa' }}
ADMIN_USERNAME: ${{ secrets.ADMIN_USERNAME }}
ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
STANDARD_USERNAME: ${{ secrets.STANDARD_USERNAME }}
STANDARD_PASSWORD: ${{ secrets.STANDARD_PASSWORD }}
DB_CONNECTION_STRING: ${{ secrets.DB_CONNECTION_STRING }}
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: cypress-artifacts-shard-${{ matrix.shard }}
path: |
cypress/screenshots
cypress/videos
reports
retention-days: 7
report:
needs: e2e
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Consolidate and merge
run: |
mkdir -p reports/mochawesome reports/junit
find artifacts -name '*.json' -path '*mochawesome*' -exec cp {} reports/mochawesome/ \;
find artifacts -name '*.xml' -path '*junit*' -exec cp {} reports/junit/ \;
npm run report:merge
- name: Publish HTML report
uses: actions/upload-artifact@v4
with:
name: e2e-html-report
path: reports/html
- name: Notify Slack
if: always()
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
REPORT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: npx ts-node scripts/notify.ts
Jenkins
pipeline {
agent none
parameters {
choice(name: 'ENVIRONMENT', choices: ['qa', 'uat', 'staging'], description: 'Target environment')
choice(name: 'SUITE', choices: ['smoke', 'regression', 'all'], description: 'Suite to execute')
string(name: 'PARALLEL_WORKERS', defaultValue: '4', description: 'Number of parallel containers')
}
options {
timeout(time: 90, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '30'))
disableConcurrentBuilds()
}
environment {
CYPRESS_environment = "${params.ENVIRONMENT}"
ADMIN_CREDS = credentials('cypress-admin-credentials')
DB_CONNECTION_STRING = credentials('cypress-db-connection')
}
stages {
stage('Parallel E2E') {
steps {
script {
def workers = params.PARALLEL_WORKERS.toInteger()
def branches = [:]
for (int i = 0; i < workers; i++) {
def index = i
branches["shard-${index}"] = {
node('docker') {
docker.image('cypress/included:13.14.2').inside('--shm-size=2g -u root') {
checkout scm
sh 'npm ci'
sh """
export SHARD_INDEX=${index}
export SHARD_TOTAL=${workers}
export ADMIN_USERNAME=\$ADMIN_CREDS_USR
export ADMIN_PASSWORD=\$ADMIN_CREDS_PSW
SPECS=\$(npx ts-node scripts/shardSpecs.ts)
npx cypress run --browser chrome --spec "\$SPECS" \
--env environment=${params.ENVIRONMENT},grepTags=@${params.SUITE}
"""
stash name: "reports-${index}", includes: 'reports/**, cypress/screenshots/**, cypress/videos/**', allowEmpty: true
}
}
}
}
parallel branches
}
}
}
stage('Merge and Publish') {
agent { label 'docker' }
steps {
script {
def workers = params.PARALLEL_WORKERS.toInteger()
for (int i = 0; i < workers; i++) {
unstash "reports-${i}"
}
}
sh 'npm ci && npm run report:merge'
junit allowEmptyResults: true, testResults: 'reports/junit/*.xml'
publishHTML(target: [
reportDir: 'reports/html',
reportFiles: 'e2e-report.html',
reportName: 'Cypress E2E Report',
keepAll: true,
alwaysLinkToLastBuild: true,
allowMissing: false
])
archiveArtifacts artifacts: 'cypress/screenshots/**, cypress/videos/**', allowEmptyArchive: true
}
}
}
post {
always {
node('docker') {
sh 'npx ts-node scripts/notify.ts || true'
}
}
}
}
Azure DevOps
trigger:
branches:
include: [main, develop]
schedules:
- cron: '0 2 * * *'
displayName: Nightly regression
branches:
include: [main]
always: true
variables:
- group: cypress-secrets
- name: nodeVersion
value: '20.x'
stages:
- stage: E2E
jobs:
- job: RunTests
strategy:
parallel: 4
pool:
vmImage: ubuntu-latest
container: cypress/browsers:node-20.14.0-chrome-125
steps:
- task: NodeTool@0
inputs:
versionSpec: $(nodeVersion)
- script: npm ci
displayName: Install dependencies
- script: |
npx cypress run \
--browser chrome \
--record --parallel \
--key $(CYPRESS_RECORD_KEY) \
--ci-build-id $(Build.BuildId) \
--env environment=$(TARGET_ENV)
displayName: Run Cypress
env:
ADMIN_USERNAME: $(ADMIN_USERNAME)
ADMIN_PASSWORD: $(ADMIN_PASSWORD)
- task: PublishTestResults@2
condition: always()
inputs:
testResultsFormat: JUnit
testResultsFiles: 'reports/junit/*.xml'
mergeTestResults: true
testRunTitle: 'Cypress E2E — $(TARGET_ENV)'
- task: PublishPipelineArtifact@1
condition: always()
inputs:
targetPath: 'cypress/screenshots'
artifact: 'screenshots-$(System.JobPositionInPhase)'
GitLab CI
stages: [install, test, report]
variables:
npm_config_cache: "$CI_PROJECT_DIR/.npm"
CYPRESS_CACHE_FOLDER: "$CI_PROJECT_DIR/cache/Cypress"
cache:
key:
files: [package-lock.json]
paths: [.npm, cache/Cypress]
install:
stage: install
image: cypress/included:13.14.2
script:
- npm ci
- npx cypress verify
artifacts:
paths: [node_modules]
expire_in: 1 hour
e2e:
stage: test
image: cypress/included:13.14.2
parallel: 4
needs: [install]
script:
- export SHARD_INDEX=$((CI_NODE_INDEX - 1))
- export SHARD_TOTAL=$CI_NODE_TOTAL
- SPECS=$(npx ts-node scripts/shardSpecs.ts)
- npx cypress run --browser chrome --spec "$SPECS" --env environment=$TARGET_ENV
artifacts:
when: always
expire_in: 1 week
paths:
- cypress/screenshots
- cypress/videos
- reports
reports:
junit: reports/junit/*.xml
merge_report:
stage: report
image: node:20
when: always
needs: [e2e]
script:
- npm ci
- npm run report:merge
- npx ts-node scripts/notify.ts
artifacts:
paths: [reports/html]
expire_in: 30 days
Docker Support
Dockerfile
FROM cypress/included:13.14.2
WORKDIR /e2e
# Copy manifests first so the dependency layer caches independently of test code.
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY tsconfig.json cypress.config.ts ./
COPY config ./config
COPY plugins ./plugins
COPY scripts ./scripts
COPY cypress ./cypress
# Never run as root in a shared build environment.
RUN groupadd -r cypress && useradd -r -g cypress -G audio,video cypress \
&& mkdir -p /home/cypress /e2e/reports \
&& chown -R cypress:cypress /home/cypress /e2e
USER cypress
ENV CYPRESS_CACHE_FOLDER=/home/cypress/.cache/Cypress
ENTRYPOINT ["npx", "cypress", "run"]
CMD ["--browser", "chrome"]
docker-compose.yml
services:
cypress:
build:
context: ..
dockerfile: docker/Dockerfile
shm_size: 2gb
environment:
- CYPRESS_environment=${CY_ENV:-qa}
- CYPRESS_baseUrl=${BASE_URL:-http://app:8080}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- DB_CONNECTION_STRING=postgres://test:test@db:5432/appdb
volumes:
- ../reports:/e2e/reports
- ../cypress/screenshots:/e2e/cypress/screenshots
- ../cypress/videos:/e2e/cypress/videos
depends_on:
app:
condition: service_healthy
db:
condition: service_healthy
command: ["--browser", "chrome", "--env", "grepTags=@regression"]
app:
image: registry.corp.test/shop-web:${APP_TAG:-latest}
ports: ["8080:8080"]
environment:
- SPRING_PROFILES_ACTIVE=test
- DB_URL=jdbc:postgresql://db:5432/appdb
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 12
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=test
- POSTGRES_PASSWORD=test
- POSTGRES_DB=appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test -d appdb"]
interval: 5s
timeout: 3s
retries: 10
docker compose -f docker/docker-compose.yml up --abort-on-container-exit --exit-code-from cypress
Docker Best Practices
-
Always set
shm_size: 2gb. The default 64 MB of shared memory causes Chrome to crash mid-run with cryptic errors. This is the single most common Docker-Cypress failure. -
Pin the image tag.
cypress/included:latestwill silently upgrade Cypress and browsers between builds, and you will spend a day diagnosing a "regression" that was an image change. -
Layer for cache efficiency. Copy
package*.jsonand install before copying test code, so a spec edit does not invalidate the dependency layer. -
Use health checks and
depends_on: condition: service_healthy. Otherwise Cypress starts before the app is listening and every test fails on the firstcy.visit(). - Mount output directories as volumes so artifacts survive container teardown.
- Run as a non-root user. Many enterprise container platforms reject root containers outright.
-
Prefer
cypress/includedfor CI (Cypress preinstalled) andcypress/browserswhen you need custom install steps. -
Set
--exit-code-from cypressin compose so the pipeline actually fails when tests fail.
Coding Standards
Standards exist so that code review discusses design, not formatting.
Folder Naming
- Lowercase, no spaces, no underscores:
pageObjects,checkout,authentication. - camelCase for multi-word folders that hold code (
pageObjects,apiServices). - Domain names, not technical names:
checkout/, notmodule2/. - Depth of three levels maximum inside
e2e/. Beyond that, navigation cost exceeds organizational benefit.
File Naming
- Specs:
featureName.cy.ts, with optional layer suffix:orderLifecycle.api.cy.ts,criticalPath.smoke.cy.ts. - Page objects:
PascalCasePage.ts. - Components:
PascalCaseComponent.ts. - Utilities:
camelCaseUtil.tsorcamelCaseHelper.ts. - Fixtures:
camelCase.json, with environment suffix where applicable. - Never use spaces, uppercase extensions, or version numbers in filenames (
loginTest_v2_final.cy.tsis a code smell with a name).
Locator Naming
-
data-testvalues are kebab-case and hierarchical:checkout-payment-submit, notbtn1. - Prefix by feature so global search finds all related selectors: every checkout selector starts with
checkout-. - Selector maps in page objects use camelCase keys mapping to kebab-case values.
- Never encode styling or position:
submit-button, notblue-button-right.
Assertion Standards
- Prefer
should()overthen(() => expect())so retries work. - Every assertion carries a meaningful failure message when the default is unclear.
- One logical concern per
it(). Multiple assertions supporting one concern are fine; four unrelated concerns are not. - Assert on business-visible values, not implementation details. Assert the rendered total, not the Redux store shape.
- Never assert on auto-generated IDs or timestamps directly; assert on their format or relationship.
SOLID Applied to Test Frameworks
- Single Responsibility — a page object models one screen; a service models one API resource; a spec verifies one feature.
-
Open/Closed — extend
BasePageandBaseApiServicerather than editing them for each new page. Adding a page requires zero changes to existing files. -
Liskov Substitution — any subclass of
BasePagemust be usable whereverBasePageis expected. Ifvisit()in a subclass requires extra arguments, the hierarchy is wrong. -
Interface Segregation — do not force every page object to implement a fat interface. A read-only report page should not carry
submit(). -
Dependency Inversion — specs depend on service abstractions (
OrderService), not oncy.requestdetails. Swapping REST for GraphQL becomes a service-layer change, not a 400-spec rewrite.
Clean Code and DRY
- Functions do one thing and are named for that thing.
- Maximum function length ~25 lines; maximum file length ~300 lines.
- No magic numbers or strings — everything in
constants/. - Avoid comments that restate code; write comments that explain why.
- Delete dead tests. A skipped test that has been skipped for three months is not a test; it is a lie about coverage.
- DRY has a limit: do not abstract until the third occurrence. Premature abstraction in test code creates couplings that make specs harder to read than the duplication would have been. The rule is DRY for infrastructure (selectors, auth, HTTP), tolerant of duplication in scenario bodies where explicitness aids comprehension.
Common Framework Mistakes
Thirty-two failure modes observed repeatedly across enterprise Cypress implementations. For each: why it happens, what it costs, and how to fix it.
1. Using cy.wait() with fixed durations
Why it happens: It works locally and requires no thought.
Impact: Every wait is dead time multiplied by test count. A 3-second wait in a beforeEach across 400 tests is 20 minutes of pure waste — and still flakes on slow CI.
Fix: Replace with cy.intercept() aliases and assertion-driven waits. Add an ESLint rule banning numeric cy.wait.
2. Logging in through the UI in every test
Why it happens: It mirrors what a user does, which feels correct.
Impact: Multiplies suite runtime by 3–10x and couples every test to the login page's stability.
Fix: cy.session() plus API authentication. Keep two or three dedicated UI login tests.
3. Brittle CSS and XPath selectors
Why it happens: Copy-pasted from browser DevTools.
Impact: A CSS refactor breaks hundreds of tests overnight; the team spends a sprint on selector repair.
Fix: Mandate data-test attributes, enforce them in code review, centralize access in BasePage.el().
4. Order-dependent tests
Why it happens: Test 2 reuses the record created by test 1 because it is convenient.
Impact: Tests cannot be parallelized, retried individually, or run in isolation. Debugging requires running the whole suite.
Fix: Each test creates its own preconditions via API in beforeEach and cleans up in after.
5. Hardcoded URLs and credentials
Why it happens: No environment abstraction exists at the time the first test is written.
Impact: Environment switching requires a code change and a pull request; secrets leak into version control.
Fix: baseUrl from configuration, secrets from Cypress.env() populated by CI.
6. One giant commands.js
Why it happens: Custom commands are the path of least resistance, and no one splits the file.
Impact: Merge conflicts on every branch; loading cost on every spec; nobody knows what exists.
Fix: Split by concern into commands/, export from a barrel, add TypeScript declarations.
7. Assertions inside page objects
Why it happens: It reduces spec verbosity.
Impact: Business rules become invisible; page objects cannot be reused for negative-path tests.
Fix: Page objects return chainables; specs assert.
8. No TypeScript
Why it happens: JavaScript is faster to start with.
Impact: Typos surface at runtime in CI instead of at compile time; refactoring is unsafe; IntelliSense is useless.
Fix: Adopt TypeScript with strict: true from the beginning. Retrofitting later costs 10x more.
9. Testing everything through the UI
Why it happens: The team was told to "automate the regression suite," which was written as manual UI steps.
Impact: An inverted pyramid: slow, flaky, expensive coverage of logic that unit tests could verify in milliseconds.
Fix: Push validation down. Use UI tests for user journeys and rendering; use API tests for business rules.
10. Ignoring flaky tests
Why it happens: Retries make them green, so they stop being visible.
Impact: Trust erodes. Real regressions get dismissed as "just the flaky one."
Fix: Track retry-passes in reporting, open a ticket per flaky test, quarantine tests that flake more than twice in a week.
11. Committing artifacts to Git
Why it happens: No .gitignore on day one.
Impact: Repository size explodes; clones take minutes; diffs become unreadable.
Fix: .gitignore covering screenshots, videos, downloads, reports, node_modules, and .env.
12. No CI integration until "the framework is ready"
Why it happens: A desire to present finished work.
Impact: The first CI run reveals dozens of environment assumptions baked in from local development. Weeks of rework.
Fix: Wire CI on day one with a single passing test, then grow.
13. Overusing custom commands
Why it happens: Custom commands feel like the "Cypress way" to share code.
Impact: The global cy namespace becomes a junk drawer of 80 commands; discoverability collapses.
Fix: Custom commands only for queue-participating, cross-cutting operations. Everything else is a function or service.
14. Not stubbing third-party dependencies
Why it happens: "We should test the real integration."
Impact: Analytics, chat widgets, ad networks, and CAPTCHA services introduce flakiness that has nothing to do with your product.
Fix: Block or stub third-party requests by default; test real integrations in a separate, non-blocking nightly job.
15. Sharing test data between tests and environments
Why it happens: A "golden" test account is created once and reused.
Impact: Parallel runs collide; one test corrupts state for another; failures are non-reproducible.
Fix: Unique data per test via factories, with a run-scoped prefix for cleanup.
16. Asserting on unstable text
Why it happens: Copy is the most visible thing on screen.
Impact: Every marketing copy change breaks tests; i18n rollout breaks everything.
Fix: Assert on data-test attributes, keys, or centralized message constants that the application also consumes.
17. Using .then() for everything
Why it happens: Familiarity with Promises.
Impact: Loses automatic retry, produces callback pyramids, and makes failures harder to read.
Fix: Prefer chained should()/and(). Use .then() only when a value must cross into imperative code.
18. Not handling uncaught application exceptions deliberately
Why it happens: The team adds a blanket return false to stop noise.
Impact: Real JavaScript errors in the application are silently swallowed; the suite reports green while users see a broken page.
Fix: Allowlist specific known-benign messages only; fail on everything else.
19. No linting or formatting
Why it happens: "It's just test code."
Impact: Style debates in review; inconsistent patterns; a codebase that repels contributors.
Fix: ESLint with eslint-plugin-cypress, Prettier, and a pre-commit hook. --max-warnings=0 in CI.
20. Giant spec files
Why it happens: Related tests naturally accumulate in one file.
Impact: Cannot parallelize; one failure obscures the rest; memory pressure in a long-running spec.
Fix: Target 60–180 seconds per spec. Split by scenario family.
21. No cleanup strategy
Why it happens: Tests create data and no one deletes it.
Impact: QA databases balloon to millions of orphaned records; search results degrade; tests slow down over months.
Fix: after hooks that delete via API, plus a scheduled cleanup job that purges records matching the automation prefix.
22. Testing implementation details
Why it happens: It is easy to assert on internal state.
Impact: Tests break on refactors that change nothing users can see.
Fix: Assert observable behaviour: rendered output, network calls, URL changes, persisted state.
23. Missing negative and edge-case coverage
Why it happens: Happy paths are what get specified.
Impact: Production incidents in error handling, validation, and permission boundaries — the code least exercised by manual testing too.
Fix: For every happy path, define at least one validation failure, one authorization failure, and one backend-error scenario (easy with cy.intercept()).
24. Not using aliases
Why it happens: Variables feel more natural.
Impact: Values captured outside the command queue are stale; tests behave unpredictably.
Fix: .as() and cy.get('@alias') for anything crossing command boundaries.
25. Running the full suite on every commit
Why it happens: It seems safest.
Impact: 45-minute PR feedback loops; developers stop waiting and merge on green-ish.
Fix: Tiered execution — smoke on every commit (under 5 minutes), regression on merge to main, full cross-browser nightly.
26. No ownership model
Why it happens: The framework belongs to "QA."
Impact: Failures have no assignee; broken tests linger for weeks.
Fix: CODEOWNERS mapping spec directories to squads. The squad that owns the feature owns its tests.
27. Skipping tests instead of fixing or deleting them
Why it happens: A test breaks at an inconvenient time.
Impact: Coverage metrics lie. Skipped tests accumulate silently.
Fix: A skipped test requires a linked ticket and an expiry date. CI fails if the count of skipped tests exceeds a threshold.
28. No handling for eventual consistency
Why it happens: The team assumes a write is immediately readable.
Impact: Intermittent failures in search, reporting, and anything behind a message queue or a read replica.
Fix: Poll with cy.request inside a retryable should(), with a bounded, explicit timeout — and document why the wait exists.
29. Mixing test types in one spec
Why it happens: Convenience.
Impact: A spec that mixes API, UI, and visual checks cannot be scheduled, tagged, or triaged coherently.
Fix: Separate directories and tags for API, UI, smoke, contract, and accessibility suites.
30. Not versioning the framework
Why it happens: It lives in the same repo as tests.
Impact: Multiple teams cannot adopt improvements at their own pace; a breaking change to a shared command breaks everyone at once.
Fix: For multi-team organizations, publish the framework core as a private npm package with semantic versioning; teams upgrade deliberately.
31. Treating the framework README as optional
Why it happens: The original authors know how it works.
Impact: Onboarding takes weeks; tribal knowledge walks out the door with attrition.
Fix: A README covering setup, environment switching, tagging, how to add a page object, and how to debug a CI failure. Review it quarterly.
32. No performance budget for the suite
Why it happens: Nobody defined one.
Impact: Runtime creeps up 30 seconds per sprint until the suite is a bottleneck, and no single commit is to blame.
Fix: Set an explicit budget (for example, smoke under 5 minutes, regression under 20). Fail the build if the budget is exceeded, and treat it as a defect.
Enterprise Best Practices
Eighty practices, grouped by concern. Adopt them incrementally; each is independently valuable.
Architecture
- Separate the three layers strictly: specs (what), page objects and services (how), utilities (support).
- Model components, not just pages — shared UI fragments deserve their own objects.
- Build a
BasePageandBaseApiServiceand inherit from them universally. - Centralize selector construction in exactly one method so the selector strategy can change in one line.
- Keep
cypress.config.tsdeclarative; push logic intoplugins/. - Enforce a one-way dependency graph: specs depend on support; support never imports from specs.
- Prefer composition over deep inheritance chains; two levels is usually enough.
- Write an Architecture Decision Record for every significant choice, including "why not Playwright."
- Define the framework's public API explicitly and treat changes to it as breaking changes.
- Keep Node-side code (plugins, scripts) physically outside
cypress/.
Scalability
- Design for many small spec files, since specs are the unit of parallelism.
- Tag every test (
@smoke,@regression,@critical,@cross-browser) so suites are composable. - Use duration-weighted sharding rather than round-robin distribution.
- Cache the Cypress binary and
node_modulesin CI. - Make every test independently runnable — this is the precondition for all scaling.
- Ensure test data generation scales: unique identifiers, no shared fixtures for mutable records.
- Set explicit worker limits that respect the capacity of the environment under test.
- Keep
numTestsKeptInMemorylow in CI (10–20) to control memory growth. - Enable
experimentalMemoryManagementfor long-running Chromium jobs. - Split very large monorepo suites into independently deployable test packages.
Maintainability
- TypeScript with
strict: true, noanyin framework code. - Declare types for every custom command.
- One responsibility per file; enforce a maximum file length in review.
- Name tests as behavioural statements: "rejects an expired coupon at checkout."
- Keep specs free of selectors, URLs, credentials, and literal messages.
- Centralize all constants and reference them everywhere.
- Delete dead code and dead tests aggressively.
- Refactor test code on the same cadence as production code.
- Maintain a living README plus a
CONTRIBUTING.mdfor the framework. - Use JSDoc on every exported utility and command.
- Prefer explicit duplication in scenario bodies over clever abstractions that obscure intent.
- Run the linter and typechecker in CI with zero tolerance for warnings.
- Keep dependencies current with Renovate or Dependabot, on a scheduled merge cadence.
- Pin the Cypress version exactly; upgrade deliberately with a dedicated pull request.
Security
- Never commit secrets; use CI secret stores or a vault resolved in
setupNodeEvents. - Add
{ log: false }to every command that touches a credential. - Commit a
.env.examplewith keys and blank values; git-ignore.env. - Block destructive database operations by environment configuration, not by convention.
- Restrict the production
specPatternto a read-only smoke suite. - Use dedicated automation service accounts with least-privilege scopes.
- Rotate automation credentials on the organization's standard schedule.
- Never use real customer data; generate synthetic data that satisfies the same validation rules.
- Scan the repository for secrets in CI with
gitleaksor an equivalent. - Redact tokens and PII from logs, screenshots, and videos before artifact upload.
- Restrict who can trigger pipelines targeting UAT, STAGING, and PROD.
- Audit third-party Cypress plugins before adoption; they execute with full Node privileges.
Reporting
- Emit JUnit XML always — every CI system consumes it natively.
- Generate a single self-contained HTML report per run.
- Include environment, build number, branch, and commit SHA in the report title.
- Track flake rate and retry-passes as first-class metrics, not footnotes.
- Publish trend history (Allure) so leadership sees direction, not just a snapshot.
- Link failing tests to the corresponding screenshot, video, and log lines.
- Notify on failure and on recovery only; suppress routine green notifications.
- Include a correlation ID in every API request so test failures can be traced in application logs.
- Keep a dashboard of the top ten slowest specs and review it every sprint.
Performance
- Authenticate via API and cache sessions across specs.
- Seed test preconditions via API or database, never through the UI.
- Stub third-party and non-essential network calls.
- Disable video in pull request pipelines; delete videos for passing specs elsewhere.
- Use
cy.intercept()waits instead of arbitrary delays. - Reduce
defaultCommandTimeoutonce the suite is stable; long timeouts hide slowness. - Avoid
cy.visit()when the page is already loaded and only state needs resetting. - Prefer element-scoped queries (
.within()) over repeated document-wide searches. - Set a suite performance budget and enforce it in CI.
Test Data
- Generate unique data per test with a run-scoped prefix.
- Clean up created records in
afterhooks and via a scheduled purge job. - Keep fixture shape identical across environments so specs never branch on environment.
- Validate fixtures against JSON Schema in CI.
- Prefer querying for suitable data over depending on fixed seed records, with creation as a fallback.
- Never store secrets or production PII in fixtures.
CI/CD
- Integrate CI from the first commit.
- Run tiered suites: smoke per commit, regression per merge, full matrix nightly.
- Fail the build on test failure — a non-blocking suite is a suite nobody fixes.
- Cancel superseded pipeline runs with a concurrency group.
- Upload artifacts with
if: always()so failures produce evidence. - Merge parallel results in a dedicated fan-in job.
- Make pipeline definitions reviewable code, not UI configuration.
Version Control
- Enforce trunk-based development with short-lived branches for test code.
- Use conventional commits so changelogs and release notes generate automatically.
- Apply CODEOWNERS so spec directories map to owning squads.
- Require green smoke tests as a merge gate.
Code Review
- Review test code with the same rigour as production code.
- Reject any pull request introducing
cy.wait(number), hardcoded credentials, or selectors in specs. - Ask "will this fail for a good reason?" — a test that cannot fail meaningfully should not exist.
- Verify that new tests are independently runnable before approving.
Performance Optimization
Reducing Flaky Tests
Flakiness has four dominant root causes. Address them in order of frequency:
- Timing and synchronization (roughly 60% of cases). Fixed waits, missing intercept waits, assertions racing async rendering. Fix with assertion-driven and network-driven waiting.
- Test data collisions (roughly 20%). Shared records, non-unique identifiers, missing cleanup. Fix with per-test data generation.
- Environment instability (roughly 15%). Backend deployments mid-run, exhausted connection pools, third-party outages. Fix with deployment windows, health-check gates before the suite starts, and stubbing of third parties.
- Genuine application race conditions (roughly 5%). These are real defects. File them as such.
Instrument flakiness rather than guessing:
// cypress/support/e2e.ts
afterEach(function () {
const test = this.currentTest;
if (test && test.state === 'passed' && (test as any).currentRetry > 0) {
cy.task('log:write', {
level: 'WARN',
message: 'FLAKY_TEST',
spec: Cypress.spec.relative,
title: test.fullTitle(),
attemptsUsed: (test as any).currentRetry + 1,
}, { log: false });
}
});
Aggregate FLAKY_TEST entries across runs. Any test appearing three weeks running gets quarantined and ticketed.
Faster Execution
Ranked by impact per unit of effort:
-
API-based authentication with
cy.session. Typically 40–60% reduction in total runtime. - API-based test data setup. A 20-step UI wizard replaced by one POST saves minutes per test.
- Parallelization with duration-weighted sharding. Linear reduction in wall-clock time.
- Disabling video in PR pipelines. 10–20% reduction.
- Stubbing slow third-party calls. Removes an unpredictable tail latency.
-
Reducing spec count churn — avoid
cy.visit()between tests when a state reset suffices. -
Lowering
defaultCommandTimeoutfrom 10s to 6s once stable, so failures fail fast.
Stable Locators
-
data-testattributes as a definition-of-done requirement. - Selector centralization in
BasePage. - A CI check that fails if a spec file contains a raw
cy.get('.orcy.get('#call. - A quarterly selector audit: search for the most-referenced selectors and confirm they are still owned by the right component.
Retry Strategy
retries: {
runMode: 2, // CI: absorb genuine infrastructure blips
openMode: 0, // Local: surface every problem immediately
}
Rules:
- Retries are a safety net for infrastructure, not a fix for design.
- Never exceed three retries; beyond that you are hiding a defect.
- Instrument every retry-pass.
- Override retries per-suite only with a written justification in a code comment.
Network Stubbing for Speed and Determinism
beforeEach(() => {
// Kill non-essential traffic that adds latency and flakiness.
cy.intercept({ url: /google-analytics|segment\.io|hotjar|doubleclick/ }, { statusCode: 204, body: {} });
cy.intercept('GET', '**/api/v1/feature-flags', { fixture: 'config/featureFlags.json' });
});
it('renders a 500-row grid without calling the real reporting service', () => {
cy.intercept('GET', '**/api/v1/reports/large*', { fixture: 'reports/largeDataset.json' }).as('report');
cy.visit('/reports');
cy.wait('@report');
cy.getByTestId('grid-row').should('have.length', 500);
});
Stubbing turns a 12-second, backend-dependent test into a 900-millisecond, deterministic one. The trade-off is contract drift: if the backend changes its response shape, the stub keeps passing. Mitigate by running schema-validated contract tests against the real API on a schedule, so the stub and the contract are verified separately.
Measuring Improvements
// cypress.config.ts — inside setupNodeEvents
const durations: Array<{ spec: string; ms: number; tests: number }> = [];
on('after:spec', (spec, results) => {
durations.push({ spec: spec.relative, ms: results.stats.duration ?? 0, tests: results.stats.tests ?? 0 });
});
on('after:run', () => {
const sorted = [...durations].sort((a, b) => b.ms - a.ms);
const total = durations.reduce((sum, d) => sum + d.ms, 0);
console.log(`Total: ${Math.round(total / 1000)}s across ${durations.length} specs`);
console.log('Slowest 10 specs:');
sorted.slice(0, 10).forEach((d) => console.log(` ${Math.round(d.ms / 1000)}s ${d.spec}`));
fs.writeFileSync('reports/durations.json', JSON.stringify(sorted, null, 2));
});
reports/durations.json becomes the input for duration-weighted sharding, closing the loop between measurement and optimization.
Folder-by-Folder Deep Dive
cypress/e2e/
- Purpose: Contains every executable test specification.
- Responsibilities: Express business scenarios; assert business outcomes; nothing else.
-
Typical files:
login.cy.ts,orderLifecycle.api.cy.ts,criticalPath.smoke.cy.ts. -
Real-world usage: Squad-owned subdirectories mapped through CODEOWNERS. The
smoke/directory is referenced directly by the deployment pipeline's gate. -
Dependencies:
support/pageObjects,support/services,support/utils,constants,fixtures. - Best practices: Tag every describe block; one feature per file; never import Node modules.
// cypress/e2e/ui/checkout/payment.cy.ts
import { cartPage } from '../../../support/pageObjects/checkout/CartPage';
import { paymentPage } from '../../../support/pageObjects/checkout/PaymentPage';
import { orderService } from '../../../support/services/OrderService';
import { buildOrder } from '../../../support/utils/factories';
describe('Checkout :: Payment', { tags: ['@regression', '@checkout'] }, () => {
let orderId: string;
beforeEach(() => {
cy.loginAs('standard');
orderService.create(buildOrder()).its('body.id').then((id: string) => {
orderId = id;
cartPage.visitOrder(orderId);
});
});
it('completes payment with a saved card and confirms the order', () => {
cy.intercept('POST', '**/api/v1/payments').as('pay');
cartPage.proceedToPayment();
paymentPage.selectSavedCard('Visa ending 4242').submit();
cy.wait('@pay').its('response.statusCode').should('eq', 201);
cy.getByTestId('confirmation-banner').should('be.visible');
orderService.getById(orderId).its('body.status').should('eq', 'CONFIRMED');
});
it('surfaces a recoverable error when the payment gateway times out', () => {
cy.intercept('POST', '**/api/v1/payments', {
statusCode: 504,
body: { errorCode: 'GATEWAY_TIMEOUT' },
}).as('payFail');
cartPage.proceedToPayment();
paymentPage.selectSavedCard('Visa ending 4242').submit();
cy.wait('@payFail');
cy.getByTestId('payment-error').should('contain', 'try again');
cy.getByTestId('retry-payment').should('be.enabled');
});
afterEach(() => {
if (orderId) orderService.remove(orderId);
});
});
cypress/fixtures/
- Purpose: Static and reference test data plus JSON Schemas.
- Responsibilities: Provide deterministic inputs and stub payloads.
-
Typical files:
users/users.qa.json,orders/orderList.json,schemas/order.schema.json. -
Real-world usage: Stub payloads for
cy.intercept(); reference matrices for data-driven suites. - Dependencies: None. Fixtures depend on nothing, which is why they are safe to share.
- Best practices: Domain subfolders; small files; schema-validated in CI; zero secrets.
cypress/support/
- Purpose: The framework library.
- Responsibilities: Global hooks, command registration, shared abstractions, type declarations.
-
Typical files:
e2e.ts,component.ts, plus thecommands/,pageObjects/,services/,utils/,types/subtrees. -
Real-world usage: Every spec implicitly loads
e2e.ts. Any code placed here executes before every test — keep it cheap. -
Dependencies:
constants/, third-party plugins. - Best practices: Thin entry file; no network calls at import time; deterministic global hooks.
cypress/support/commands/
-
Purpose: Domain verbs on the
cynamespace. - Responsibilities: Authentication, API access, database access, common UI interactions, custom assertions.
-
Typical files:
auth.commands.ts,api.commands.ts,db.commands.ts,ui.commands.ts,index.ts. -
Dependencies:
constants/endpoints,utils/logger,types/global.d.ts. - Best practices: One concern per file; always declare types; return chainables so commands compose.
cypress/support/pageObjects/
- Purpose: Encapsulate selectors and interactions.
- Responsibilities: Expose intent-revealing methods; hide DOM structure entirely.
-
Typical files:
BasePage.ts,authentication/LoginPage.ts,components/HeaderComponent.ts. - Real-world usage: A design-system upgrade that changes markup across the product touches only this folder.
-
Dependencies:
constants/,BasePage. -
Best practices: Fluent interfaces returning
this; export a singleton instance for ergonomics; no assertions about business rules.
cypress/support/services/
- Purpose: Typed API client layer.
- Responsibilities: One class per API resource; handle serialization, headers, and paths.
-
Typical files:
BaseApiService.ts,OrderService.ts,AuthService.ts,UserService.ts. - Real-world usage: Used by UI specs for setup and by API specs for verification — one abstraction serving both.
-
Dependencies:
cy.apiRequest,constants/endpoints,types/models. -
Best practices: Return typed
Cypress.Response<T>; never assert inside a service; keep one class per resource.
cypress/support/utils/
- Purpose: Pure helpers.
- Responsibilities: Date math, randomness, formatting, schema validation, logging, file assertions.
-
Typical files:
dateUtil.ts,randomGenerator.ts,schemaValidator.ts,logger.ts,fileHelper.ts,factories.ts. - Dependencies: Ideally none beyond typed third-party libraries.
- Best practices: Pure functions; unit-tested with Jest or Vitest independently of Cypress; no hidden global state.
cypress/constants/
- Purpose: Eliminate magic values.
- Responsibilities: Endpoints, timeouts, roles, messages, feature flag keys.
-
Typical files:
endpoints.ts,timeouts.ts,messages.ts,roles.ts,index.ts. - Dependencies: None.
-
Best practices:
as consteverywhere; split by domain past 150 lines; never mix constants with logic.
config/
- Purpose: Per-environment non-secret configuration.
- Responsibilities: Base URLs, feature flags, timeouts, retry policy, spec pattern restrictions.
-
Typical files:
cypress.qa.jsonthroughcypress.prod.json. -
Real-world usage: A single
--env environment=uatflag switches the entire suite. -
Dependencies: Consumed by
cypress.config.ts. - Best practices: Identical key shape across environments; production config is intentionally the most restrictive.
plugins/
- Purpose: Privileged Node-side capability.
- Responsibilities: Database access, file system operations, cryptography, secret resolution, report hooks.
-
Typical files:
dbPlugin.ts,filePlugin.ts,cryptoPlugin.ts,excelPlugin.ts. -
Dependencies:
pg,xlsx, Node built-ins. - Best practices: Small, audited surface; environment guards on every destructive operation; connection pooling with explicit teardown.
// plugins/filePlugin.ts
import fs from 'fs';
import path from 'path';
export function clearDirectory(dir: string): null {
const resolved = path.resolve(dir);
if (!resolved.includes('cypress') && !resolved.includes('reports')) {
throw new Error(`Refusing to clear directory outside the project: ${resolved}`);
}
if (fs.existsSync(resolved)) {
for (const entry of fs.readdirSync(resolved)) {
fs.rmSync(path.join(resolved, entry), { recursive: true, force: true });
}
} else {
fs.mkdirSync(resolved, { recursive: true });
}
return null;
}
export function appendLog(payload: Record<string, unknown>): null {
const logDir = path.resolve('reports/logs');
fs.mkdirSync(logDir, { recursive: true });
fs.appendFileSync(path.join(logDir, 'e2e.ndjson'), `${JSON.stringify(payload)}\n`);
return null;
}
reports/
- Purpose: Generated output only.
- Responsibilities: Hold Mochawesome JSON, JUnit XML, Allure results, merged HTML, NDJSON logs, duration data.
- Real-world usage: Uploaded as CI artifacts; consumed by dashboards and the sharding script.
- Best practices: Git-ignored; cleared before every run; retention policy per pipeline tier.
cypress/downloads/
- Purpose: Destination for files downloaded during tests.
- Responsibilities: Enable file assertions after export actions.
-
Best practices: Cleared in
beforeEachof any download test; never asserted with a fixed sleep — usecy.readFilewith a timeout, which retries.
cypress/screenshots/ and cypress/videos/
- Purpose: Failure evidence.
- Responsibilities: Provide visual context for triage.
-
Best practices: Git-ignored; passing-spec videos deleted in
after:spec; artifacts named with build, environment, and shard.
Complete End-to-End Example
This section assembles everything into a single coherent slice: an order management application, tested end to end.
Folder Structure for the Slice
cypress/
├── e2e/
│ ├── smoke/
│ │ └── orderCriticalPath.smoke.cy.ts
│ ├── api/
│ │ └── orders/
│ │ └── createOrder.api.cy.ts
│ └── ui/
│ └── orders/
│ └── createOrderJourney.cy.ts
├── fixtures/
│ ├── users/users.qa.json
│ ├── orders/catalog.json
│ └── schemas/order.schema.json
└── support/
├── commands/
├── pageObjects/
│ └── orders/
│ ├── OrderListPage.ts
│ └── OrderFormPage.ts
├── services/
│ └── OrderService.ts
└── utils/
└── factories.ts
Configuration
Already shown in the Environment Configuration section: cypress.config.ts plus config/cypress.qa.json. The slice requires no additional configuration — a sign the architecture is working.
Page Objects
// cypress/support/pageObjects/orders/OrderListPage.ts
import { BasePage } from '../BasePage';
import { DataGridComponent } from '../components/DataGridComponent';
export class OrderListPage extends BasePage {
protected readonly path = '/orders';
protected readonly readyIndicator = '[data-test="orders-grid"]';
readonly grid = new DataGridComponent('orders-grid');
private readonly selectors = {
createButton: 'orders-create',
searchInput: 'orders-search',
statusFilter: 'orders-status-filter',
emptyState: 'orders-empty-state',
} as const;
startNewOrder(): this {
this.el(this.selectors.createButton).should('be.enabled').click();
return this;
}
search(term: string): this {
this.el(this.selectors.searchInput).clear().type(`${term}{enter}`);
return this;
}
filterByStatus(status: string): this {
cy.selectFromDropdown(this.selectors.statusFilter, status);
return this;
}
getEmptyStateMessage(): Cypress.Chainable<string> {
return this.el(this.selectors.emptyState).invoke('text');
}
}
export const orderListPage = new OrderListPage();
// cypress/support/pageObjects/orders/OrderFormPage.ts
import { BasePage } from '../BasePage';
export interface LineItemInput {
sku: string;
quantity: number;
}
export class OrderFormPage extends BasePage {
protected readonly path = '/orders/new';
protected readonly readyIndicator = '[data-test="order-form"]';
private readonly selectors = {
customerSearch: 'order-customer-search',
customerOption: 'order-customer-option',
addLineItem: 'order-add-line-item',
skuInput: 'order-line-sku',
quantityInput: 'order-line-quantity',
total: 'order-total',
submit: 'order-submit',
validationSummary: 'order-validation-summary',
} as const;
selectCustomer(name: string): this {
this.el(this.selectors.customerSearch).clear().type(name);
cy.getByTestId(this.selectors.customerOption).contains(name).click();
return this;
}
addLineItems(items: LineItemInput[]): this {
items.forEach((item, index) => {
this.el(this.selectors.addLineItem).click();
cy.get(`[data-test="${this.selectors.skuInput}"]`).eq(index).clear().type(item.sku);
cy.get(`[data-test="${this.selectors.quantityInput}"]`).eq(index).clear().type(String(item.quantity));
});
return this;
}
getTotal(): Cypress.Chainable<number> {
return this.el(this.selectors.total)
.invoke('text')
.then((text) => Number(text.replace(/[^0-9.]/g, '')));
}
submit(): this {
this.el(this.selectors.submit).should('be.enabled').click();
return this;
}
getValidationErrors(): Cypress.Chainable<string[]> {
return this.el(this.selectors.validationSummary)
.find('li')
.then(($items) => Cypress._.map($items.toArray(), (el) => el.textContent?.trim() ?? ''));
}
}
export const orderFormPage = new OrderFormPage();
The API Spec
// cypress/e2e/api/orders/createOrder.api.cy.ts
import { orderService } from '../../../support/services/OrderService';
import { buildOrder } from '../../../support/utils/factories';
import { validateSchema } from '../../../support/utils/schemaValidator';
import orderSchema from '../../../fixtures/schemas/order.schema.json';
describe('Orders API :: creation', { tags: ['@api', '@regression'] }, () => {
const createdIds: string[] = [];
before(() => cy.loginAs('standard'));
it('creates an order and returns a schema-conformant payload', () => {
orderService
.create(buildOrder())
.expectStatus(201)
.then((res) => {
validateSchema(orderSchema, res.body, 'POST /orders');
createdIds.push(res.body.id);
});
});
it('calculates the total as the sum of line item extensions', () => {
const payload = buildOrder({
lineItems: [
{ sku: 'SKU-1001', quantity: 2, unitPrice: 49.5 },
{ sku: 'SKU-1002', quantity: 1, unitPrice: 101.0 },
],
});
orderService
.create(payload)
.expectStatus(201)
.then((res) => {
expect(res.body.total).to.eq(200);
createdIds.push(res.body.id);
});
});
it('rejects an order with no line items and returns field-level errors', () => {
orderService
.create(buildOrder({ lineItems: [] }))
.expectStatus(422)
.then((res) => {
expect(res.body.errorCode).to.eq('VALIDATION_FAILED');
expect(res.body.fieldErrors).to.deep.include({
field: 'lineItems',
message: 'At least one line item is required',
});
});
});
it('rejects an unauthenticated request with 401', () => {
cy.request({
method: 'POST',
url: `${Cypress.env('apiBaseUrl')}/api/v1/orders`,
body: buildOrder(),
failOnStatusCode: false,
headers: { 'Content-Type': 'application/json' },
})
.its('status')
.should('eq', 401);
});
after(() => {
createdIds.forEach((id) => orderService.remove(id));
});
});
The UI Journey Spec
// cypress/e2e/ui/orders/createOrderJourney.cy.ts
import { orderListPage } from '../../../support/pageObjects/orders/OrderListPage';
import { orderFormPage } from '../../../support/pageObjects/orders/OrderFormPage';
import { orderService } from '../../../support/services/OrderService';
describe('Orders UI :: creation journey', { tags: ['@regression', '@orders'] }, () => {
let createdOrderId: string | undefined;
beforeEach(() => {
cy.loginAs('standard');
cy.intercept('GET', '**/api/v1/orders*').as('loadOrders');
orderListPage.visit();
cy.wait('@loadOrders');
});
it('creates a two-line order and shows it in the grid with the correct total', () => {
cy.intercept('POST', '**/api/v1/orders').as('createOrder');
orderListPage.startNewOrder();
orderFormPage
.waitUntilReady()
.selectCustomer('Northwind Traders')
.addLineItems([
{ sku: 'SKU-1001', quantity: 2 },
{ sku: 'SKU-1002', quantity: 1 },
]);
orderFormPage.getTotal().should('be.greaterThan', 0);
orderFormPage.submit();
cy.wait('@createOrder').then(({ request, response }) => {
expect(request.body.lineItems).to.have.length(2);
expect(response?.statusCode).to.eq(201);
createdOrderId = response?.body.id;
cy.getByTestId('confirmation-banner').should('contain', createdOrderId);
orderListPage.visit();
orderListPage.grid.rowByText(createdOrderId as string).should('be.visible');
orderListPage.grid.cellValue(createdOrderId as string, 'status').should('eq', 'DRAFT');
});
});
it('blocks submission and lists every validation error when required fields are missing', () => {
orderListPage.startNewOrder();
orderFormPage.waitUntilReady();
cy.getByTestId('order-submit').click({ force: true });
orderFormPage.getValidationErrors().should((errors) => {
expect(errors).to.include('Customer is required');
expect(errors).to.include('At least one line item is required');
});
cy.url().should('include', '/orders/new');
});
it('shows an empty state when a search matches nothing', () => {
orderListPage.search('ZZZ-NO-SUCH-ORDER-ZZZ');
orderListPage.getEmptyStateMessage().should('contain', 'No orders match');
});
afterEach(() => {
if (createdOrderId) {
orderService.remove(createdOrderId);
createdOrderId = undefined;
}
});
});
The Smoke Spec
// cypress/e2e/smoke/orderCriticalPath.smoke.cy.ts
describe('Smoke :: critical path', { tags: ['@smoke'] }, () => {
it('serves the application shell and an authenticated dashboard', () => {
cy.loginAs('standard');
cy.visit('/');
cy.getByTestId('app-shell').should('be.visible');
cy.getByTestId('user-menu').should('be.visible');
});
it('returns a healthy status from the orders API', () => {
cy.apiRequest({ method: 'GET', url: '/api/v1/health' })
.expectStatus(200)
.its('body.status')
.should('eq', 'UP');
});
it('loads the orders grid within the performance budget', () => {
cy.loginAs('standard');
cy.intercept('GET', '**/api/v1/orders*').as('orders');
cy.visit('/orders');
cy.wait('@orders').its('duration').should('be.lessThan', 3000);
cy.getByTestId('orders-grid').should('be.visible');
});
});
Execution
# Local development against QA, interactive
npm run cy:open:qa
# Smoke suite, headless, five minutes or less
npx cypress run --env environment=qa,grepTags=@smoke
# Full regression against UAT with four parallel shards
SHARD_TOTAL=4 SHARD_INDEX=0 npx cypress run --env environment=uat,grepTags=@regression
# Containerized run with the application and database
docker compose -f docker/docker-compose.yml up --abort-on-container-exit --exit-code-from cypress
Reports
npm run clean
npx cypress run --env environment=qa
npm run report:merge
open reports/html/e2e-report.html
The pipeline produces four artifacts per run: a self-contained HTML report, JUnit XML for the CI test tab, failure screenshots and videos, and reports/durations.json feeding the next run's sharding. That closed loop — measure, shard, execute, report, measure — is what separates a framework from a folder full of tests.
Interview Questions
Fifty advanced questions with the answers a Test Architect would expect.
1. Explain Cypress's architecture and why it differs from Selenium.
Selenium runs outside the browser and communicates over the WebDriver protocol; every command is a serialized round trip. Cypress runs a Node process that proxies traffic and controls the browser, while the test code itself executes inside the browser alongside the application. This gives Cypress direct access to the DOM, window, network layer, and application internals with no serialization boundary. The trade-off is that Cypress inherits the browser's security model — hence single-tab and same-origin constraints.
2. What is the Cypress command queue, and why does it confuse newcomers?
cy.* calls do not execute when invoked; they enqueue commands that run after the test body finishes. Consequently, synchronous code interleaved with Cypress commands executes first, in the wrong logical order. Values must be extracted with .then() or aliases rather than assigned to variables inline. Understanding this one mechanism eliminates most beginner bugs.
3. Which Cypress commands retry and which do not?
Query commands (get, find, contains, its, invoke) and assertions retry automatically until they pass or time out. Action commands (click, type, select, check) do not retry — they execute once against the resolved subject, though they do wait for actionability first. cy.request and cy.task do not retry. Knowing the boundary tells you where to place assertions to gain retryability.
4. How does cy.session() work and why does it matter at enterprise scale?
cy.session(id, setup, options) runs the setup function once per unique id, then caches cookies, localStorage, and sessionStorage. Subsequent calls restore the cached state instead of re-authenticating. With cacheAcrossSpecs: true, the cache survives across spec files in the same run. On a suite of several hundred tests, this typically removes 40–60% of total runtime.
5. What does the validate option in cy.session() do?
validate runs after a session is restored. If it throws or fails an assertion, Cypress discards the cache and re-runs the setup function. This handles expired tokens and server-side session invalidation without manual cache busting. Without it, a stale cached session produces confusing mid-suite 401 failures.
6. Differentiate cy.intercept() spying, stubbing, and modification.
Passing only a route matcher creates a spy: the request proceeds to the server and is observable via an alias. Passing a static response or fixture stubs it: the request never reaches the server. Passing a handler function lets you inspect and mutate the request, and req.continue(res => ...) lets you send the real request and mutate the real response. All three are useful; stubbing gives speed and determinism, spying gives contract fidelity.
7. When should you stub the network and when should you not?
Stub for deterministic UI states, error paths, edge-case data, third-party services, and speed. Do not stub in the tests whose purpose is to verify integration with the real backend — critical journeys and contract tests. A balanced suite stubs aggressively in UI tests and validates the real contract in a separate API suite with schema validation.
8. How do you handle authentication in a large Cypress suite?
Authenticate through the API with a token grant, store the token, and wrap it in cy.session keyed by role and environment. Provide a cy.loginAs(role) command that pulls credentials from Cypress.env(). Keep two or three dedicated UI login tests so the login form itself is covered. Never place a UI login in a global beforeEach.
9. How do you manage multiple environments?
Keep one JSON config per environment holding non-secret values, load it in setupNodeEvents based on an environment flag, and merge process environment variables on top so CI secrets win. Restrict specPattern in the production config so only smoke tests can run there. The result: a single --env environment=uat switches everything.
10. Where do secrets live, and where must they never live?
Secrets live in the CI secret store or a vault, injected as process environment variables and read in setupNodeEvents. They must never live in fixtures, config JSON, spec files, or .env files committed to the repository. Any command handling them uses { log: false } so they do not appear in the command log, screenshots, or video.
11. Explain the Page Object Model's role and its main anti-pattern.
Page objects centralize selectors and interactions so a UI change touches one file. The dominant anti-pattern is placing business assertions inside page objects, which hides the rule being verified and prevents reuse in negative-path tests. Page objects answer "how do I interact"; specs answer "what should be true."
12. When is a component object better than a page object?
When a UI fragment appears on multiple pages — headers, modals, data grids, date pickers. Parameterize the component with a root selector so the same class serves every instance. This eliminates the most common source of duplication in mature suites.
13. Why should specs be small?
Cypress parallelizes at the spec level. A single spec with 300 tests pins one worker while others idle, cannot be retried granularly, and increases browser memory pressure. Target 60–180 seconds per spec.
14. How do you achieve parallel execution without Cypress Cloud?
Shard the spec list across workers using an index and total, passed as environment variables, and run cypress run --spec with each worker's slice. Upgrade from round-robin to duration-weighted bin packing using timings persisted from previous runs. Merge reports in a dedicated fan-in job.
15. What is the difference between test retries and fixing flakiness?
Retries re-run a failed test from its beforeEach and mask intermittent failures. They are appropriate for genuine infrastructure blips but are not a fix — a test that only passes on attempt three is still broken. Instrument retry-passes, report them, and treat each as a defect with an owner.
16. What are the four dominant root causes of flakiness?
Synchronization problems, test data collisions, environment instability, and genuine application race conditions — roughly in that order of frequency. The first two are framework defects and are fully within the team's control. The fourth is a product defect and should be filed as one.
17. How do you test file downloads?
Configure a downloadsFolder, clear it before the test, trigger the download, then use cy.readFile() with a timeout — readFile retries, so no sleep is needed. Assert on content, row count, or headers rather than just existence. In containers, ensure the downloads directory is writable by the container user.
18. How do you handle file uploads?
cy.selectFile() handles standard inputs, multiple files, and drag-and-drop via the action: 'drag-drop' option. For generated content, pass an object with contents, fileName, and mimeType rather than shipping a physical fixture. For API uploads, build a FormData with a Blob created from a binary fixture.
19. How do you work with iframes?
Get the iframe element, read its('0.contentDocument.body'), assert it is not empty, and wrap it with cy.wrap. Encapsulate this in a cy.iframeBody() custom command. Cross-origin iframes require chromeWebSecurity: false and only work in Chromium-family browsers.
20. How do you handle Shadow DOM?
Use .shadow() to pierce a shadow root explicitly, or enable includeShadowDom globally. Global enabling is convenient but slows every query, so prefer explicit .shadow() unless the application is built entirely from web components.
21. Cypress cannot open a second tab. What do you do?
Three sanctioned approaches: remove the target attribute and navigate in the same tab; assert the link's href contract and verify the target with cy.request; or stub window.open and assert it was called with the expected URL. Choose based on whether you need to verify the destination content or just the navigation intent.
22. What does cy.origin() do and what are its constraints?
cy.origin() executes a callback in the context of a different origin, enabling cross-origin flows such as third-party SSO. Code inside runs in a separate context, so outer-scope variables must be passed explicitly via the args option and must be serializable. Custom commands are not automatically available inside unless re-registered.
23. How do you validate API response schemas?
Store JSON Schema documents in fixtures/schemas/, compile them with Ajv, and validate response bodies in a shared validateSchema helper that throws with readable error paths. Set additionalProperties: false to catch unexpected fields, including accidental PII leakage. This turns "it returned 200" into "it honoured the contract."
24. Compare cy.request() and cy.intercept().
cy.request() makes an HTTP call from the Node process, outside the browser, unaffected by CORS — ideal for setup, teardown, and pure API tests. cy.intercept() observes or modifies requests the application makes from within the browser. They serve different purposes and are frequently used together in the same test.
25. How do you structure an API service layer?
A BaseApiService provides get/post/put/patch/delete helpers that add auth headers, base URLs, and correlation IDs. One subclass per API resource exposes typed methods for that resource's operations. Services never assert; they return typed Cypress.Response<T> so specs own the assertions.
26. What is the correct approach to test data?
Generate unique data per test with a run-scoped prefix so parallel runs cannot collide and cleanup jobs can identify automation records. Create preconditions via API or database, never through the UI. Clean up in after hooks and back that up with a scheduled purge.
27. Why can't cy.fixture() drive it() generation?
Mocha collects tests synchronously during the describe phase, before any Cypress command runs. cy.fixture() resolves inside the command queue, which is far too late. Use a static import or require of the JSON when the data must generate test cases.
28. How do you implement data-driven tests from Excel or CSV?
Parse the file in Node — in a plugin registered as a task — and expose it via cy.task() for in-test iteration, or read it synchronously at module load for it() generation. xlsx handles Excel; csv-parse/sync handles CSV. Validate the parsed shape and fail loudly if a sheet or column is missing.
29. What is your reporting strategy?
Emit JUnit XML always for native CI integration, Mochawesome JSON for a self-contained HTML report, and optionally Allure for history, trends, severity, and requirement traceability. Merge parallel results in a fan-in job and notify once from there. Include environment, build number, and commit SHA in the report title.
30. How do you keep artifact storage under control?
Disable video in PR pipelines, delete videos for passing specs via an after:spec hook, and set tiered retention: seven days for PR builds, thirty for nightly, longer only where audit requires. Never commit artifacts to version control.
31. What Docker settings are non-negotiable for Cypress?
Set shm_size: 2gb — the default 64 MB causes Chrome crashes that look like random test failures. Pin the image tag rather than using latest. Gate the test container on application and database health checks, and use --exit-code-from cypress so the pipeline fails correctly.
32. How do you design a tiered CI strategy?
Smoke tests on every commit with a target under five minutes; regression on merge to the main branch; full cross-browser and long-tail suites nightly. This keeps developer feedback fast while preserving depth of coverage. A single 45-minute suite on every commit trains developers to ignore it.
33. How do you enforce selector standards?
Make data-test attributes a definition-of-done item with developers, centralize selector construction in BasePage.el(), and add a CI check that fails when a spec file contains a raw class or ID selector. Enforcement in the pipeline is what makes the standard real.
34. What is Cypress.env() precedence?
Command-line --env values win, followed by CYPRESS_* process environment variables, then values from the environment JSON file, then defaults in the config. Documenting this order prevents the classic "why is it hitting QA when I set staging" investigation.
35. How do you handle uncaught application exceptions?
Register Cypress.on('uncaught:exception') with an explicit allowlist of known-benign messages such as ResizeObserver warnings, returning false only for those, and returning true for everything else. A blanket return false silently hides real product defects while the suite reports green.
36. How do you test eventual consistency?
Poll through a retryable construct — cy.request inside a should() callback, or a bounded recursive helper — with an explicit timeout and a comment explaining why the wait exists. Never use a fixed cy.wait. Cap the wait so a genuinely broken pipeline still fails in reasonable time.
37. What is your policy on .then() versus .should()?
Prefer .should() and .and() so assertions retry. Use .then() only when a value must cross into imperative logic, and keep the block short. Overusing .then() sacrifices retryability and produces nested callback code that is hard to read.
38. How do you make custom commands type-safe?
Declare them in cypress/support/types/global.d.ts inside declare global { namespace Cypress { interface Chainable { ... } } }, with a trailing export {} to make the file a module. This gives IntelliSense, compile-time checks on arguments, and safe refactoring.
39. How do you apply SOLID principles to a test framework?
Single responsibility per page object, service, and spec; extend base classes rather than modifying them; keep subclasses substitutable for their base; avoid fat interfaces that force irrelevant methods; and have specs depend on service abstractions rather than raw HTTP details.
40. When does DRY hurt in test code?
When abstraction obscures intent. Infrastructure — selectors, auth, HTTP, data factories — should be strictly DRY. Scenario bodies benefit from explicitness; a test that requires reading three helper files to understand is worse than one with mild duplication. The practical rule is to abstract on the third occurrence, not the second.
41. How do you decide between a custom command, a utility, and a service?
If it must participate in the command queue and is used across many specs, make it a custom command. If it is a pure data transformation, make it a utility function. If it is a group of related HTTP operations against one resource, make it a service class method.
42. How do you measure and control suite performance?
Capture per-spec durations in an after:spec hook, write them to reports/durations.json, and review the ten slowest specs each sprint. Set an explicit performance budget and fail the build when exceeded. Feed the duration data back into duration-weighted sharding.
43. What is component testing in Cypress and when do you use it?
Component testing mounts a single framework component in a real browser with a dev server, enabling fast, isolated tests of rendering and interaction. Use it for design-system components, complex form validation, and edge-case rendering that would be slow and brittle to reach through a full E2E journey. It sits between unit tests and E2E in the pyramid.
44. How do you handle feature flags?
Read flag state from configuration so specs can branch at the suite level, and stub the flag endpoint with cy.intercept() when you need deterministic control. Tag tests by the flag they depend on so they can be excluded when a flag is off in a given environment. Delete flag-conditional tests when the flag is retired.
45. What belongs in a smoke suite?
The minimum set proving the system is worth testing further: the application shell renders, authentication works, core APIs report healthy, and the primary revenue path reaches its first decision point. It must run in under five minutes and must never be flaky, because it gates everything downstream.
46. How do you safely run tests against production?
Restrict specPattern in the production config to smoke tests only, block database writes through an environment guard in the task handler, use a dedicated read-only service account, and never create or mutate data. Synthetic monitoring, not regression testing, is the production use case.
47. How do you onboard a new engineer to the framework?
A README covering setup, environment switching, tagging, adding a page object, and debugging CI failures, plus a CONTRIBUTING.md with the code standards. Well-typed commands and a discoverable folder structure do most of the teaching. If onboarding requires a knowledge transfer session, the structure is not self-explanatory enough.
48. What metrics do you report to leadership?
Pass rate, flake rate, suite duration trend, mean time to detect, coverage of critical journeys, and the count of quarantined tests. Avoid raw test counts as a quality metric — they incentivize volume over value.
49. How do you decide what not to automate?
Skip scenarios that are cheaper to verify at a lower layer, tests whose maintenance cost exceeds their defect-detection value, exploratory and usability testing, and anything requiring capabilities Cypress does not have. Document the decision so the gap is deliberate rather than accidental.
50. How would you migrate a legacy Selenium suite to Cypress?
Do not port test-for-test. Start by rebuilding the framework layer — auth, services, page objects, config — then migrate the highest-value critical journeys first, running both suites in parallel during transition. Take the opportunity to push logic down to API tests rather than reproducing an inverted pyramid in a new tool. Retire the old suite only when the new one has demonstrated equivalent defect detection over several release cycles.
FAQs
Forty-two questions, beginner through advanced.
1. Is Cypress better than Selenium?
Neither is universally better. Cypress offers a superior developer experience, faster feedback, and better network control for modern web applications. Selenium offers broader language support, true multi-tab and multi-browser coverage, and mobile-adjacent tooling. Choose by constraint, not by preference.
2. Should I use JavaScript or TypeScript?
TypeScript, without exception, for any framework expected to live longer than a quarter. Type declarations for custom commands and page objects turn runtime failures into compile errors and make refactoring safe.
3. How many tests should an enterprise suite have?
The wrong question. Measure coverage of critical business journeys and defect detection rate, not test count. A 200-test suite covering every revenue path is more valuable than a 2,000-test suite covering the same paths eight times.
4. What is a healthy suite runtime?
Smoke under five minutes, regression under twenty with parallelization, full nightly matrix under an hour. Set these as budgets and enforce them in CI.
5. What flake rate is acceptable?
Under 1% for the regression suite and 0% for smoke. Above 2%, developers begin distrusting results, and the suite starts losing its purpose.
6. Can Cypress test mobile applications?
No. It emulates viewports, which tests responsive layout, not device behaviour. Native mobile testing requires Appium, Espresso, or XCUITest.
7. Can Cypress handle multiple browser tabs?
No, by design. Use the target-removal, link-contract, or window.open stubbing strategies described earlier.
8. How do I test third-party SSO logins?
Prefer programmatic authentication through the identity provider's token endpoint, wrapped in cy.session. When the UI flow itself must be tested, use cy.origin() and accept the constraints. Test the SSO UI flow once, not before every test.
9. How do I handle CAPTCHA?
Disable it in non-production environments via configuration, or use the provider's test keys that always pass. Never attempt to solve it programmatically.
10. Should I use BDD with Cucumber?
Only if business stakeholders genuinely read and write the feature files. If Gherkin is written by engineers for engineers, it adds an indirection layer with no benefit. Well-named describe/it blocks are readable enough for most teams.
11. Is the Page Object Model still relevant?
Yes, though the modern form is lighter: selector maps plus intent-revealing methods, with component objects for shared fragments. The alternative — screenplay or app-actions patterns — solves the same problem differently, but some abstraction over selectors is mandatory at scale.
12. How do I test drag and drop?
Use cy.selectFile(..., { action: 'drag-drop' }) for file drops. For element reordering, use cypress-real-events for real pointer events, or trigger the underlying HTML5 drag events manually. HTML5 drag and drop is genuinely difficult to automate reliably; consider whether an API-level assertion on the resulting order is sufficient.
13. How do I test emails?
Use a mail-catching service with an API — Mailosaur, MailHog, or a test inbox — and poll it with cy.request inside a retryable helper. Never automate a real consumer mail client.
14. Can Cypress test PDFs?
Not directly, but you can download the file and parse it in Node via a task using pdf-parse, then assert on extracted text. Visual PDF verification requires a separate rendering step.
15. How do I do visual regression testing?
Use a plugin such as cypress-image-diff or a hosted service like Percy or Applitools. Run visual checks on a small, curated set of pages, in a container for font consistency, and expect to spend real effort on baseline management.
16. How do I test accessibility?
Add cypress-axe, inject axe-core, and run cy.checkA11y() on key pages with an agreed rule set. Treat it as a gate for new pages and a tracked backlog for existing ones, or you will drown in pre-existing violations on day one.
17. How do I test WebSockets?
Cypress cannot intercept WebSocket frames natively. Stub the client library, assert on application state after simulated messages, or verify the connection handshake and test message handling at the unit level.
18. How do I test GraphQL?
Intercept the single GraphQL endpoint and filter by operation name in the handler, aliasing per operation. Build a service layer that constructs queries so specs never contain raw query strings.
19. Do I need Cypress Cloud?
Not strictly. It provides dynamic load balancing, flake detection, test history, and parallel orchestration out of the box. You can replicate the essentials with sharding scripts and Allure, at the cost of engineering time. Evaluate the trade against your team's capacity.
20. How do I debug a failure that only happens in CI?
Compare environment differences first: browser version, viewport, timezone, locale, data state, and network latency. Reproduce in the same Docker image locally, enable video and debug logging, and add correlation IDs so application logs can be joined to the failing test.
21. Why does my test pass alone but fail in the suite?
Almost always shared state: cached sessions, leftover test data, or a previous test mutating a record. Verify the test is independently runnable and that cleanup actually executes.
22. What is DEBUG=cypress:* for?
It enables verbose internal logging from the Cypress process, useful for diagnosing launch failures, proxy issues, and plugin errors. It is noisy — use it for specific investigations, not routinely.
23. How do I control test execution order?
You should not need to. If order matters, the tests are coupled and must be refactored. Cypress runs specs alphabetically by default, which is not a guarantee to build on.
24. Can I share code between Cypress and unit tests?
Yes, and you should for factories, type definitions, and constants. Keep them in a shared package or directory imported by both, so a model change updates everything at once.
25. How do I handle timezone-dependent assertions?
Pin the timezone explicitly with TZ=UTC in CI, format dates through a shared DateUtil with an explicit timeZone option, and never compare against a locally computed new Date() without normalization.
26. How do I test infinite scroll or virtualized lists?
Assert on the rendered window rather than the full dataset, scroll programmatically, and wait for the corresponding network request via an intercept alias. Verify the total count through the API rather than by scrolling to the end.
27. What is the right ratio of API to UI tests?
There is no universal number, but a healthy enterprise suite typically has several times more API tests than UI tests. Business rules belong in API tests; UI tests verify that journeys work and that data renders correctly.
28. How do I stop tests from breaking on every UI change?
data-test attributes, centralized selectors, assertions on behaviour rather than markup, and a definition-of-done agreement with the development team. Selector churn is a process problem more than a technical one.
29. Should the QA team or the development team own the tests?
The squad that owns the feature should own its tests, with the automation team owning the framework. CODEOWNERS makes this concrete. Central ownership of all tests creates a bottleneck and diffuses accountability.
30. How do I handle test data in a shared UAT environment?
Create data per test with unique prefixes, avoid mutating existing records, clean up afterwards, and run a scheduled purge for orphans. Coordinate with manual testers so automation data is recognizable and ignorable.
31. What is the correct approach to a legacy application with no test attributes?
Negotiate data-test attributes for new and changed code, and use accessible roles and names as the interim strategy for existing pages. Do not build the entire suite on structural CSS while waiting; you will pay for it twice.
32. How do I run only the tests affected by a code change?
Map spec files to application modules through a manifest or CODEOWNERS-style mapping, and select specs based on the changed paths in the pull request. This is powerful but requires discipline to keep the mapping accurate; the fallback must always be to run everything.
33. Can Cypress replace manual testing?
No. It replaces repetitive regression checking. Exploratory testing, usability evaluation, and judgment about whether the right thing was built remain human work.
34. How do I convince leadership to invest in framework quality?
Present the numbers: pipeline duration as lead-time tax, flake rate as engineering hours lost to re-runs, and escaped defects as production incident cost. Framework quality becomes an easy decision when expressed in those terms rather than as engineering preference.
35. What is a reasonable timeline to build an enterprise framework?
A working skeleton with CI, config, auth, and a first passing test in one to two weeks. A production-grade framework with services, page objects, reporting, parallelization, and Docker in six to ten weeks. Coverage growth is continuous thereafter.
36. How often should I upgrade Cypress?
Quarterly on a dedicated pull request, with the full suite run against the new version before merging. Pin the exact version and never float, especially in Docker images.
37. How do I test multi-language applications?
Assert on message keys resolved through a shared constants module that the application also consumes, or on data-test attributes rather than copy. Run one locale in regression and a curated subset across all supported locales.
38. What if my application uses heavy animations?
Cypress waits for elements to stop animating before acting, with configurable thresholds. Where animations are decorative, disable them in test builds via a CSS override injected in the support file — this both stabilizes and speeds up the suite.
39. How do I handle a backend that is unstable in QA?
Stub aggressively in UI tests so the suite measures the frontend, and run a separate integration suite against the real backend on a schedule. Gate the suite on a health check so a down environment reports a clear infrastructure failure rather than 300 test failures.
40. Should I write tests before or after the feature?
Write API tests alongside the API and UI tests as the UI stabilizes. Writing E2E tests against an unstable, in-progress UI produces churn. Push shift-left effort into unit and component tests, where it pays off immediately.
41. What is the future of Cypress relative to Playwright?
Both are actively developed and converging on capability. Playwright currently leads on multi-tab, multi-context, and cross-browser breadth; Cypress leads on developer experience, debugging, and component testing ergonomics. Choose on team fit and constraints; both are defensible enterprise choices in 2026.
42. How do I keep a framework healthy over years?
Treat it as a product: an owner, a roadmap, a changelog, a deprecation policy, quarterly dependency upgrades, a performance budget, and a flake budget. Frameworks decay by neglect, not by age.
🎁 MEGA SALE — Flat 90% OFF on ALL 100+ eBooks & Bundles
Use coupon code: JUPITER90
📚 Explore the complete HimanshuAI Digital Playbook Store:
👉 https://himanshuai.gumroad.com/
If you're planning to master Playwright, TypeScript, AI Testing, Salesforce, API Testing, Java, Python, Cypress, Selenium, System Design, DevOps, Cloud Testing, Performance Testing, or Software Test Architecture, this is the best opportunity to build your complete technical library at a fraction of the regular price.
Conclusion
Key Takeaways
- A test framework is production software. It has consumers, an API, an SLA, and a total cost of ownership, and it must be engineered accordingly.
- Folder structure is architecture made physical. It determines change radius, and change radius determines maintenance cost.
- Three layers, strictly separated: specs say what, page objects and services say how, utilities support both.
- Authenticate and seed data through the API. This single decision is usually worth more than every other optimization combined.
- Synchronize with assertions and network intercepts. A fixed sleep is a bet you lose in both directions.
- Selectors are a contract with developers. Negotiate
data-testattributes once instead of repairing selectors forever. - Test independence is the precondition for parallelism, retries, and debugging.
- Configuration switches environments; secrets never live in the repository; production is restricted by configuration, not by convention.
- Flakiness is a defect with an owner, not weather.
- Measure everything — duration, flake rate, pass rate — and set budgets that fail the build when exceeded.
Enterprise Recommendations
- Adopt TypeScript with strict mode from the first commit.
- Wire CI on day one with one passing test, then grow.
- Build the service layer before the page object layer; most setup should never touch the UI.
- Establish tiered suites — smoke, regression, nightly — with explicit runtime budgets.
- Containerize execution so local and CI environments are identical.
- Assign ownership through CODEOWNERS at the directory level.
- Publish the framework core as a versioned internal package once more than two teams depend on it.
- Review test code with the same rigour as production code, and reject the known anti-patterns automatically through linting.
- Instrument flakiness from the beginning; retrofitting observability after trust is lost is far harder.
- Write the architecture decision record, including why you did not choose the alternatives.
Learning Roadmap
- Foundations (weeks 1–2). JavaScript and TypeScript fundamentals, the Cypress command queue, retryability, basic assertions.
- Structure (weeks 3–4). Page objects, custom commands, fixtures, constants, configuration.
-
Depth (weeks 5–8). API testing with a service layer, schema validation, network interception, data factories,
cy.session. - Scale (weeks 9–12). Parallelization, Docker, CI/CD pipelines, reporting, artifact strategy.
- Mastery (ongoing). Performance budgets, flakiness analytics, framework versioning, component testing, accessibility and visual layers, and mentoring others through code review.
The Future of Cypress and Test Automation
Cypress in 2026 is a mature tool in a competitive space. Its trajectory is toward deeper component testing integration, improved cross-browser parity, and better first-class support for the orchestration patterns that teams currently hand-roll. The framework patterns in this article outlast any single tool: separation of concerns, API-first setup, deterministic data, and observability are tool-agnostic.
AI-powered test automation has moved from novelty to practical utility. The realistic, high-value applications today are test generation from requirements and user-flow recordings, intelligent test selection that predicts which specs a change is likely to break, failure triage that clusters failures by root cause instead of listing them, and automated maintenance suggestions when selectors drift. The pattern is assistance, not replacement: AI proposes, engineers approve. A generated test that nobody understands is a liability, not coverage.
Agentic AI extends this from suggestion to bounded execution — an agent that explores a build, proposes new scenarios, opens a pull request with tests, and responds to review feedback. The engineering discipline that makes this safe is exactly the discipline described in this article: a typed, well-structured framework gives an agent a constrained, legible surface to work against. Agents operating on an unstructured suite produce unstructured output at machine speed, which is a considerably worse problem than having no agent at all.
Self-healing frameworks address selector drift by maintaining multiple candidate locators per element and scoring them against the current DOM when the primary fails. Used carefully, this reduces maintenance on legacy applications with no test attributes. Used carelessly, it hides real regressions — a button that moved because it broke will "heal" silently. The correct policy is to heal, log, report, and require human confirmation, never to heal silently.
Testing trends worth tracking into 2026 and beyond:
- Contract testing between services displacing broad end-to-end integration suites.
- Testing in production through feature flags, canary releases, and synthetic monitoring as a complement to pre-production gates.
- Observability-driven testing, where production telemetry identifies which paths actually need coverage.
- Accessibility and performance assertions becoming standard gates rather than specialist activities.
- Shift-left security testing integrated into the same pipeline as functional tests.
- Ephemeral, per-pull-request environments making full-stack isolation practical.
- Component and contract layers absorbing work that previously required end-to-end tests, shrinking E2E suites to genuine journeys.
The organizations that will benefit most from all of it are the ones whose frameworks are already well-structured, well-typed, well-instrumented, and owned. The architecture comes first. The tooling follows.
Top comments (0)