Organizing Specs as the Suite Grows
As your Cypress suite expands, the main risk is not “more tests” but “more places to look” when something fails or needs updating. A readable structure makes it obvious where a test belongs, what it covers, and which helpers it relies on.
Option A: Organize by Feature (Recommended for Most Apps)
Feature-based organization groups tests by user capability (authentication, checkout, search), which usually matches how product teams think and how work is delivered.
- Pros: aligns with user journeys, reduces cross-folder hopping, scales well when pages change but features remain.
- Cons: some features touch many pages; you need consistent naming to avoid “misc” buckets.
cypress/e2e/auth/login.cy.js
cypress/e2e/auth/password-reset.cy.js
cypress/e2e/cart/add-to-cart.cy.js
cypress/e2e/cart/checkout.cy.js
cypress/e2e/search/search-results.cy.jsOption B: Organize by Page (Useful for Admin/CRUD Screens)
Page-based organization groups tests by screen or route. This can work well for back-office apps where each page is a self-contained CRUD surface.
- Pros: easy mapping to routes/screens, straightforward for “one page = one responsibility” UIs.
- Cons: user flows span pages; tests can become fragmented and duplicated across page folders.
cypress/e2e/pages/login.cy.js
cypress/e2e/pages/products.cy.js
cypress/e2e/pages/cart.cy.js
cypress/e2e/pages/checkout.cy.jsA Practical Hybrid
A common compromise is feature-first, with a small “pages” or “shared” area only for reusable UI helpers (not tests). Keep specs feature-oriented, keep helpers reusable.
cypress/e2e/cart/checkout.cy.js
cypress/e2e/cart/coupons.cy.js
cypress/support/pages/checkoutPage.js
cypress/support/pages/cartPage.jsUsing Support Files and Custom Commands for Repeated Actions
Repeated steps are a signal: either your tests are too low-level everywhere, or you need a helper that expresses intent. Cypress provides cypress/support for shared utilities and Cypress.Commands.add for custom commands.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Where Helpers Live
cypress/support/e2e.js: loaded before every spec; good for registering commands and global hooks.cypress/support/commands.js: a common place to define custom commands.cypress/support/utils/*: pure helper functions (formatting, building payloads) that don’t need Cypress chaining.
Custom Command Example: Selecting by data-cy
If your tests frequently use [data-cy="..."], create a command that standardizes it. This reduces noise and makes selectors consistent.
// cypress/support/commands.js
Cypress.Commands.add('getByCy', (value, ...args) => {
return cy.get(`[data-cy="${value}"]`, ...args)
})Usage:
cy.getByCy('email').type('user@example.com')
cy.getByCy('password').type('secret')
cy.getByCy('submit').click()Custom Command Example: Login as a Reusable Action
Login is often repeated across specs. A custom command can express intent while keeping the steps visible enough to debug.
// cypress/support/commands.js
Cypress.Commands.add('loginUI', ({ email, password }) => {
cy.visit('/login')
cy.getByCy('email').clear().type(email)
cy.getByCy('password').clear().type(password, { log: false })
cy.getByCy('submit').click()
})Usage:
cy.loginUI({ email: 'user@example.com', password: 'secret' })
cy.url().should('include', '/dashboard')Notes for maintainability:
- Keep the command name explicit:
loginUIcommunicates this uses the UI (as opposed to an API-based shortcut). - Avoid embedding assertions inside the login command unless they are intrinsic to the action (see “transparent helpers” below).
Prefer Small, Composable Commands
Instead of one mega-command like completeCheckout(), create smaller commands that can be combined differently per test:
addItemToCart(sku)applyCoupon(code)fillShippingAddress(address)placeOrder()
This keeps tests readable and reduces the chance that one helper becomes a “black box” that hides important behavior.
Lightweight Page Objects vs Direct Cypress Commands
A “page object” in Cypress should be lightweight: a thin layer that centralizes selectors and common interactions, without turning tests into an unreadable DSL. The goal is to reduce duplication while keeping the test narrative clear.
When Direct Commands Are Better
- The interaction is unique to one test.
- The selector is used once and is already readable.
- You are exploring a new feature and the UI is still changing.
// Direct is fine when it's local and clear
cy.getByCy('coupon-code').type('SAVE10')
cy.getByCy('apply-coupon').click()
cy.getByCy('coupon-success').should('contain', 'Applied')When a Lightweight Page-Object Style Helps
- The same elements are used across many specs (e.g., cart drawer, header, login form).
- Selectors are verbose or repeated.
- You want a single place to update selectors when markup changes.
Example: a small module that exposes element getters and a few focused actions.
// cypress/support/pages/loginPage.js
export const loginPage = {
email: () => cy.getByCy('email'),
password: () => cy.getByCy('password'),
submit: () => cy.getByCy('submit'),
visit: () => cy.visit('/login'),
login: ({ email, password }) => {
loginPage.email().clear().type(email)
loginPage.password().clear().type(password, { log: false })
loginPage.submit().click()
}
}Usage in a spec:
import { loginPage } from '../../support/pages/loginPage'
describe('Authentication', () => {
it('logs in with valid credentials', () => {
loginPage.visit()
loginPage.login({ email: 'user@example.com', password: 'secret' })
cy.url().should('include', '/dashboard')
})
})This style stays “light” because:
- Selectors are centralized.
- Actions are short and map to visible UI steps.
- Assertions remain in the spec (so the test still reads like a test).
Avoid Heavy Page Objects That Hide the Test
If your spec becomes a list of opaque calls, failures become harder to diagnose and the test stops documenting behavior.
// Too opaque (avoid)
checkoutFlow.completeHappyPathOrder()Prefer something that still reads like a user story:
// Better: intent is clear and steps are still visible
cart.addItem('SKU-123')
checkout.fillShippingAddress(address)
checkout.choosePaymentMethod('card')
checkout.placeOrder()
cy.getByCy('order-confirmation').should('be.visible')Keeping Helpers Transparent (Don’t Hide Assertions)
Helpers should reduce duplication, not hide verification. When assertions are buried inside commands/page objects, you lose clarity about what each test is proving, and you can accidentally assert the same thing everywhere.
Guideline: Assertions Belong in Specs
Keep the “then” part of the test in the spec so a reader can quickly see the expected outcome.
// Good: helper performs action, spec asserts outcome
cy.loginUI({ email, password })
cy.getByCy('user-menu').should('contain', 'My Account')Exception: Assertions That Are Intrinsic to the Action
Some checks are part of making an action reliable and safe, such as verifying a modal is open before interacting with it. These are “precondition” checks rather than “test expectations.” Keep them minimal and clearly tied to the action.
// Acceptable: precondition check inside helper
Cypress.Commands.add('openCartDrawer', () => {
cy.getByCy('cart-button').click()
cy.getByCy('cart-drawer').should('be.visible')
})Still, avoid asserting business outcomes (prices, totals, permissions) inside helpers; those should remain test-specific.
Conventions for Naming, Grouping, and Reuse
Spec File Naming
- Use feature-oriented names:
checkout.cy.js,coupons.cy.js,login.cy.js. - Prefer consistent suffix:
.cy.js(or.cy.ts). - Keep one primary feature per file; split when the file becomes hard to scan.
describe and context Structure
Use describe for the feature and context for meaningful states. Use beforeEach for shared setup that truly applies to every test in the block.
describe('Checkout', () => {
context('when the cart has items', () => {
beforeEach(() => {
// setup steps shared by all tests in this context
})
it('shows shipping options', () => {
// test steps + assertions
})
it('applies a valid coupon', () => {
// test steps + assertions
})
})
})Command and Helper Naming
- Use verbs for actions:
loginUI,addItemToCart,openCartDrawer. - Include the “how” when it matters:
loginUIvsloginApi(even if you only have one today, it prevents confusion later). - Keep selector helpers consistent:
getByCy,findByCy(if you implement both, make the difference clear).
Reuse Without Over-Abstraction
A good rule: if extracting a helper makes the spec harder to read, don’t extract it yet. Duplication is sometimes acceptable when it preserves clarity, especially for one-off flows. Extract when:
- the same steps appear in 3+ places,
- a change would require editing multiple specs,
- the extracted helper name clearly communicates intent.
Refactoring Exercise: Extract Repeated Steps into Commands (Without Losing Clarity)
This exercise starts with a spec that repeats the same login and selector patterns. You will refactor it into small commands while keeping assertions in the spec.
Step 1: Start with a Repetitive Spec
// cypress/e2e/cart/checkout.cy.js
describe('Checkout', () => {
it('applies a coupon and shows the discounted total', () => {
cy.visit('/login')
cy.get('[data-cy="email"]').type('user@example.com')
cy.get('[data-cy="password"]').type('secret')
cy.get('[data-cy="submit"]').click()
cy.visit('/products')
cy.get('[data-cy="product-card"]').first().within(() => {
cy.get('[data-cy="add-to-cart"]').click()
})
cy.visit('/cart')
cy.get('[data-cy="coupon-code"]').type('SAVE10')
cy.get('[data-cy="apply-coupon"]').click()
cy.get('[data-cy="total"]').should('contain', '$')
cy.get('[data-cy="coupon-success"]').should('be.visible')
})
it('rejects an invalid coupon', () => {
cy.visit('/login')
cy.get('[data-cy="email"]').type('user@example.com')
cy.get('[data-cy="password"]').type('secret')
cy.get('[data-cy="submit"]').click()
cy.visit('/cart')
cy.get('[data-cy="coupon-code"]').type('NOTREAL')
cy.get('[data-cy="apply-coupon"]').click()
cy.get('[data-cy="coupon-error"]').should('contain', 'Invalid')
})
})Step 2: Extract a Selector Helper (getByCy)
Create a single, consistent way to select elements by data-cy.
// cypress/support/commands.js
Cypress.Commands.add('getByCy', (value, ...args) => {
return cy.get(`[data-cy="${value}"]`, ...args)
})Update the spec to use it (no behavior change, just readability):
cy.getByCy('email').type('user@example.com')
cy.getByCy('password').type('secret')
cy.getByCy('submit').click()Step 3: Extract Login Steps into a Focused Command
Move only the repeated action steps. Do not add assertions like “should land on dashboard” inside the command; keep that per-test.
// cypress/support/commands.js
Cypress.Commands.add('loginUI', ({ email, password }) => {
cy.visit('/login')
cy.getByCy('email').clear().type(email)
cy.getByCy('password').clear().type(password, { log: false })
cy.getByCy('submit').click()
})Update the spec:
cy.loginUI({ email: 'user@example.com', password: 'secret' })Step 4: Extract “Apply Coupon” as a Small Command
This action is repeated and has a clear intent. Keep success/error assertions in the spec.
// cypress/support/commands.js
Cypress.Commands.add('applyCoupon', (code) => {
cy.getByCy('coupon-code').clear().type(code)
cy.getByCy('apply-coupon').click()
})Step 5: Refactor the Spec While Preserving Clarity
The refactored spec should read like a scenario, with helpers expressing intent and assertions remaining explicit.
// cypress/e2e/cart/checkout.cy.js
describe('Checkout', () => {
beforeEach(() => {
cy.loginUI({ email: 'user@example.com', password: 'secret' })
})
it('applies a coupon and shows the discounted total', () => {
cy.visit('/products')
cy.getByCy('product-card').first().within(() => {
cy.getByCy('add-to-cart').click()
})
cy.visit('/cart')
cy.applyCoupon('SAVE10')
cy.getByCy('coupon-success').should('be.visible')
cy.getByCy('total').should('contain', '$')
})
it('rejects an invalid coupon', () => {
cy.visit('/cart')
cy.applyCoupon('NOTREAL')
cy.getByCy('coupon-error').should('contain', 'Invalid')
})
})Step 6: Optional—Introduce a Lightweight Page Module for Cart
If many specs interact with the cart coupon UI, you can centralize selectors without hiding expectations.
// cypress/support/pages/cartPage.js
export const cartPage = {
visit: () => cy.visit('/cart'),
couponCode: () => cy.getByCy('coupon-code'),
applyCouponButton: () => cy.getByCy('apply-coupon'),
couponSuccess: () => cy.getByCy('coupon-success'),
couponError: () => cy.getByCy('coupon-error'),
total: () => cy.getByCy('total')
}Usage stays explicit:
import { cartPage } from '../../support/pages/cartPage'
cartPage.visit()
cartPage.couponCode().clear().type('SAVE10')
cartPage.applyCouponButton().click()
cartPage.couponSuccess().should('be.visible')
cartPage.total().should('contain', '$')