Fixtures and custom commands are the DRY (Don’t Repeat Yourself) tools of Cypress. But DRY taken too far creates abstraction layers that obscure what a test is actually doing. The best Cypress suites find the balance: enough abstraction to eliminate duplication, but enough visibility to understand each test at a glance. This lesson distils the best practices for fixture organisation, command design, and the trade-off between DRY and readability.
Best Practices for Production Cypress Suites
These practices come from teams running Cypress suites with 500+ tests in CI/CD.
// ── BEST PRACTICE 1: Fixture organisation ──
/*
cypress/fixtures/
auth/
valid-users.json # Credentials for happy-path tests
edge-case-users.json # Locked, expired, SSO users
api-stubs/
products-200.json # Successful product API response
products-empty.json # Empty catalogue edge case
products-500.json # Server error response
checkout-success.json
checkout-card-declined.json
forms/
valid-address.json # Standard form fill data
international-address.json
files/
small-avatar.jpg # < 1MB for upload tests
large-file-6mb.zip # Over-limit for error testing
*/
// Rule: One fixture file per scenario, grouped by domain
// ── BEST PRACTICE 2: Command granularity — not too big, not too small ──
// TOO GRANULAR — these should be inline, not commands:
// cy.typeUsername('alice') — just use cy.get('#user').type('alice')
// cy.clickSubmit() — just use cy.get('[data-cy="submit"]').click()
// TOO BROAD — this does too much and is hard to reuse:
// cy.completeEntireCheckout() — login + add items + fill form + pay + verify
// JUST RIGHT — encapsulates a meaningful workflow step:
// cy.loginAPI(user, pass) — multi-step auth via API
// cy.addToCart(index) — find product + click add + handle dynamic button text
// cy.fillAddress(data) — clear + type 3 fields
// cy.cartShouldHave(count) — handles 0 (no badge) vs N (badge with text)
// ── BEST PRACTICE 3: The readability test ──
// A test should be readable WITHOUT reading the custom command code
// GOOD — readable test:
it('should complete purchase with two items', () => {
cy.loginAPI('standard_user', 'secret_sauce');
cy.visit('/inventory.html');
cy.addToCart(0); // clear: adding first product
cy.addToCart(1); // clear: adding second product
cy.cartShouldHave(2); // clear: expect 2 items
cy.get('.shopping_cart_link').click(); // explicit navigation
cy.get('[data-test="checkout"]').click();
cy.fillAddress({ firstName: 'Alice', lastName: 'Tester', postcode: 'SW1A 1AA' });
cy.get('[data-test="continue"]').click();
cy.get('[data-test="finish"]').click();
cy.contains('Thank you').should('be.visible');
});
// BAD — over-abstracted, unreadable without checking command implementations:
// it('should complete purchase', () => {
// cy.setupAuthenticatedSession();
// cy.addDefaultProductsToCart();
// cy.navigateToCheckout();
// cy.completeCheckoutFlow();
// cy.verifyOrderConfirmation();
// });
// ── BEST PRACTICE 4: Use API for setup, UI for verification ──
const SETUP_STRATEGY = {
login: "cy.loginAPI() — API request, not form clicks",
seedData: "cy.request() — POST to API, not UI forms",
navigate: "cy.visit() — direct URL, not menu clicks",
verify: "cy.get().should() — UI assertions (this IS what we are testing)",
};
// ── BEST PRACTICE 5: Custom command checklist ──
const COMMAND_CHECKLIST = [
"Does this action repeat in 3+ test files? → Make it a command",
"Does this action involve 1 line of code? → Keep it inline",
"Does the command name describe the user action? → cy.addToCart, not cy.clickButton3",
"Is the command testable independently? → It should work in any test context",
"Does the command avoid assertions (unless it IS a verification command)?",
"Does the command have TypeScript type definitions?",
"Is the command documented with a JSDoc comment?",
];
// ── BEST PRACTICE 6: Avoid shared mutable state ──
// WRONG — shared variable between tests
// let orderId: string;
// it('creates order', () => { ... orderId = resp.body.id; });
// it('verifies order', () => { cy.visit(`/orders/${orderId}`); });
// ↑ FAILS if first test fails or is skipped
// CORRECT — each test is self-contained
// it('creates and verifies order', () => {
// cy.request('POST', '/api/orders', orderData).then((resp) => {
// cy.visit(`/orders/${resp.body.id}`);
// cy.contains(resp.body.id).should('be.visible');
// });
// });
commands.ts, you have over-abstracted. Each test should read like a user story: log in, add products, check the cart, complete checkout, verify the confirmation. Commands should map to user actions (cy.addToCart), not to implementation details (cy.clickInventoryItemButtonAtIndex).it('creates order') sets orderId = 'ORD-123' and it('verifies order') reads that variable, the second test depends on the first. Cypress may run tests in different orders (especially with test retries), and a failed first test leaves the variable undefined. Each test must create its own data, verify its own result, and clean up its own state.Common Mistakes
Mistake 1 — Over-abstracting tests into unreadable command chains
❌ Wrong: cy.setupTest().performUserJourney().verifyResults() — impossible to understand without reading three command implementations.
✅ Correct: 10-15 lines of readable test code using 3-4 focused commands mixed with explicit cy.get().click() calls. The test tells a story.
Mistake 2 — Not updating fixture files when the API contract changes
❌ Wrong: API now returns { productName: '...' } but fixtures still have { name: '...' } — tests pass against stale data.
✅ Correct: Updating fixtures whenever the API contract changes. Consider adding a contract test that validates fixture structure against the real API schema.