Fixtures and Custom Commands Best Practices — Keeping Your Suite DRY and Fast

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');
//   });
// });
Note: The “readability test” is the most important guideline for custom command design. If a new team member reads your test and cannot understand what it does without opening 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).
Tip: Apply the “rule of three” for custom commands: if a workflow is duplicated in 3 or more test files, extract it into a custom command. If it appears in only 1-2 files, keep it inline. This prevents premature abstraction while still eliminating genuine duplication. Review your test suite quarterly and extract new commands as patterns emerge.
Warning: Never share mutable state between tests using module-level variables. If 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.

🧠 Test Yourself

Which of the following custom command designs best follows Cypress best practices?