E2E Testing with Cypress

Core Packages Versions

  • cypress: 13.15.2
  • @cypress/vite-dev-server: 5.2.0
  • @cypress/react: 8.0.2
  • typescript: ~5.7.2

End-to-End Testing

We use Cypress for end-to-end testing to validate complete user workflows and frontend behavior. E2E tests simulate real user interactions in a browser environment, focusing on the complete user experience from start to finish.

Key principles:

  • Test complete user journeys and workflows
  • Focus on user interactions and frontend behavior
  • Validate UI state changes and visual feedback
  • Test responsive design and accessibility
  • Ensure seamless navigation flows

Installation

To get up and running with Cypress E2E Testing in a Vite React TypeScript project:

# Install Cypress
npm install cypress --save-dev
# or
pnpm add cypress --save-dev

# Install TypeScript types for Cypress
npm install @types/cypress --save-dev
# or
pnpm add @types/cypress --save-dev

# Open Cypress for first-time setup
npx cypress open
# or
pnpm cypress open

Choose E2E Testing during the setup process.

Configuration

Cypress Configuration for Vite + TypeScript

Create cypress.config.ts in your project root:

import { defineConfig } from 'cypress'

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:5173', // Vite dev server default port
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
    specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}',
    supportFile: 'cypress/support/e2e.ts',
    viewportWidth: 1280,
    viewportHeight: 720,
    video: false, // Set to true for CI/CD
    screenshotOnRunFailure: true,
    defaultCommandTimeout: 10000,
    requestTimeout: 10000,
    responseTimeout: 10000,
    experimentalStudio: true,
  },
})

TypeScript Configuration

Create cypress/tsconfig.json:

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM"],
    "types": ["cypress", "node"],
    "isolatedModules": false,
    "skipLibCheck": true
  },
  "include": [
    "**/*.ts",
    "**/*.tsx"
  ],
  "exclude": []
}

Package.json Scripts

Add these scripts to your package.json:

{
  "scripts": {
    "cypress:open": "cypress open",
    "cypress:run": "cypress run",
    "cypress:run:headless": "cypress run --headless",
    "test:e2e": "start-server-and-test dev http://localhost:5173 cypress:run",
    "test:e2e:open": "start-server-and-test dev http://localhost:5173 cypress:open"
  },
  "devDependencies": {
    "start-server-and-test": "^2.0.3",
    "@types/cypress": "^1.1.3"
  }
}

Directory Structure

cypress/
├── e2e/
│   ├── auth/
│   │   └── login.cy.ts
│   ├── users/
│   │   ├── create-user.cy.ts
│   │   ├── edit-user.cy.ts
│   │   └── delete-user.cy.ts
│   └── forms/
│       └── step-form.cy.ts
├── fixtures/
│   └── users.json
├── support/
│   ├── commands.ts
│   ├── e2e.ts
│   ├── index.d.ts
│   └── pages/
│       ├── LoginPage.ts
│       ├── UserFormPage.ts
│       └── DashboardPage.ts
├── downloads/
└── tsconfig.json

TypeScript Types and Interfaces

Create type definitions in cypress/support/types.ts:

// cypress/support/types.ts
export interface UserData {
  firstName: string
  lastName: string
  email: string
  password: string
  phoneNumber?: string
}

export interface FormData {
  [key: string]: string | number | boolean
}

export interface NavigationItem {
  label: string
  url: string
  testId: string
}

export interface UIState {
  isLoading: boolean
  isVisible: boolean
  isDisabled: boolean
  hasError: boolean
}

Simple CRUD Flow Tests

1. Login Flow

// cypress/e2e/auth/login.cy.ts
describe('Login Flow', () => {
  beforeEach(() => {
    cy.visit('/login')
  })

  it('should login and navigate to dashboard', () => {
    // Fill login form
    cy.get('[data-testid="email-input"]').type('admin@example.com')
    cy.get('[data-testid="password-input"]').type('password123')
    cy.get('[data-testid="login-button"]').click()
    
    // Verify redirect to dashboard
    cy.url().should('include', '/dashboard')
    cy.get('[data-testid="welcome-message"]').should('be.visible')
    cy.get('[data-testid="create-user-button"]').should('be.visible')
  })

  it('should show validation errors', () => {
    cy.get('[data-testid="login-button"]').click()
    
    cy.get('[data-testid="email-error"]').should('contain', 'Email is required')
    cy.get('[data-testid="password-error"]').should('contain', 'Password is required')
  })
})

2. Complete User Creation Flow

// cypress/e2e/users/create-user.cy.ts
describe('Create User Flow', () => {
  beforeEach(() => {
    // Login first
    cy.login('admin@example.com', 'password123')
    cy.visit('/dashboard')
  })

  it('should create a new user through step form', () => {
    // Start user creation
    cy.get('[data-testid="create-user-button"]').click()
    cy.url().should('include', '/users/create')
    
    // Step 1: Basic Information
    cy.get('[data-testid="step-indicator"]').should('contain', 'Step 1 of 3')
    cy.get('[data-testid="first-name-input"]').type('John')
    cy.get('[data-testid="last-name-input"]').type('Doe')
    cy.get('[data-testid="email-input"]').type('john.doe@example.com')
    cy.get('[data-testid="next-button"]').click()
    
    // Step 2: Contact Information
    cy.get('[data-testid="step-indicator"]').should('contain', 'Step 2 of 3')
    cy.get('[data-testid="phone-input"]').type('+1234567890')
    cy.get('[data-testid="address-input"]').type('123 Main St')
    cy.get('[data-testid="city-input"]').type('New York')
    cy.get('[data-testid="country-select"]').select('United States')
    cy.get('[data-testid="next-button"]').click()
    
    // Step 3: Role and Permissions
    cy.get('[data-testid="step-indicator"]').should('contain', 'Step 3 of 3')
    cy.get('[data-testid="role-select"]').select('User')
    cy.get('[data-testid="permissions-admin"]').check()
    cy.get('[data-testid="permissions-read"]').check()
    
    // Submit form
    cy.get('[data-testid="submit-button"]').click()
    
    // Verify success
    cy.get('[data-testid="success-message"]')
      .should('be.visible')
      .and('contain', 'User created successfully')
    
    // Verify redirect to users list
    cy.url().should('include', '/users')
    cy.get('[data-testid="users-table"]').should('contain', 'john.doe@example.com')
  })

  it('should validate required fields in each step', () => {
    cy.get('[data-testid="create-user-button"]').click()
    
    // Step 1 validation
    cy.get('[data-testid="next-button"]').click()
    cy.get('[data-testid="first-name-error"]').should('be.visible')
    cy.get('[data-testid="last-name-error"]').should('be.visible')
    cy.get('[data-testid="email-error"]').should('be.visible')
    
    // Fill step 1 and move to step 2
    cy.get('[data-testid="first-name-input"]').type('John')
    cy.get('[data-testid="last-name-input"]').type('Doe')
    cy.get('[data-testid="email-input"]').type('john@example.com')
    cy.get('[data-testid="next-button"]').click()
    
    // Step 2 validation
    cy.get('[data-testid="next-button"]').click()
    cy.get('[data-testid="phone-error"]').should('be.visible')
    cy.get('[data-testid="address-error"]').should('be.visible')
  })

  it('should allow navigation between steps', () => {
    cy.get('[data-testid="create-user-button"]').click()
    
    // Fill step 1
    cy.get('[data-testid="first-name-input"]').type('John')
    cy.get('[data-testid="last-name-input"]').type('Doe')
    cy.get('[data-testid="email-input"]').type('john@example.com')
    cy.get('[data-testid="next-button"]').click()
    
    // Go back to step 1
    cy.get('[data-testid="back-button"]').click()
    cy.get('[data-testid="step-indicator"]').should('contain', 'Step 1 of 3')
    
    // Verify data is preserved
    cy.get('[data-testid="first-name-input"]').should('have.value', 'John')
    cy.get('[data-testid="last-name-input"]').should('have.value', 'Doe')
    cy.get('[data-testid="email-input"]').should('have.value', 'john@example.com')
  })
})

3. Edit User Flow

// cypress/e2e/users/edit-user.cy.ts
describe('Edit User Flow', () => {
  beforeEach(() => {
    cy.login('admin@example.com', 'password123')
    cy.visit('/users')
  })

  it('should edit an existing user', () => {
    // Find and edit first user
    cy.get('[data-testid="user-row"]').first().within(() => {
      cy.get('[data-testid="edit-button"]').click()
    })
    
    cy.url().should('include', '/users/edit/')
    
    // Verify form is pre-filled
    cy.get('[data-testid="first-name-input"]').should('not.have.value', '')
    cy.get('[data-testid="email-input"]').should('not.have.value', '')
    
    // Edit user information
    cy.get('[data-testid="first-name-input"]').clear().type('Jane')
    cy.get('[data-testid="phone-input"]').clear().type('+9876543210')
    
    // Save changes
    cy.get('[data-testid="save-button"]').click()
    
    // Verify success
    cy.get('[data-testid="success-message"]').should('contain', 'User updated successfully')
    cy.url().should('include', '/users')
    cy.get('[data-testid="users-table"]').should('contain', 'Jane')
  })
})

4. Delete User Flow

// cypress/e2e/users/delete-user.cy.ts
describe('Delete User Flow', () => {
  beforeEach(() => {
    cy.login('admin@example.com', 'password123')
    cy.visit('/users')
  })

  it('should delete a user with confirmation', () => {
    // Count initial users
    cy.get('[data-testid="user-row"]').its('length').as('initialCount')
    
    // Delete first user
    cy.get('[data-testid="user-row"]').first().within(() => {
      cy.get('[data-testid="user-name"]').invoke('text').as('userName')
      cy.get('[data-testid="delete-button"]').click()
    })
    
    // Confirm deletion in modal
    cy.get('[data-testid="confirm-modal"]').should('be.visible')
    cy.get('@userName').then((userName) => {
      cy.get('[data-testid="confirm-text"]').should('contain', userName)
    })
    cy.get('[data-testid="confirm-delete-button"]').click()
    
    // Verify user is deleted
    cy.get('[data-testid="success-message"]').should('contain', 'User deleted successfully')
    
    cy.get('@initialCount').then((initialCount) => {
      cy.get('[data-testid="user-row"]').should('have.length', Number(initialCount) - 1)
    })
  })

  it('should cancel deletion', () => {
    cy.get('[data-testid="user-row"]').its('length').as('initialCount')
    
    cy.get('[data-testid="user-row"]').first().within(() => {
      cy.get('[data-testid="delete-button"]').click()
    })
    
    // Cancel deletion
    cy.get('[data-testid="cancel-button"]').click()
    cy.get('[data-testid="confirm-modal"]').should('not.exist')
    
    // Verify count unchanged
    cy.get('@initialCount').then((initialCount) => {
      cy.get('[data-testid="user-row"]').should('have.length', initialCount)
    })
  })
})

Custom Commands

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>
      fillUserForm(userData: {
        firstName: string
        lastName: string
        email: string
        phone?: string
        address?: string
        city?: string
        role?: string
      }): Chainable<void>
      goToStep(stepNumber: number): Chainable<void>
    }
  }
}

Cypress.Commands.add('login', (email: string, password: string) => {
  cy.session([email, password], () => {
    cy.visit('/login')
    cy.get('[data-testid="email-input"]').type(email)
    cy.get('[data-testid="password-input"]').type(password)
    cy.get('[data-testid="login-button"]').click()
    cy.url().should('include', '/dashboard')
  })
})

Cypress.Commands.add('fillUserForm', (userData) => {
  if (userData.firstName) {
    cy.get('[data-testid="first-name-input"]').type(userData.firstName)
  }
  if (userData.lastName) {
    cy.get('[data-testid="last-name-input"]').type(userData.lastName)
  }
  if (userData.email) {
    cy.get('[data-testid="email-input"]').type(userData.email)
  }
  if (userData.phone) {
    cy.get('[data-testid="phone-input"]').type(userData.phone)
  }
  if (userData.address) {
    cy.get('[data-testid="address-input"]').type(userData.address)
  }
  if (userData.city) {
    cy.get('[data-testid="city-input"]').type(userData.city)
  }
  if (userData.role) {
    cy.get('[data-testid="role-select"]').select(userData.role)
  }
})

Cypress.Commands.add('goToStep', (stepNumber: number) => {
  // Navigate to specific step in multi-step form
  for (let i = 1; i < stepNumber; i++) {
    cy.get('[data-testid="next-button"]').click()
  }
  cy.get('[data-testid="step-indicator"]').should('contain', `Step ${stepNumber}`)
})

Page Objects (Simplified)

// cypress/support/pages/UserFormPage.ts
export class UserFormPage {
  fillBasicInfo(firstName: string, lastName: string, email: string) {
    cy.get('[data-testid="first-name-input"]').type(firstName)
    cy.get('[data-testid="last-name-input"]').type(lastName)
    cy.get('[data-testid="email-input"]').type(email)
    return this
  }

  fillContactInfo(phone: string, address: string, city: string) {
    cy.get('[data-testid="phone-input"]').type(phone)
    cy.get('[data-testid="address-input"]').type(address)
    cy.get('[data-testid="city-input"]').type(city)
    return this
  }

  selectRole(role: string) {
    cy.get('[data-testid="role-select"]').select(role)
    return this
  }

  nextStep() {
    cy.get('[data-testid="next-button"]').click()
    return this
  }

  submit() {
    cy.get('[data-testid="submit-button"]').click()
    return this
  }

  verifySuccess() {
    cy.get('[data-testid="success-message"]').should('be.visible')
    return this
  }
}

// Usage example
describe('User Form with Page Object', () => {
  it('should create user using page object', () => {
    const userForm = new UserFormPage()
    
    cy.login('admin@example.com', 'password123')
    cy.visit('/users/create')
    
    userForm
      .fillBasicInfo('John', 'Doe', 'john@example.com')
      .nextStep()
      .fillContactInfo('+1234567890', '123 Main St', 'New York')
      .nextStep()
      .selectRole('User')
      .submit()
      .verifySuccess()
  })
})

Form Validation Examples

// cypress/e2e/forms/step-form.cy.ts
describe('Step Form Validation', () => {
  beforeEach(() => {
    cy.login('admin@example.com', 'password123')
    cy.visit('/users/create')
  })

  it('should validate email format', () => {
    cy.get('[data-testid="email-input"]').type('invalid-email')
    cy.get('[data-testid="next-button"]').click()
    
    cy.get('[data-testid="email-error"]')
      .should('be.visible')
      .and('contain', 'Please enter a valid email')
  })

  it('should validate phone format', () => {
    // Complete step 1
    cy.fillUserForm({
      firstName: 'John',
      lastName: 'Doe',
      email: 'john@example.com'
    })
    cy.get('[data-testid="next-button"]').click()
    
    // Test invalid phone in step 2
    cy.get('[data-testid="phone-input"]').type('invalid-phone')
    cy.get('[data-testid="next-button"]').click()
    
    cy.get('[data-testid="phone-error"]')
      .should('be.visible')
      .and('contain', 'Please enter a valid phone number')
  })

  it('should show loading state during submission', () => {
    // Fill all steps
    cy.fillUserForm({
      firstName: 'John',
      lastName: 'Doe',
      email: 'john@example.com'
    })
    cy.get('[data-testid="next-button"]').click()
    
    cy.fillUserForm({
      phone: '+1234567890',
      address: '123 Main St',
      city: 'New York'
    })
    cy.get('[data-testid="next-button"]').click()
    
    cy.get('[data-testid="role-select"]').select('User')
    
    // Submit and verify loading state
    cy.get('[data-testid="submit-button"]').click()
    cy.get('[data-testid="submit-button"]').should('be.disabled')
    cy.get('[data-testid="loading-spinner"]').should('be.visible')
  })
})

Test Data

// cypress/fixtures/users.json
{
  "validUser": {
    "firstName": "John",
    "lastName": "Doe",
    "email": "john.doe@example.com",
    "phone": "+1234567890",
    "address": "123 Main St",
    "city": "New York",
    "role": "User"
  },
  "adminUser": {
    "firstName": "Admin",
    "lastName": "User",
    "email": "admin@example.com",
    "phone": "+9876543210",
    "address": "456 Admin Ave",
    "city": "Admin City",
    "role": "Admin"
  }
}

Running Tests

# Open Cypress Test Runner
npm run cypress:open

# Run tests headlessly
npm run cypress:run

# Run specific test file
npm run cypress run --spec "cypress/e2e/users/create-user.cy.ts"

# Run all user CRUD tests
npm run cypress run --spec "cypress/e2e/users/**/*.cy.ts"

Best Practices for Simple CRUD Testing

Test Organization

  • One test file per main operation (create, edit, delete)
  • Group related tests in describe blocks
  • Use clear, descriptive test names

Data Management

  • Use cy.session() for login to avoid repeated authentication
  • Create test data in fixtures for reusability
  • Clean up test data when necessary

Form Testing

  • Test each step of multi-step forms independently
  • Verify form validation on each field
  • Test navigation between form steps
  • Verify data persistence across steps

Assertions

  • Focus on user-visible outcomes
  • Verify success/error messages
  • Check URL changes after operations
  • Validate data appears in lists/tables

Selectors

  • Always use data-testid attributes
  • Make selectors specific and stable
  • Avoid CSS class or text-based selectors

This simplified approach focuses on practical CRUD workflows that are common in business applications, making it easy to understand and implement.