TestingEnd-to-End Testing

End-to-End Testing

Learn about End-to-End (E2E) testing with Selenium WebDriver, Playwright, and Cypress. Understand when to use each tool and how to implement comprehensive E2E test strategies.

What is End-to-End Testing?

End-to-End (E2E) testing validates your entire application flow from start to finish, simulating real user scenarios across multiple systems and components.

Characteristics

  • Full Stack: Tests the complete application, including frontend, backend, and databases
  • User Perspective: Simulates real user interactions and workflows
  • High Confidence: Catches integration issues that unit and integration tests might miss
  • Slower Execution: Takes more time to run compared to unit tests
  • Environment Dependent: Requires a complete testing environment

E2E tests should focus on critical user journeys and business workflows rather than covering every possible scenario. Use unit and integration tests for detailed coverage.

Testing Tools Comparison

FeatureSeleniumPlaywrightCypress
Language SupportJava, Python, C#, Ruby, JavaScriptJavaScript, TypeScript, Python, Java, .NETJavaScript, TypeScript
Browser SupportChrome, Firefox, Safari, Edge, IEChrome, Firefox, Safari, EdgeChrome, Firefox, Edge
Mobile TestingYes (Appium integration)Yes (Device emulation)Limited (viewport only)
Auto-WaitNo (manual waits)YesYes
SpeedModerateFastFast
Network MockingNoYesYes
Component TestingNoYesYes
DebuggingStandard browser DevToolsPlaywright Inspector, Trace ViewerTime-travel debugging
Cross-browserExcellentExcellentGood
Learning CurveSteepModerateEasy
Parallel TestingGrid requiredBuilt-inDashboard required
Best ForCross-browser, legacy appsModern apps, API testingDeveloper experience, fast feedback

Selenium WebDriver

Selenium is the most mature and widely-used browser automation framework, supporting multiple languages and browsers.

What is Selenium?

Selenium WebDriver is a browser automation framework that provides a programming interface to create and execute test scripts in real browsers. It supports multiple programming languages and browsers, making it ideal for cross-browser testing.

Key Components

  • WebDriver: API for browser automation
  • Selenium Grid: Distributed test execution across multiple machines
  • Browser Drivers: Browser-specific implementations (ChromeDriver, GeckoDriver, etc.)
  • Selenium IDE: Record and playback tool for creating tests

Selenium Architecture

┌─────────────────────┐
│   Test Script       │
│   (Java/Python/JS)  │
└──────────┬──────────┘


┌─────────────────────┐
│  WebDriver API      │
│  (Language Binding) │
└──────────┬──────────┘


┌─────────────────────┐
│  JSON Wire Protocol │
│  /W3C WebDriver     │
└──────────┬──────────┘


┌─────────────────────┐
│  Browser Driver     │
│  (ChromeDriver,     │
│   GeckoDriver, etc.)│
└──────────┬──────────┘


┌─────────────────────┐
│     Browser         │
│  (Chrome, Firefox)  │
└─────────────────────┘

Selenium communicates with browsers through browser-specific drivers using the W3C WebDriver protocol, allowing language-agnostic browser automation.

WebDriver Setup

Chrome Setup

// npm install selenium-webdriver chromedriver
import { Builder, Browser, By, until } from 'selenium-webdriver'
import chrome from 'selenium-webdriver/chrome'
 
async function setupChrome() {
  const options = new chrome.Options()
  
  // Headless mode (no UI)
  options.addArguments('--headless')
  
  // Window size
  options.addArguments('--window-size=1920,1080')
  
  // Disable GPU (for CI environments)
  options.addArguments('--disable-gpu')
  
  // Disable dev shm (for Docker)
  options.addArguments('--disable-dev-shm-usage')
  
  // No sandbox (for CI)
  options.addArguments('--no-sandbox')
  
  const driver = await new Builder()
    .forBrowser(Browser.CHROME)
    .setChromeOptions(options)
    .build()
 
  return driver
}
 
// Usage
const driver = await setupChrome()
try {
  await driver.get('https://example.com')
  // Test code here
} finally {
  await driver.quit()
}

Firefox Setup

import { Builder, Browser } from 'selenium-webdriver'
import firefox from 'selenium-webdriver/firefox'
 
async function setupFirefox() {
  const options = new firefox.Options()
  
  // Headless mode
  options.addArguments('-headless')
  
  // Set preferences
  options.setPreference('dom.webnotifications.enabled', false)
  options.setPreference('geo.enabled', false)
  
  const driver = await new Builder()
    .forBrowser(Browser.FIREFOX)
    .setFirefoxOptions(options)
    .build()
 
  return driver
}

Edge Setup

import { Builder, Browser } from 'selenium-webdriver'
import edge from 'selenium-webdriver/edge'
 
async function setupEdge() {
  const options = new edge.Options()
  
  options.addArguments('--headless')
  options.addArguments('--disable-gpu')
  
  const driver = await new Builder()
    .forBrowser(Browser.EDGE)
    .setEdgeOptions(options)
    .build()
 
  return driver
}

Cross-Browser Test Setup

import { describe, it, beforeEach, afterEach } from 'vitest'
import { WebDriver, Builder, Browser } from 'selenium-webdriver'
 
const browsers = [Browser.CHROME, Browser.FIREFOX, Browser.EDGE]
 
browsers.forEach(browserName => {
  describe(`Tests on ${browserName}`, () => {
    let driver: WebDriver
 
    beforeEach(async () => {
      driver = await new Builder()
        .forBrowser(browserName)
        .build()
    })
 
    afterEach(async () => {
      await driver.quit()
    })
 
    it('should work on all browsers', async () => {
      await driver.get('https://example.com')
      const title = await driver.getTitle()
      expect(title).toBe('Example Domain')
    })
  })
})

Locator Strategies

Selenium provides multiple strategies for locating elements on a page.

By ID

The most reliable and fastest locator strategy.

import { By } from 'selenium-webdriver'
 
// HTML: <input id="email" type="email" />
const emailInput = await driver.findElement(By.id('email'))
await emailInput.sendKeys('user@example.com')
 
// Multiple elements (returns first match)
const element = await driver.findElement(By.id('unique-id'))
⚠️

IDs should be unique on the page. If an ID appears multiple times, findElement returns the first match, which may not be the element you want.

By Name

Useful for form elements.

// HTML: <input name="username" />
const usernameInput = await driver.findElement(By.name('username'))
await usernameInput.sendKeys('john_doe')
 
// Find all elements with the same name (radio buttons, checkboxes)
const checkboxes = await driver.findElements(By.name('interests'))
for (const checkbox of checkboxes) {
  await checkbox.click()
}

By Class Name

Locates elements by CSS class.

// HTML: <button class="btn btn-primary">Submit</button>
const button = await driver.findElement(By.className('btn-primary'))
await button.click()
 
// Multiple elements
const allButtons = await driver.findElements(By.className('btn'))
console.log(`Found ${allButtons.length} buttons`)

By Tag Name

Locates elements by HTML tag.

// Find all links
const links = await driver.findElements(By.tagName('a'))
console.log(`Found ${links.length} links`)
 
// Find first h1
const heading = await driver.findElement(By.tagName('h1'))
const text = await heading.getText()

Locates links by their exact text content.

// HTML: <a href="/about">About Us</a>
const aboutLink = await driver.findElement(By.linkText('About Us'))
await aboutLink.click()
 
// Case-sensitive exact match required
const link = await driver.findElement(By.linkText('Sign In'))

Locates links by partial text match.

// HTML: <a href="/products">View All Products</a>
const productsLink = await driver.findElement(By.partialLinkText('Products'))
await productsLink.click()
 
// Useful when link text is dynamic
const link = await driver.findElement(By.partialLinkText('Welcome'))

By CSS Selector

Powerful and flexible locator using CSS selectors.

// By ID
await driver.findElement(By.css('#email'))
 
// By class
await driver.findElement(By.css('.btn-primary'))
 
// By attribute
await driver.findElement(By.css('[data-testid="submit-button"]'))
await driver.findElement(By.css('input[type="email"]'))
 
// Descendant selectors
await driver.findElement(By.css('form .error-message'))
await driver.findElement(By.css('#login-form input[name="password"]'))
 
// Pseudo-classes
await driver.findElement(By.css('button:enabled'))
await driver.findElement(By.css('li:first-child'))
await driver.findElement(By.css('input:not([disabled])'))
 
// Attribute contains
await driver.findElement(By.css('[class*="btn"]'))
await driver.findElement(By.css('[href^="https"]'))
await driver.findElement(By.css('[href$=".pdf"]'))
 
// Complex selectors
await driver.findElement(By.css('nav > ul > li:nth-child(2) > a'))

By XPath

Most powerful but also most fragile locator strategy.

// Absolute XPath (avoid - brittle)
await driver.findElement(By.xpath('/html/body/div[1]/form/input[2]'))
 
// Relative XPath (preferred)
await driver.findElement(By.xpath('//input[@id="email"]'))
 
// By text content
await driver.findElement(By.xpath('//button[text()="Submit"]'))
await driver.findElement(By.xpath('//h1[contains(text(), "Welcome")]'))
 
// By attribute
await driver.findElement(By.xpath('//input[@name="username"]'))
await driver.findElement(By.xpath('//a[@href="/about"]'))
 
// Contains
await driver.findElement(By.xpath('//div[contains(@class, "error")]'))
await driver.findElement(By.xpath('//span[contains(text(), "Error")]'))
 
// Parent selection
await driver.findElement(By.xpath('//input[@id="email"]/..'))
await driver.findElement(By.xpath('//button/parent::form'))
 
// Following sibling
await driver.findElement(By.xpath('//label[text()="Email"]/following-sibling::input'))
 
// Preceding sibling
await driver.findElement(By.xpath('//input[@id="email"]/preceding-sibling::label'))
 
// Axes combinations
await driver.findElement(By.xpath('//form//input[@type="text"][1]'))
await driver.findElement(By.xpath('//div[@id="container"]//button[last()]'))
 
// Multiple conditions
await driver.findElement(By.xpath('//input[@type="text" and @name="email"]'))
await driver.findElement(By.xpath('//button[@type="submit" or @class="submit"]'))
⚠️

XPath Best Practices:

  • Avoid absolute XPaths - they break easily when page structure changes
  • Prefer relative XPaths starting with //
  • Use contains() for partial matches
  • Combine with unique attributes when possible
  • Test XPaths in browser DevTools before using in code

Locator Strategy Priority

  1. ID - Fastest and most reliable
  2. Name - Good for form elements
  3. CSS Selector - Flexible and performant
  4. XPath - Most powerful but slower and fragile
  5. Class/Tag - Use when combined with other strategies
  6. Link Text - Good for navigation links
// ✅ Good - Specific and reliable
await driver.findElement(By.id('submit-btn'))
await driver.findElement(By.css('[data-testid="user-profile"]'))
 
// ⚠️ Acceptable - Less specific
await driver.findElement(By.css('.user-profile'))
await driver.findElement(By.name('email'))
 
// ❌ Avoid - Fragile and slow
await driver.findElement(By.xpath('/html/body/div[3]/div[2]/form/button'))
await driver.findElement(By.className('btn'))

Basic Commands and Navigation

import { WebDriver } from 'selenium-webdriver'
 
async function navigationExamples(driver: WebDriver) {
  // Navigate to URL
  await driver.get('https://example.com')
  
  // Get current URL
  const currentUrl = await driver.getCurrentUrl()
  console.log(`Current URL: ${currentUrl}`)
  
  // Get page title
  const title = await driver.getTitle()
  console.log(`Page Title: ${title}`)
  
  // Navigate back
  await driver.navigate().back()
  
  // Navigate forward
  await driver.navigate().forward()
  
  // Refresh page
  await driver.navigate().refresh()
  
  // Navigate to URL (alternative)
  await driver.navigate().to('https://example.com/page')
}

Element Interactions

import { By, Key } from 'selenium-webdriver'
 
async function elementInteractions(driver: WebDriver) {
  // Click element
  const button = await driver.findElement(By.id('submit'))
  await button.click()
  
  // Type text
  const input = await driver.findElement(By.name('username'))
  await input.sendKeys('john_doe')
  
  // Clear input
  await input.clear()
  
  // Send special keys
  await input.sendKeys('test', Key.ENTER)
  await input.sendKeys(Key.chord(Key.CONTROL, 'a')) // Ctrl+A
  
  // Get text content
  const heading = await driver.findElement(By.tagName('h1'))
  const text = await heading.getText()
  
  // Get attribute value
  const link = await driver.findElement(By.tagName('a'))
  const href = await link.getAttribute('href')
  const className = await link.getAttribute('class')
  
  // Get CSS property value
  const color = await button.getCssValue('color')
  const fontSize = await button.getCssValue('font-size')
  
  // Check if element is displayed
  const isDisplayed = await button.isDisplayed()
  
  // Check if element is enabled
  const isEnabled = await button.isEnabled()
  
  // Check if checkbox/radio is selected
  const checkbox = await driver.findElement(By.id('terms'))
  const isSelected = await checkbox.isSelected()
  
  // Submit form
  const form = await driver.findElement(By.tagName('form'))
  await form.submit()
}

Working with Multiple Elements

async function multipleElements(driver: WebDriver) {
  // Find all matching elements
  const listItems = await driver.findElements(By.tagName('li'))
  
  console.log(`Found ${listItems.length} items`)
  
  // Iterate through elements
  for (const item of listItems) {
    const text = await item.getText()
    console.log(text)
  }
  
  // Click specific element
  if (listItems.length > 0) {
    await listItems[0].click()
  }
  
  // Find nested elements
  const container = await driver.findElement(By.id('container'))
  const buttons = await container.findElements(By.tagName('button'))
}

Handling Forms and Inputs

Text Inputs

async function handleTextInputs(driver: WebDriver) {
  // Basic text input
  const nameInput = await driver.findElement(By.id('name'))
  await nameInput.sendKeys('John Doe')
  
  // Email input
  const emailInput = await driver.findElement(By.css('input[type="email"]'))
  await emailInput.clear()
  await emailInput.sendKeys('john@example.com')
  
  // Password input
  const passwordInput = await driver.findElement(By.name('password'))
  await passwordInput.sendKeys('secretPassword123')
  
  // Textarea
  const commentBox = await driver.findElement(By.tagName('textarea'))
  await commentBox.sendKeys('This is a long comment\nwith multiple lines')
  
  // Verify value
  const value = await nameInput.getAttribute('value')
  expect(value).toBe('John Doe')
}

Checkboxes and Radio Buttons

async function handleCheckboxesAndRadios(driver: WebDriver) {
  // Check a checkbox
  const termsCheckbox = await driver.findElement(By.id('terms'))
  
  if (!await termsCheckbox.isSelected()) {
    await termsCheckbox.click()
  }
  
  // Verify checkbox is checked
  expect(await termsCheckbox.isSelected()).toBe(true)
  
  // Uncheck checkbox
  if (await termsCheckbox.isSelected()) {
    await termsCheckbox.click()
  }
  
  // Select radio button
  const maleRadio = await driver.findElement(By.css('input[value="male"]'))
  await maleRadio.click()
  
  // Verify radio is selected
  expect(await maleRadio.isSelected()).toBe(true)
  
  // Work with multiple checkboxes
  const checkboxes = await driver.findElements(By.css('input[type="checkbox"]'))
  
  for (const checkbox of checkboxes) {
    if (!await checkbox.isSelected()) {
      await checkbox.click()
    }
  }
}
import { Select } from 'selenium-webdriver/lib/select'
 
async function handleDropdowns(driver: WebDriver) {
  const selectElement = await driver.findElement(By.id('country'))
  const select = new Select(selectElement)
  
  // Select by visible text
  await select.selectByVisibleText('United States')
  
  // Select by value
  await select.selectByValue('us')
  
  // Select by index
  await select.selectByIndex(1)
  
  // Get selected option
  const selectedOption = await select.getFirstSelectedOption()
  const selectedText = await selectedOption.getText()
  console.log(`Selected: ${selectedText}`)
  
  // Get all options
  const options = await select.getOptions()
  console.log(`Total options: ${options.length}`)
  
  // Multi-select dropdown
  const multiSelect = new Select(await driver.findElement(By.id('skills')))
  
  // Select multiple options
  await multiSelect.selectByVisibleText('JavaScript')
  await multiSelect.selectByVisibleText('TypeScript')
  await multiSelect.selectByVisibleText('Python')
  
  // Get all selected options
  const selectedOptions = await multiSelect.getAllSelectedOptions()
  console.log(`Selected ${selectedOptions.length} skills`)
  
  // Deselect options
  await multiSelect.deselectByVisibleText('Python')
  await multiSelect.deselectAll()
}

File Uploads

import path from 'path'
 
async function handleFileUpload(driver: WebDriver) {
  const fileInput = await driver.findElement(By.css('input[type="file"]'))
  
  // Path to file (absolute path required)
  const filePath = path.resolve(__dirname, 'test-files', 'document.pdf')
  
  // Send file path to input
  await fileInput.sendKeys(filePath)
  
  // Verify file is selected
  const fileName = await fileInput.getAttribute('value')
  expect(fileName).toContain('document.pdf')
  
  // Submit form
  const uploadButton = await driver.findElement(By.id('upload-btn'))
  await uploadButton.click()
}

Handling Alerts and Popups

JavaScript Alerts

import { until } from 'selenium-webdriver'
 
async function handleAlerts(driver: WebDriver) {
  // Trigger alert
  const alertButton = await driver.findElement(By.id('alert-btn'))
  await alertButton.click()
  
  // Wait for alert to appear
  await driver.wait(until.alertIsPresent(), 5000)
  
  // Switch to alert
  const alert = await driver.switchTo().alert()
  
  // Get alert text
  const alertText = await alert.getText()
  console.log(`Alert says: ${alertText}`)
  
  // Accept alert (click OK)
  await alert.accept()
}

Confirm Dialogs

async function handleConfirm(driver: WebDriver) {
  const confirmButton = await driver.findElement(By.id('confirm-btn'))
  await confirmButton.click()
  
  await driver.wait(until.alertIsPresent(), 5000)
  const confirmDialog = await driver.switchTo().alert()
  
  const text = await confirmDialog.getText()
  console.log(`Confirm dialog: ${text}`)
  
  // Accept (OK)
  await confirmDialog.accept()
  
  // Or dismiss (Cancel)
  // await confirmDialog.dismiss()
}

Prompt Dialogs

async function handlePrompt(driver: WebDriver) {
  const promptButton = await driver.findElement(By.id('prompt-btn'))
  await promptButton.click()
  
  await driver.wait(until.alertIsPresent(), 5000)
  const promptDialog = await driver.switchTo().alert()
  
  // Type into prompt
  await promptDialog.sendKeys('John Doe')
  
  // Submit
  await promptDialog.accept()
  
  // Or cancel
  // await promptDialog.dismiss()
}

Window Handles and Popups

async function handlePopupWindows(driver: WebDriver) {
  // Get current window handle
  const mainWindow = await driver.getWindowHandle()
  
  // Click link that opens new window
  const popupLink = await driver.findElement(By.id('open-popup'))
  await popupLink.click()
  
  // Get all window handles
  const windows = await driver.getAllWindowHandles()
  
  // Switch to new window
  for (const handle of windows) {
    if (handle !== mainWindow) {
      await driver.switchTo().window(handle)
      break
    }
  }
  
  // Do something in popup
  const heading = await driver.findElement(By.tagName('h1'))
  const text = await heading.getText()
  console.log(`Popup heading: ${text}`)
  
  // Close popup
  await driver.close()
  
  // Switch back to main window
  await driver.switchTo().window(mainWindow)
}

iFrames

async function handleIframes(driver: WebDriver) {
  // Switch to iframe by index
  await driver.switchTo().frame(0)
  
  // Switch to iframe by name or ID
  await driver.switchTo().frame('iframe-name')
  
  // Switch to iframe by WebElement
  const iframeElement = await driver.findElement(By.id('my-iframe'))
  await driver.switchTo().frame(iframeElement)
  
  // Interact with elements inside iframe
  const button = await driver.findElement(By.id('iframe-button'))
  await button.click()
  
  // Switch back to main content
  await driver.switchTo().defaultContent()
  
  // Switch to parent frame
  await driver.switchTo().parentFrame()
}

Waiting Strategies

Implicit Waits

Tells WebDriver to poll the DOM for a certain amount of time when trying to find elements.

async function implicitWaitExample(driver: WebDriver) {
  // Set implicit wait (applies to all findElement calls)
  await driver.manage().setTimeouts({ implicit: 10000 }) // 10 seconds
  
  // This will wait up to 10 seconds for element to appear
  const element = await driver.findElement(By.id('dynamic-element'))
  await element.click()
}
⚠️

Don’t mix implicit and explicit waits! It can lead to unpredictable wait times. Choose one strategy and stick with it.

Explicit Waits

Wait for a specific condition to be true before proceeding.

import { until, WebDriver } from 'selenium-webdriver'
 
async function explicitWaitExamples(driver: WebDriver) {
  // Wait for element to be located
  await driver.wait(
    until.elementLocated(By.id('submit-btn')),
    10000,
    'Element not found'
  )
  
  // Wait for element to be visible
  const element = await driver.findElement(By.id('message'))
  await driver.wait(
    until.elementIsVisible(element),
    5000
  )
  
  // Wait for element to be enabled
  const button = await driver.findElement(By.id('submit'))
  await driver.wait(
    until.elementIsEnabled(button),
    5000
  )
  
  // Wait for element to be selected
  const checkbox = await driver.findElement(By.id('terms'))
  await driver.wait(
    until.elementIsSelected(checkbox),
    5000
  )
  
  // Wait for element to contain text
  await driver.wait(
    until.elementTextContains(element, 'Success'),
    5000
  )
  
  // Wait for element to be clickable (visible and enabled)
  await driver.wait(
    until.elementIsEnabled(button),
    5000
  )
  await driver.wait(
    until.elementIsVisible(button),
    5000
  )
  
  // Wait for title to contain text
  await driver.wait(
    until.titleContains('Dashboard'),
    10000
  )
  
  // Wait for URL to contain text
  await driver.wait(
    until.urlContains('/dashboard'),
    10000
  )
}

Fluent Waits

More flexible waiting with custom polling intervals and ignore exceptions.

import { WebDriver, until, WebElement } from 'selenium-webdriver'
 
async function fluentWaitExample(driver: WebDriver) {
  // Custom wait function
  const waitForElementText = async (
    locator: any,
    expectedText: string,
    timeout: number = 10000
  ): Promise<WebElement> => {
    const endTime = Date.now() + timeout
    
    while (Date.now() < endTime) {
      try {
        const element = await driver.findElement(locator)
        const text = await element.getText()
        
        if (text.includes(expectedText)) {
          return element
        }
      } catch (error) {
        // Ignore NoSuchElementException
      }
      
      // Poll every 500ms
      await driver.sleep(500)
    }
    
    throw new Error(`Element with text "${expectedText}" not found within ${timeout}ms`)
  }
  
  // Usage
  const element = await waitForElementText(
    By.id('status'),
    'Complete',
    15000
  )
}

Custom Wait Conditions

import { WebDriver, Condition } from 'selenium-webdriver'
 
async function customWaitConditions(driver: WebDriver) {
  // Wait for element to have specific class
  const hasClass = new Condition(
    'for element to have class',
    async () => {
      const element = await driver.findElement(By.id('button'))
      const className = await element.getAttribute('class')
      return className?.includes('active') || false
    }
  )
  
  await driver.wait(hasClass, 10000)
  
  // Wait for element count
  const elementCountIs = (locator: any, count: number) => {
    return new Condition(
      `for element count to be ${count}`,
      async () => {
        const elements = await driver.findElements(locator)
        return elements.length === count
      }
    )
  }
  
  await driver.wait(elementCountIs(By.className('item'), 5), 10000)
  
  // Wait for AJAX request to complete
  const ajaxComplete = new Condition(
    'for AJAX to complete',
    async () => {
      const result = await driver.executeScript(
        'return jQuery.active === 0'
      )
      return result as boolean
    }
  )
  
  await driver.wait(ajaxComplete, 10000)
}

Page Object Model (POM) Pattern

The Page Object Model is a design pattern that creates an object repository for web elements, improving test maintenance and reducing code duplication.

Benefits

  • Maintainability: Changes to UI require updates in one place
  • Reusability: Page objects can be used across multiple tests
  • Readability: Tests are more readable and easier to understand
  • Separation of Concerns: Separates test logic from page interactions

Basic Page Object

// pages/LoginPage.ts
import { WebDriver, By, until } from 'selenium-webdriver'
 
export class LoginPage {
  private driver: WebDriver
  private url = 'https://example.com/login'
 
  // Locators
  private locators = {
    emailInput: By.id('email'),
    passwordInput: By.id('password'),
    submitButton: By.css('button[type="submit"]'),
    errorMessage: By.className('error-message'),
    successMessage: By.className('success-message'),
  }
 
  constructor(driver: WebDriver) {
    this.driver = driver
  }
 
  // Page actions
  async navigate(): Promise<void> {
    await this.driver.get(this.url)
  }
 
  async enterEmail(email: string): Promise<void> {
    const emailField = await this.driver.findElement(this.locators.emailInput)
    await emailField.clear()
    await emailField.sendKeys(email)
  }
 
  async enterPassword(password: string): Promise<void> {
    const passwordField = await this.driver.findElement(this.locators.passwordInput)
    await passwordField.clear()
    await passwordField.sendKeys(password)
  }
 
  async clickSubmit(): Promise<void> {
    const submitBtn = await this.driver.findElement(this.locators.submitButton)
    await submitBtn.click()
  }
 
  async login(email: string, password: string): Promise<void> {
    await this.enterEmail(email)
    await this.enterPassword(password)
    await this.clickSubmit()
  }
 
  // Assertions/Verifications
  async getErrorMessage(): Promise<string> {
    await this.driver.wait(
      until.elementLocated(this.locators.errorMessage),
      5000
    )
    const errorElement = await this.driver.findElement(this.locators.errorMessage)
    return await errorElement.getText()
  }
 
  async isLoginSuccessful(): Promise<boolean> {
    try {
      await this.driver.wait(
        until.elementLocated(this.locators.successMessage),
        5000
      )
      return true
    } catch {
      return false
    }
  }
 
  async getCurrentUrl(): Promise<string> {
    return await this.driver.getCurrentUrl()
  }
}

Advanced Page Object with Base Page

// pages/BasePage.ts
import { WebDriver, By, until, WebElement } from 'selenium-webdriver'
 
export abstract class BasePage {
  protected driver: WebDriver
  protected url: string = ''
 
  constructor(driver: WebDriver) {
    this.driver = driver
  }
 
  async navigate(): Promise<void> {
    await this.driver.get(this.url)
  }
 
  async getCurrentUrl(): Promise<string> {
    return await this.driver.getCurrentUrl()
  }
 
  async getTitle(): Promise<string> {
    return await this.driver.getTitle()
  }
 
  protected async findElement(locator: By): Promise<WebElement> {
    await this.driver.wait(until.elementLocated(locator), 10000)
    return await this.driver.findElement(locator)
  }
 
  protected async findElements(locator: By): Promise<WebElement[]> {
    return await this.driver.findElements(locator)
  }
 
  protected async click(locator: By): Promise<void> {
    const element = await this.findElement(locator)
    await this.driver.wait(until.elementIsVisible(element), 5000)
    await element.click()
  }
 
  protected async type(locator: By, text: string): Promise<void> {
    const element = await this.findElement(locator)
    await element.clear()
    await element.sendKeys(text)
  }
 
  protected async getText(locator: By): Promise<string> {
    const element = await this.findElement(locator)
    return await element.getText()
  }
 
  protected async isDisplayed(locator: By): Promise<boolean> {
    try {
      const element = await this.findElement(locator)
      return await element.isDisplayed()
    } catch {
      return false
    }
  }
 
  protected async waitForElement(locator: By, timeout: number = 10000): Promise<void> {
    await this.driver.wait(until.elementLocated(locator), timeout)
  }
 
  protected async waitForElementVisible(locator: By, timeout: number = 10000): Promise<void> {
    const element = await this.findElement(locator)
    await this.driver.wait(until.elementIsVisible(element), timeout)
  }
}
// pages/DashboardPage.ts
import { By } from 'selenium-webdriver'
import { BasePage } from './BasePage'
 
export class DashboardPage extends BasePage {
  protected url = 'https://example.com/dashboard'
 
  private locators = {
    welcomeMessage: By.id('welcome'),
    logoutButton: By.id('logout'),
    userMenu: By.className('user-menu'),
    notificationBadge: By.className('notification-badge'),
  }
 
  async getWelcomeMessage(): Promise<string> {
    return await this.getText(this.locators.welcomeMessage)
  }
 
  async logout(): Promise<void> {
    await this.click(this.locators.logoutButton)
  }
 
  async openUserMenu(): Promise<void> {
    await this.click(this.locators.userMenu)
  }
 
  async getNotificationCount(): Promise<number> {
    const badge = await this.findElement(this.locators.notificationBadge)
    const text = await badge.getText()
    return parseInt(text, 10)
  }
}

Full POM Test Example

// tests/login.test.ts
import { describe, it, beforeEach, afterEach } from 'vitest'
import { Builder, Browser, WebDriver } from 'selenium-webdriver'
import { LoginPage } from '../pages/LoginPage'
import { DashboardPage } from '../pages/DashboardPage'
 
describe('Login Flow', () => {
  let driver: WebDriver
  let loginPage: LoginPage
  let dashboardPage: DashboardPage
 
  beforeEach(async () => {
    driver = await new Builder().forBrowser(Browser.CHROME).build()
    loginPage = new LoginPage(driver)
    dashboardPage = new DashboardPage(driver)
  })
 
  afterEach(async () => {
    await driver.quit()
  })
 
  it('should login successfully with valid credentials', async () => {
    await loginPage.navigate()
    await loginPage.login('user@example.com', 'password123')
 
    // Verify redirect to dashboard
    await dashboardPage.waitForElement(By.id('welcome'), 10000)
    
    const welcomeMessage = await dashboardPage.getWelcomeMessage()
    expect(welcomeMessage).toContain('Welcome')
    
    const currentUrl = await dashboardPage.getCurrentUrl()
    expect(currentUrl).toContain('/dashboard')
  })
 
  it('should show error message with invalid credentials', async () => {
    await loginPage.navigate()
    await loginPage.login('invalid@example.com', 'wrongpassword')
 
    const errorMessage = await loginPage.getErrorMessage()
    expect(errorMessage).toBe('Invalid email or password')
  })
 
  it('should logout successfully', async () => {
    // First login
    await loginPage.navigate()
    await loginPage.login('user@example.com', 'password123')
    
    // Then logout
    await dashboardPage.logout()
    
    // Verify redirect to login
    const currentUrl = await driver.getCurrentUrl()
    expect(currentUrl).toContain('/login')
  })
})

Selenium Grid

Selenium Grid allows you to run tests in parallel across multiple machines and browsers.

Grid Architecture

┌──────────────────┐
│   Grid Hub       │
│  (Coordinator)   │
└────────┬─────────┘

    ┌────┴────┬─────────┬─────────┐
    │         │         │         │
┌───▼───┐ ┌──▼───┐ ┌───▼───┐ ┌───▼───┐
│ Node 1│ │Node 2│ │ Node 3│ │ Node 4│
│Chrome │ │Firefox│ │ Edge  │ │Safari │
│Linux  │ │Windows│ │Windows│ │ macOS │
└───────┘ └──────┘ └───────┘ └───────┘

Starting Grid with Docker

# docker-compose.yml
version: '3'
services:
  selenium-hub:
    image: selenium/hub:latest
    ports:
      - "4444:4444"
    environment:
      - GRID_MAX_SESSION=10
      - GRID_BROWSER_TIMEOUT=300
      - GRID_TIMEOUT=300
 
  chrome:
    image: selenium/node-chrome:latest
    depends_on:
      - selenium-hub
    environment:
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
    shm_size: 2gb
 
  firefox:
    image: selenium/node-firefox:latest
    depends_on:
      - selenium-hub
    environment:
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
    shm_size: 2gb
 
  edge:
    image: selenium/node-edge:latest
    depends_on:
      - selenium-hub
    environment:
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
    shm_size: 2gb
# Start grid
docker-compose up -d
 
# View grid console
# Navigate to http://localhost:4444

Connecting to Grid

import { Builder, Browser } from 'selenium-webdriver'
 
async function runOnGrid() {
  const driver = await new Builder()
    .forBrowser(Browser.CHROME)
    .usingServer('http://localhost:4444/wd/hub')
    .build()
 
  try {
    await driver.get('https://example.com')
    // Test code...
  } finally {
    await driver.quit()
  }
}

Parallel Testing with Grid

import { describe, it } from 'vitest'
import { Builder, Browser, WebDriver } from 'selenium-webdriver'
 
const testData = [
  { browser: Browser.CHROME, url: 'https://example1.com' },
  { browser: Browser.FIREFOX, url: 'https://example2.com' },
  { browser: Browser.EDGE, url: 'https://example3.com' },
]
 
describe('Parallel Grid Tests', () => {
  testData.forEach(({ browser, url }) => {
    it(`should work on ${browser}`, async () => {
      const driver = await new Builder()
        .forBrowser(browser)
        .usingServer('http://localhost:4444/wd/hub')
        .build()
 
      try {
        await driver.get(url)
        const title = await driver.getTitle()
        expect(title).toBeTruthy()
      } finally {
        await driver.quit()
      }
    })
  })
})

Selenium Best Practices

1. Use Explicit Waits

// ❌ Bad - Thread.sleep (avoid in Selenium)
await driver.sleep(5000)
 
// ✅ Good - Explicit wait
await driver.wait(until.elementLocated(By.id('element')), 10000)

2. Create Reusable Methods

// ✅ Good - Reusable helper methods
async function waitAndClick(driver: WebDriver, locator: By): Promise<void> {
  const element = await driver.wait(until.elementLocated(locator), 10000)
  await driver.wait(until.elementIsVisible(element), 5000)
  await element.click()
}

3. Use Page Object Model

// ✅ Good - Separation of concerns
const loginPage = new LoginPage(driver)
await loginPage.login(email, password)
 
// ❌ Bad - Direct WebDriver calls in tests
await driver.findElement(By.id('email')).sendKeys(email)
await driver.findElement(By.id('password')).sendKeys(password)

4. Close Resources Properly

// ✅ Good - Always quit driver
afterEach(async () => {
  if (driver) {
    await driver.quit()
  }
})

5. Take Screenshots on Failure

import fs from 'fs'
 
async function takeScreenshot(driver: WebDriver, filename: string) {
  const screenshot = await driver.takeScreenshot()
  fs.writeFileSync(filename, screenshot, 'base64')
}
 
// In test
it('test case', async () => {
  try {
    // Test code
  } catch (error) {
    await takeScreenshot(driver, `failure-${Date.now()}.png`)
    throw error
  }
})

6. Avoid Absolute XPaths

// ❌ Bad
By.xpath('/html/body/div[1]/div[2]/form/input[3]')
 
// ✅ Good
By.xpath('//input[@id="email"]')
By.css('#email')

7. Use Data-Driven Testing

const testCases = [
  { email: 'user1@test.com', password: 'pass1', shouldSucceed: true },
  { email: 'user2@test.com', password: 'pass2', shouldSucceed: false },
]
 
testCases.forEach(({ email, password, shouldSucceed }) => {
  it(`should ${shouldSucceed ? 'login' : 'fail'} with ${email}`, async () => {
    await loginPage.login(email, password)
    
    if (shouldSucceed) {
      expect(await loginPage.isLoginSuccessful()).toBe(true)
    } else {
      expect(await loginPage.getErrorMessage()).toBeTruthy()
    }
  })
})

Common Pitfalls

1. StaleElementReferenceException

// ❌ Problem - Element reference becomes stale after page refresh
const element = await driver.findElement(By.id('button'))
await driver.navigate().refresh()
await element.click() // Throws StaleElementReferenceException
 
// ✅ Solution - Re-find element
await driver.navigate().refresh()
const element = await driver.findElement(By.id('button'))
await element.click()

2. NoSuchElementException

// ❌ Problem - Element not found
const element = await driver.findElement(By.id('nonexistent'))
 
// ✅ Solution - Use waits
await driver.wait(until.elementLocated(By.id('element')), 10000)
const element = await driver.findElement(By.id('element'))

3. ElementNotInteractableException

// ❌ Problem - Element not visible/enabled
const button = await driver.findElement(By.id('button'))
await button.click() // Fails if not visible
 
// ✅ Solution - Wait for element to be interactable
const button = await driver.findElement(By.id('button'))
await driver.wait(until.elementIsVisible(button), 5000)
await driver.wait(until.elementIsEnabled(button), 5000)
await button.click()

Playwright

Playwright is a modern, fast, and reliable browser automation framework with auto-waiting and powerful debugging tools.

Installation and Setup

# Install Playwright
npm install -D @playwright/test
 
# Install browsers
npx playwright install
 
# Install specific browser
npx playwright install chromium

Configuration

playwright.config.ts

import { defineConfig, devices } from '@playwright/test'
 
export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results.json' }],
    ['junit', { outputFile: 'junit.xml' }],
  ],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

Browser Automation

Basic Test

import { test, expect } from '@playwright/test'
 
test('basic navigation', async ({ page }) => {
  await page.goto('https://example.com')
  
  await expect(page).toHaveTitle(/Example Domain/)
  await expect(page).toHaveURL(/example.com/)
})
 
test('clicking and navigation', async ({ page }) => {
  await page.goto('https://example.com')
  
  await page.click('text=More information')
  
  await expect(page).toHaveURL(/iana.org/)
})

Locators

test('playwright locators', async ({ page }) => {
  await page.goto('https://example.com')
  
  // By role (preferred)
  await page.getByRole('button', { name: 'Submit' }).click()
  await page.getByRole('link', { name: /learn more/i }).click()
  
  // By label
  await page.getByLabel('Email').fill('user@example.com')
  
  // By placeholder
  await page.getByPlaceholder('Enter your name').fill('John')
  
  // By text
  await page.getByText('Welcome back').click()
  
  // By test ID
  await page.getByTestId('submit-button').click()
  
  // CSS selector
  await page.locator('#email').fill('test@example.com')
  await page.locator('.btn-primary').click()
  
  // XPath
  await page.locator('xpath=//button[@type="submit"]').click()
})

Auto-Waiting Mechanism

Playwright automatically waits for elements to be ready before performing actions.

test('auto-waiting', async ({ page }) => {
  await page.goto('https://example.com')
  
  // Playwright automatically waits for:
  // - Element to be attached to DOM
  // - Element to be visible
  // - Element to be stable (not animating)
  // - Element to receive events (not obscured)
  // - Element to be enabled
  
  await page.click('button') // Waits automatically
  await page.fill('input', 'text') // Waits automatically
  
  // Custom wait if needed
  await page.waitForSelector('#dynamic-element')
  await page.waitForLoadState('networkidle')
})

Multiple Browser Support

import { test, chromium, firefox, webkit } from '@playwright/test'
 
test('cross-browser testing', async () => {
  // Chrome
  const chrome = await chromium.launch()
  const chromePage = await chrome.newPage()
  await chromePage.goto('https://example.com')
  await chrome.close()
  
  // Firefox
  const ff = await firefox.launch()
  const ffPage = await ff.newPage()
  await ffPage.goto('https://example.com')
  await ff.close()
  
  // WebKit (Safari)
  const wk = await webkit.launch()
  const wkPage = await wk.newPage()
  await wkPage.goto('https://example.com')
  await wk.close()
})

Mobile Emulation

import { test, devices } from '@playwright/test'
 
test('mobile emulation', async ({ browser }) => {
  const iphone = devices['iPhone 13']
  const context = await browser.newContext({
    ...iphone,
  })
  
  const page = await context.newPage()
  await page.goto('https://example.com')
  
  // Interactions on mobile viewport
  await page.getByRole('button', { name: 'Menu' }).click()
  
  await context.close()
})
 
test('custom viewport', async ({ browser }) => {
  const context = await browser.newContext({
    viewport: { width: 375, height: 667 },
    userAgent: 'Custom User Agent',
    geolocation: { latitude: 37.7749, longitude: -122.4194 },
    permissions: ['geolocation'],
  })
  
  const page = await context.newPage()
  await page.goto('https://example.com')
  
  await context.close()
})

Network Interception

test('network mocking', async ({ page }) => {
  // Mock API response
  await page.route('**/api/users', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'John Doe' },
        { id: 2, name: 'Jane Smith' },
      ]),
    })
  })
  
  await page.goto('https://example.com/users')
  
  // Verify mocked data is displayed
  await expect(page.getByText('John Doe')).toBeVisible()
})
 
test('abort requests', async ({ page }) => {
  // Block images and stylesheets
  await page.route('**/*.{png,jpg,jpeg,css}', route => route.abort())
  
  await page.goto('https://example.com')
})
 
test('modify requests', async ({ page }) => {
  await page.route('**/api/**', route => {
    const headers = {
      ...route.request().headers(),
      'Authorization': 'Bearer mock-token',
    }
    route.continue({ headers })
  })
  
  await page.goto('https://example.com')
})

Screenshots and Videos

test('screenshots', async ({ page }) => {
  await page.goto('https://example.com')
  
  // Full page screenshot
  await page.screenshot({ path: 'screenshot.png', fullPage: true })
  
  // Element screenshot
  const element = page.getByRole('heading')
  await element.screenshot({ path: 'heading.png' })
  
  // Screenshot to buffer
  const buffer = await page.screenshot()
})
 
test('videos', async ({ browser }) => {
  const context = await browser.newContext({
    recordVideo: {
      dir: 'videos/',
      size: { width: 1280, height: 720 },
    },
  })
  
  const page = await context.newPage()
  await page.goto('https://example.com')
  
  // Test actions...
  
  await context.close()
  // Video is saved after context closes
})

Parallel Testing

import { test } from '@playwright/test'
 
test.describe.configure({ mode: 'parallel' })
 
test.describe('Parallel tests', () => {
  test('test 1', async ({ page }) => {
    // Runs in parallel
  })
  
  test('test 2', async ({ page }) => {
    // Runs in parallel
  })
  
  test('test 3', async ({ page }) => {
    // Runs in parallel
  })
})
 
// Serial tests
test.describe.configure({ mode: 'serial' })
 
test.describe('Serial tests', () => {
  test('test 1', async ({ page }) => {
    // Runs first
  })
  
  test('test 2', async ({ page }) => {
    // Runs second
  })
})

Debugging Tools

Playwright Inspector

# Run with inspector
PWDEBUG=1 npx playwright test
 
# Headed mode with slow mo
npx playwright test --headed --slow-mo=1000

Trace Viewer

test('with tracing', async ({ page }) => {
  await page.context().tracing.start({ screenshots: true, snapshots: true })
  
  await page.goto('https://example.com')
  // Test actions...
  
  await page.context().tracing.stop({ path: 'trace.zip' })
})
# View trace
npx playwright show-trace trace.zip

Cypress

Cypress is a developer-friendly E2E testing framework with excellent DX and time-travel debugging.

Setup and Configuration

# Install Cypress
npm install -D cypress
 
# Open Cypress
npx cypress open

Configuration

cypress.config.ts

import { defineConfig } from 'cypress'
 
export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
    video: true,
    screenshotOnRunFailure: true,
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
  },
  component: {
    devServer: {
      framework: 'react',
      bundler: 'vite',
    },
  },
})

Writing E2E Tests

describe('Login Flow', () => {
  beforeEach(() => {
    cy.visit('/login')
  })
 
  it('should login successfully', () => {
    cy.get('[data-testid="email-input"]').type('user@example.com')
    cy.get('[data-testid="password-input"]').type('password123')
    cy.get('button[type="submit"]').click()
 
    cy.url().should('include', '/dashboard')
    cy.contains('Welcome').should('be.visible')
  })
 
  it('should show error for invalid credentials', () => {
    cy.get('[data-testid="email-input"]').type('invalid@example.com')
    cy.get('[data-testid="password-input"]').type('wrongpassword')
    cy.get('button[type="submit"]').click()
 
    cy.contains('Invalid credentials').should('be.visible')
    cy.url().should('include', '/login')
  })
})

Component Testing

import { mount } from 'cypress/react18'
import Counter from './Counter'
 
describe('Counter Component', () => {
  it('renders and increments', () => {
    mount(<Counter initialValue={0} />)
 
    cy.contains('Count: 0').should('be.visible')
    cy.get('button').contains('Increment').click()
    cy.contains('Count: 1').should('be.visible')
  })
})

API Testing

describe('API Tests', () => {
  it('should fetch users', () => {
    cy.request('GET', '/api/users')
      .its('status')
      .should('equal', 200)
 
    cy.request('/api/users').then(response => {
      expect(response.body).to.have.length.greaterThan(0)
      expect(response.body[0]).to.have.property('name')
    })
  })
 
  it('should create user', () => {
    cy.request({
      method: 'POST',
      url: '/api/users',
      body: {
        name: 'John Doe',
        email: 'john@example.com',
      },
    }).then(response => {
      expect(response.status).to.equal(201)
      expect(response.body).to.have.property('id')
    })
  })
})

Custom Commands

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>
      logout(): Chainable<void>
    }
  }
}
 
Cypress.Commands.add('login', (email: string, password: string) => {
  cy.visit('/login')
  cy.get('[data-testid="email-input"]').type(email)
  cy.get('[data-testid="password-input"]').type(password)
  cy.get('button[type="submit"]').click()
  cy.url().should('include', '/dashboard')
})
 
Cypress.Commands.add('logout', () => {
  cy.get('[data-testid="user-menu"]').click()
  cy.get('[data-testid="logout-button"]').click()
  cy.url().should('include', '/login')
})
 
// Usage in tests
it('should work with custom commands', () => {
  cy.login('user@example.com', 'password123')
  cy.logout()
})

Best Practices

1. Use data-testid for Selectors

// ✅ Good
cy.get('[data-testid="submit-button"]')
 
// ❌ Bad (fragile)
cy.get('.btn.btn-primary.submit')

2. Avoid Hard-Coded Waits

// ❌ Bad
cy.wait(5000)
 
// ✅ Good
cy.get('[data-testid="loading"]').should('not.exist')
cy.get('[data-testid="data"]').should('be.visible')

3. Use Aliases

cy.get('[data-testid="user-list"]').as('userList')
cy.get('@userList').find('li').should('have.length', 5)

For more E2E testing strategies and best practices, see Best Practices and CI/CD Integration.