Cypress Best Practices: Custom Commands, Page Objects, and Anti-Patterns
A Cypress test suite that starts clean can become a maintenance nightmare if it grows without structure. Duplicated login flows, fragile selectors, and tests that share state all contribute to slow, flaky, hard-to-debug pipelines. This guide covers the patterns that keep Cypress test suites maintainable as they grow.
Use data-cy Attributes for Selectors
The most common source of test brittleness is selectors tied to implementation details — CSS classes, element order, or visible text.
Fragile selectors:
cy.get('.btn-primary').click(); // breaks when CSS changes
cy.get('ul > li:nth-child(2) > button').click(); // breaks when markup changes
cy.contains('Submit').click(); // breaks when copy changesResilient selectors:
cy.get('[data-cy="submit-order"]').click();Add data-cy attributes to your HTML specifically for testing:
<button data-cy="submit-order" class="btn btn-primary">
Place Order
</button>These attributes:
- Are invisible to users
- Don't affect styling or behavior
- Signal testing intent to developers
- Never break due to CSS or copy changes
Configure data-cy in the Cypress selector playground for even faster authoring:
// cypress.config.ts
export default defineConfig({
e2e: {
selectorPriority: ['data-cy', 'data-testid', 'aria-label'],
},
});Custom Commands
Custom commands let you encapsulate complex, repeated interactions as reusable single-line calls.
Add to cypress/support/commands.ts:
// Authentication
Cypress.Commands.add('login', (email: string, password: string) => {
cy.session([email, password], () => {
cy.visit('/login');
cy.get('[data-cy="email"]').type(email);
cy.get('[data-cy="password"]').type(password);
cy.get('[data-cy="submit"]').click();
cy.url().should('include', '/dashboard');
});
});
// API-based login (faster)
Cypress.Commands.add('loginViaApi', (email: string, password: string) => {
cy.request('POST', '/api/auth/login', { email, password })
.its('body.token')
.then((token) => {
localStorage.setItem('auth_token', token);
});
});
// Create test data
Cypress.Commands.add('createTestUser', (overrides = {}) => {
const user = {
name: 'Test User',
email: `test-${Date.now()}@example.com`,
role: 'member',
...overrides,
};
return cy.request('POST', '/api/test/users', user).its('body');
});
// Drag and drop
Cypress.Commands.add('dragTo', { prevSubject: 'element' }, (subject, target) => {
cy.wrap(subject).trigger('dragstart');
cy.get(target).trigger('drop');
});Declare types in cypress/support/index.d.ts:
declare namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
loginViaApi(email: string, password: string): Chainable<void>;
createTestUser(overrides?: Partial<User>): Chainable<User>;
dragTo(target: string): Chainable<void>;
}
}Use in tests:
before(() => {
cy.login('admin@example.com', 'password');
});
it('admin can manage users', () => {
cy.createTestUser({ role: 'member' }).then((user) => {
cy.visit(`/admin/users/${user.id}`);
cy.get('[data-cy="promote-admin"]').click();
cy.get('[data-cy="user-role"]').should('contain', 'admin');
});
});Page Objects
Page Objects encapsulate the structure and interactions of a page. They make tests read like user stories and keep selector changes isolated to one place.
// cypress/pages/LoginPage.ts
export class LoginPage {
visit() {
cy.visit('/login');
return this;
}
fillEmail(email: string) {
cy.get('[data-cy="email"]').clear().type(email);
return this;
}
fillPassword(password: string) {
cy.get('[data-cy="password"]').clear().type(password);
return this;
}
submit() {
cy.get('[data-cy="submit"]').click();
return this;
}
expectError(message: string) {
cy.get('[data-cy="error-message"]').should('contain', message);
return this;
}
}// cypress/e2e/auth.cy.ts
import { LoginPage } from '../pages/LoginPage';
const loginPage = new LoginPage();
describe('Login', () => {
it('redirects to dashboard on success', () => {
loginPage
.visit()
.fillEmail('user@example.com')
.fillPassword('correctpassword')
.submit();
cy.url().should('include', '/dashboard');
});
it('shows error for wrong password', () => {
loginPage
.visit()
.fillEmail('user@example.com')
.fillPassword('wrongpassword')
.submit()
.expectError('Invalid credentials');
});
});When to use Page Objects:
- Pages that appear in many tests (login, navigation)
- Pages with many interactive elements
- When the same interactions are repeated across 3+ tests
When not to use Page Objects:
- Simple one-off tests
- Tests that only visit a page once
- When the abstraction adds more complexity than it removes
Fixtures for Test Data
Keep test data in cypress/fixtures/ instead of inline in tests:
// cypress/fixtures/product.json
{
"id": "prod-1",
"name": "Pro Plan",
"price": 99.00,
"currency": "USD",
"features": ["unlimited tests", "priority support"]
}it('shows product details', () => {
cy.fixture('product.json').then((product) => {
cy.intercept('GET', '/api/products/prod-1', { body: product });
cy.visit('/products/prod-1');
cy.get('[data-cy="product-name"]').should('contain', product.name);
cy.get('[data-cy="product-price"]').should('contain', product.price);
});
});For dynamic fixtures with timestamps or unique IDs:
// cypress/fixtures/factories.ts
export const makeUser = (overrides = {}) => ({
id: `user-${Date.now()}`,
name: 'Test User',
email: `test-${Date.now()}@example.com`,
createdAt: new Date().toISOString(),
...overrides,
});Managing Authentication State
Every test that requires authentication should reuse a saved session. Re-running the login flow for each test is the single biggest cause of slow Cypress suites.
// cypress/support/commands.ts
Cypress.Commands.add('loginAs', (role: 'admin' | 'member' | 'viewer') => {
const credentials = {
admin: { email: 'admin@example.com', password: 'adminpass' },
member: { email: 'member@example.com', password: 'memberpass' },
viewer: { email: 'viewer@example.com', password: 'viewerpass' },
};
const { email, password } = credentials[role];
cy.session(role, () => {
cy.request('POST', '/api/auth/login', { email, password })
.then(({ body }) => {
localStorage.setItem('auth_token', body.token);
document.cookie = `session=${body.sessionId}`;
});
});
});Test Isolation
Each test should set up its own state and leave no side effects for the next test. Shared state between tests is the primary cause of non-deterministic failures.
describe('Orders', () => {
let orderId: string;
beforeEach(() => {
cy.loginAs('member');
// Create fresh data for each test
cy.request('POST', '/api/test/orders', {
items: [{ productId: 'prod-1', quantity: 1 }],
}).then(({ body }) => {
orderId = body.id;
});
});
afterEach(() => {
// Clean up after each test
cy.request('DELETE', `/api/test/orders/${orderId}`);
});
it('displays order details', () => {
cy.visit(`/orders/${orderId}`);
cy.get('[data-cy="order-status"]').should('contain', 'pending');
});
});Anti-Patterns to Avoid
Using cy.wait() with fixed timeouts:
// Bad — brittle and slow
cy.wait(3000);
// Good — waits for actual condition
cy.get('[data-cy="spinner"]').should('not.exist');
cy.wait('@loadUsers'); // wait for a specific requestChaining too many then() callbacks:
// Bad — callback hell
cy.get('.list')
.then(($list) => {
cy.get('.item', { withinSubject: $list })
.then(($item) => {
cy.wrap($item).click().then(() => {
cy.get('.modal').should('be.visible');
});
});
});
// Good — let Cypress handle the chain
cy.get('.list').within(() => {
cy.get('.item').first().click();
});
cy.get('.modal').should('be.visible');Testing implementation details:
// Bad — tests internal state
cy.window().its('__store__.state.user.isLoggedIn').should('be.true');
// Good — tests visible behavior
cy.get('[data-cy="user-avatar"]').should('be.visible');
cy.get('[data-cy="login-button"]').should('not.exist');Sharing test data across tests:
// Bad — tests depend on each other's side effects
it('creates a user', () => {
cy.get('[data-cy="create-user"]').click();
// This leaves a user in the DB
});
it('deletes the user', () => {
// Depends on the previous test having run first
cy.get('[data-cy="delete-user"]').click();
});
// Good — each test is self-contained
it('creates and deletes a user', () => {
cy.createTestUser().then((user) => {
cy.visit(`/admin/users/${user.id}`);
cy.get('[data-cy="delete-user"]').click();
cy.get('[data-cy="confirm-delete"]').click();
cy.get('[data-cy="success-toast"]').should('be.visible');
});
});Test Organization
cypress/
e2e/
auth/
login.cy.ts
signup.cy.ts
password-reset.cy.ts
checkout/
cart.cy.ts
payment.cy.ts
confirmation.cy.ts
admin/
users.cy.ts
settings.cy.ts
pages/
LoginPage.ts
CheckoutPage.ts
AdminPanel.ts
support/
commands.ts ← custom commands
component.tsx ← component test setup
e2e.ts ← E2E test setup
index.d.ts ← TypeScript types
fixtures/
users.json
products.json
orders.jsonSummary
The patterns that matter most for maintainability:
data-cyattributes — decouple tests from CSS and copy- Custom commands — encapsulate repeated interactions (especially auth)
cy.session()— run login once per test group, not per test- Page Objects — isolate selector changes to one file per page
- Test isolation — create and destroy test data per test
- Fixtures — keep test data out of test files
The anti-patterns that hurt most:
cy.wait(3000)— brittle, slow, unnecessary- Shared state between tests — non-deterministic failures
- CSS selectors — break on every style refactor
- Testing implementation details — breaks on internal refactors
A well-structured Cypress suite should read like user stories, not implementation specs.