Skip to main content

Why Maintainability Matters

A maintainable test suite:
  • Remains reliable as your application grows
  • Is easy for team members to understand and modify
  • Reduces time spent fixing broken tests
  • Encourages consistent testing practices
  • Provides long-term value to the project

Page Object Model for Maintainability

The framework uses the Page Object Model pattern to centralize UI changes:

Before: Without Page Objects

// Multiple tests with duplicated selectors
it('Test 1', () => {
  cy.get('#user-name').type('user')
  cy.get('#password').type('pass')
  cy.get('#login-button').click()
})

it('Test 2', () => {
  cy.get('#user-name').type('admin')
  cy.get('#password').type('admin')
  cy.get('#login-button').click()
})

// If #login-button changes to .login-btn,
// you must update ALL tests!

After: With Page Objects

// Change selector in ONE place
class LoginPage {
  private loginBtnField = '#login-button'  // Change here only
  
  public clickOnLoginButton() {
    cy.get(this.loginBtnField).click()
  }
}

// All tests continue to work
it('Test 1', () => {
  loginPage.clickOnLoginButton()  // No changes needed
})

it('Test 2', () => {
  loginPage.clickOnLoginButton()  // No changes needed
})
Page Objects reduce maintenance by up to 90% when UI selectors change.

Reducing Test Flakiness

Avoid Hard-Coded Waits

// ❌ Bad - arbitrary wait
cy.get('#submit').click()
cy.wait(3000)  // Hope 3s is enough
cy.get('#success').should('be.visible')

// ✅ Good - wait for specific condition
cy.get('#submit').click()
cy.get('#success', { timeout: 10000 }).should('be.visible')

Use Proper Assertions

// ❌ Bad - doesn't wait
if (cy.get('#element').length > 0) {
  // This doesn't work as expected
}

// ✅ Good - waits and asserts
cy.get('#element').should('exist')
cy.get('#element').should('be.visible')

Wait for API Calls

// ✅ Intercept and wait for API calls
cy.intercept('GET', '/api/data').as('getData')
cy.get('#load-button').click()
cy.wait('@getData')
cy.get('#data-display').should('contain', 'Expected data')

Handle Dynamic Content

// ✅ Wait for dynamic elements
cy.get('[data-test="product-list"]')
  .find('[data-test="product-item"]')
  .should('have.length.at.least', 1)
  .first()
  .click()

Writing Reliable Selectors

Selector Hierarchy

1

Best: data-test attributes

cy.get('[data-test="login-button"]')
✅ Stable, won’t change with styling
2

Good: ID selectors

cy.get('#login-button')
✅ Unique and reliable
3

Okay: Semantic attributes

cy.get('button[type="submit"]')
⚠️ May match multiple elements
4

Avoid: CSS classes

cy.get('.btn-primary')
❌ Changes frequently with styling
5

Last resort: XPath

cy.xpath('//button[text()="Login"]')
❌ Fragile and hard to read

Add data-test Attributes

Work with developers to add test-specific attributes:
<!-- Application code -->
<button 
  class="btn btn-primary" 
  data-test="login-button"
>
  Login
</button>
// Test code - stable and readable
cy.get('[data-test="login-button"]').click()

Managing Test Data

Centralize with Fixtures

// ❌ Bad - hardcoded data scattered across tests
it('Test 1', () => {
  cy.get('#username').type('standard_user')
})

it('Test 2', () => {
  cy.get('#username').type('standard_user')  // Duplicated!
})

// ✅ Good - centralized in fixture
cy.fixture('users').then((user) => {
  cy.get('#username').type(user.standard)
})

Environment-Specific Data

Use environment variables for configuration:
// cypress.config.ui.ts
export default defineConfig({
  e2e: {
    baseUrl: process.env.CYPRESS_BASE_URL || 'https://www.saucedemo.com/',
    env: {
      apiUrl: process.env.API_URL || 'https://restful-booker.herokuapp.com'
    }
  }
})

Avoid Test Data Dependencies

// ❌ Bad - tests depend on specific data existing
it('Should edit user #123', () => {
  cy.visit('/users/123')  // What if user 123 doesn't exist?
})

// ✅ Good - create data needed for test
it('Should edit user', () => {
  cy.request('POST', '/api/users', userData).then((res) => {
    const userId = res.body.id
    cy.visit(`/users/${userId}`)
    // Test with known data
  })
})

Test Independence

Each Test Should Be Isolated

// ❌ Bad - tests depend on execution order
it('Should create user', () => {
  // Creates user
  cy.request('POST', '/users', user)
})

it('Should update user', () => {
  // Assumes previous test ran
  cy.request('PUT', '/users/123', updatedUser)
})

// ✅ Good - each test is independent
beforeEach(() => {
  // Each test gets fresh data
  cy.request('POST', '/users', user).then((res) => {
    cy.wrap(res.body.id).as('userId')
  })
})

it('Should update user', () => {
  cy.get('@userId').then((id) => {
    cy.request('PUT', `/users/${id}`, updatedUser)
  })
})

Clean Up After Tests

afterEach(() => {
  // Clean up created data
  cy.get('@userId').then((id) => {
    cy.request('DELETE', `/users/${id}`)
  })
})

Code Reusability

Extract Common Logic

// ❌ Bad - duplicated login logic
it('Test 1', () => {
  cy.visit('/')
  cy.get('#username').type('user')
  cy.get('#password').type('pass')
  cy.get('#login').click()
  // Test logic
})

it('Test 2', () => {
  cy.visit('/')
  cy.get('#username').type('user')
  cy.get('#password').type('pass')
  cy.get('#login').click()
  // Test logic
})

// ✅ Good - reusable method in page object
class LoginPage {
  public userLogin(username: string, password: string) {
    this.typeUsername(username)
    this.typePassword(password)
    this.clickOnLoginButton()
  }
}

it('Test 1', () => {
  loginPage.userLogin('user', 'pass')
  // Test logic
})

Custom Commands for Global Operations

cypress/support/commands.ts
Cypress.Commands.add('loginViaAPI', (username: string, password: string) => {
  cy.request({
    method: 'POST',
    url: '/api/login',
    body: { username, password }
  }).then((response) => {
    window.localStorage.setItem('token', response.body.token)
  })
})

// Use in tests
it('Should access dashboard', () => {
  cy.loginViaAPI('user', 'pass')
  cy.visit('/dashboard')
})

Documentation and Readability

Self-Documenting Test Names

// ❌ Bad - vague
it('Test login', () => {})

// ✅ Good - describes expected behavior
it('Should display error message when login fails with invalid credentials', () => {})

Add Comments for Complex Logic

it('Should verify sorted product list', () => {
  homePage.selectFilterAtoZ()
  
  // Extract product names from DOM and verify they're sorted alphabetically
  cy.get('[data-test="inventory-item"]').then(($els) => {
    const names = [...$els].map(el => el.innerText.trim())
    const sorted = [...names].sort((a, b) => a.localeCompare(b))
    expect(names).to.deep.equal(sorted)
  })
})

Document Page Object Methods

class LoginPage {
  /**
   * Performs complete login workflow
   * @param username - Username to login with (from fixtures/users.json)
   * @param password - Password to login with (from fixtures/users.json)
   * @example
   * loginPage.userLogin(user.standard, user.password)
   */
  public userLogin(username: string, password: string) {
    this.typeUsername(username)
    this.typePassword(password)
    this.clickOnLoginButton()
  }
}

Version Control Best Practices

Commit Messages

# ✅ Good - descriptive commits
git commit -m "Add login page object with error handling"
git commit -m "Fix flaky product filter test by waiting for API"
git commit -m "Update user fixture with new test accounts"

# ❌ Bad - vague commits
git commit -m "Fix tests"
git commit -m "Update"
git commit -m "WIP"

Ignore Generated Files

.gitignore
# Cypress generated files
cypress/screenshots/
cypress/videos/
cypress/downloads/

# Dependencies
node_modules/

# Environment variables (if they contain secrets)
.env.local

Include Test Files

# ✅ Commit test code
git add cypress/e2e/
git add cypress/support/
git add cypress/fixtures/
git add cypress.config.*.ts

Handling Test Failures

Investigate Root Causes

// ❌ Bad - using .skip to ignore failing tests
it.skip('Should process payment', () => {
  // Skipped because it's flaky
})

// ✅ Good - fix the underlying issue
it('Should process payment', () => {
  // Wait for payment API to complete
  cy.intercept('POST', '/api/payment').as('payment')
  cy.get('#pay-button').click()
  cy.wait('@payment')
  cy.get('#success-message').should('be.visible')
})

Use Screenshots and Videos

// Cypress automatically captures on failure
it('Should display product details', () => {
  cy.visit('/products/1')
  cy.get('#product-name').should('be.visible')
  // If this fails, screenshot is automatically saved
})

Debug with cy.debug() and cy.pause()

it('Should complete checkout', () => {
  homePage.addProductToCart(0)
  cy.pause()  // Pause to inspect state
  cartPage.proceedToCheckout()
  cy.debug()  // Log state to console
})

Performance Considerations

Optimize Test Speed

// ❌ Slow - logs in via UI for every test
beforeEach(() => {
  cy.visit('/')
  loginPage.userLogin('user', 'pass')
})

// ✅ Fast - logs in via API
beforeEach(() => {
  cy.loginViaAPI('user', 'pass')
  cy.visit('/dashboard')  // Go directly to the page you need
})

Reduce Unnecessary Actions

// ❌ Slow - clicks through multiple pages
it('Should access settings', () => {
  cy.visit('/')
  homePage.clickMenu()
  homePage.clickProfile()
  profilePage.clickSettings()
})

// ✅ Fast - navigate directly
it('Should access settings', () => {
  cy.loginViaAPI('user', 'pass')
  cy.visit('/settings')  // Direct navigation
})

Disable Video in Development

cypress.config.ui.ts
export default defineConfig({
  e2e: {
    video: false,  // Faster test runs during development
    screenshotOnRunFailure: true  // Keep screenshots for debugging
  }
})

Refactoring Tests

When to Refactor

Duplication

Same code appears in multiple tests

Complexity

Tests are hard to understand

Fragility

Tests break frequently

Slow Execution

Tests take too long to run

Refactoring Example

Before:
it('Should add product to cart', () => {
  cy.visit('/')
  cy.get('#username').type('standard_user')
  cy.get('#password').type('secret_sauce')
  cy.get('#login').click()
  cy.get('[data-test="inventory-item"]').first().find('button').click()
  cy.get('.shopping_cart_badge').should('contain', '1')
})
After:
it('Should add product to cart', () => {
  // Refactored to use page objects and API login
  cy.fixture('users').then((user) => {
    cy.loginViaAPI(user.standard, user.password)
  })
  cy.visit('/')
  homePage.addProductToCart(0)
  homePage.verifyCartCount(1)
})

Monitoring Test Health

Track Flaky Tests

Keep a list of flaky tests and prioritize fixing them:
// Add comments for tests that need improvement
it('Should load dashboard', () => {
  // TODO: This test is flaky - investigate API timing issues
  cy.visit('/dashboard')
})

Review Test Coverage

Ensure critical paths are tested:
// Critical user journeys should have comprehensive tests
describe('Critical: Checkout Flow', () => {
  it('Should complete purchase with credit card', () => {})
  it('Should complete purchase with PayPal', () => {})
  it('Should handle payment failure gracefully', () => {})
})

Next Steps

TypeScript Usage

Leverage TypeScript for maintainability

Test Organization

Structure tests for long-term success

Page Object Model

Master the POM pattern

Running Tests

Execute and monitor your tests

Build docs developers (and LLMs) love