DailyDevDiet

logo - dailydevdiet

Learn. Build. Innovate. Elevate your coding skills with dailydevdiet!

Chapter 23: End-to-End Testing with Selenium

End-to-End Testing with Selenium

Introduction

End-to-End Testing with Selenium is a methodology that tests the entire application flow from start to finish, simulating real user interactions. Unlike unit tests that test individual components or integration tests that test component interactions, E2E tests validate the complete user journey across your React application.

Selenium is one of the most popular tools for browser automation and E2E testing. It allows you to write tests that interact with your application just like a real user would – clicking buttons, filling forms, navigating between pages, and verifying results.

Why E2E Testing Matters

E2E testing provides several critical benefits:

  • User Perspective: Tests mirror actual user behavior and workflows
  • Full Stack Validation: Tests the entire application stack, including backend APIs
  • Cross-Browser Compatibility: Ensures your app works across different browsers
  • Regression Prevention: Catches issues that might break existing functionality
  • Confidence in Releases: Provides assurance that critical user flows work correctly

Setting Up Selenium for React Applications

Prerequisites

Before starting with Selenium, ensure you have:

  • Node.js installed
  • A React application ready for testing
  • Basic understanding of JavaScript and React

Installation

First, install the necessary packages:

npm install --save-dev selenium-webdriver
npm install --save-dev chromedriver
npm install --save-dev @types/selenium-webdriver  # For TypeScript projects

For cross-browser testing, you might also want:

npm install --save-dev geckodriver  # Firefox
npm install --save-dev edgedriver   # Microsoft Edge

Basic Setup

Create a basic test setup file:

// tests/setup.js
const { Builder, By, until } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');

class TestSetup {
  constructor() {
    this.driver = null;
  }

  async initialize() {
    const options = new chrome.Options();
   
    // Add options for headless testing in CI
    if (process.env.CI) {
      options.addArguments('--headless');
      options.addArguments('--no-sandbox');
      options.addArguments('--disable-dev-shm-usage');
    }

    this.driver = await new Builder()
      .forBrowser('chrome')
      .setChromeOptions(options)
      .build();

    // Set implicit wait
    await this.driver.manage().setTimeouts({ implicit: 10000 });
   
    return this.driver;
  }

  async cleanup() {
    if (this.driver) {
      await this.driver.quit();
    }
  }
}

module.exports = TestSetup;

Writing Your First E2E Test

Let’s create a simple E2E test for a React todo application:

// tests/todo-app.test.js
const { By, until } = require('selenium-webdriver');
const TestSetup = require('./setup');

describe('Todo Application E2E Tests', () => {
  let testSetup;
  let driver;

  beforeAll(async () => {
    testSetup = new TestSetup();
    driver = await testSetup.initialize();
  });

  afterAll(async () => {
    await testSetup.cleanup();
  });

  beforeEach(async () => {
    // Navigate to the application
    await driver.get('http://localhost:3000');
  });

  test('should add a new todo item', async () => {
    // Find the input field
    const todoInput = await driver.findElement(By.css('[data-testid="todo-input"]'));
   
    // Type a new todo
    await todoInput.sendKeys('Learn Selenium with React');
   
    // Find and click the add button
    const addButton = await driver.findElement(By.css('[data-testid="add-todo-btn"]'));
    await addButton.click();
   
    // Wait for the todo to appear and verify
    const todoItem = await driver.wait(
      until.elementLocated(By.css('[data-testid="todo-item"]')),
      5000
    );
   
    const todoText = await todoItem.getText();
    expect(todoText).toContain('Learn Selenium with React');
  });

  test('should mark todo as completed', async () => {
    // First add a todo
    const todoInput = await driver.findElement(By.css('[data-testid="todo-input"]'));
    await todoInput.sendKeys('Complete this task');
   
    const addButton = await driver.findElement(By.css('[data-testid="add-todo-btn"]'));
    await addButton.click();
   
    // Wait for todo to appear
    await driver.wait(
      until.elementLocated(By.css('[data-testid="todo-item"]')),
      5000
    );
   
    // Click the checkbox to mark as completed
    const checkbox = await driver.findElement(By.css('[data-testid="todo-checkbox"]'));
    await checkbox.click();
   
    // Verify the todo is marked as completed
    const todoItem = await driver.findElement(By.css('[data-testid="todo-item"]'));
    const isCompleted = await todoItem.getAttribute('class');
    expect(isCompleted).toContain('completed');
  });

  test('should delete a todo item', async () => {
    // Add a todo first
    const todoInput = await driver.findElement(By.css('[data-testid="todo-input"]'));
    await todoInput.sendKeys('Delete this item');
   
    const addButton = await driver.findElement(By.css('[data-testid="add-todo-btn"]'));
    await addButton.click();
   
    // Wait for todo to appear
    await driver.wait(
      until.elementLocated(By.css('[data-testid="todo-item"]')),
      5000
    );
   
    // Click delete button
    const deleteButton = await driver.findElement(By.css('[data-testid="delete-todo-btn"]'));
    await deleteButton.click();
   
    // Verify todo is removed
    const todoItems = await driver.findElements(By.css('[data-testid="todo-item"]'));
    expect(todoItems.length).toBe(0);
  });
});

Advanced Selenium Techniques

Page Object Model

The Page Object Model (POM) is a design pattern that creates an abstraction layer between your tests and the UI. It makes tests more maintainable and readable:

// pages/TodoPage.js
const { By, until } = require('selenium-webdriver');

class TodoPage {
  constructor(driver) {
    this.driver = driver;
    this.url = 'http://localhost:3000';
   
    // Element selectors
    this.selectors = {
      todoInput: '[data-testid="todo-input"]',
      addButton: '[data-testid="add-todo-btn"]',
      todoItem: '[data-testid="todo-item"]',
      todoCheckbox: '[data-testid="todo-checkbox"]',
      deleteButton: '[data-testid="delete-todo-btn"]',
      todoList: '[data-testid="todo-list"]'
    };
  }

  async navigate() {
    await this.driver.get(this.url);
  }

  async addTodo(todoText) {
    const input = await this.driver.findElement(By.css(this.selectors.todoInput));
    await input.clear();
    await input.sendKeys(todoText);
   
    const addBtn = await this.driver.findElement(By.css(this.selectors.addButton));
    await addBtn.click();
   
    // Wait for the todo to be added
    await this.driver.wait(
      until.elementLocated(By.css(this.selectors.todoItem)),
      5000
    );
  }

  async getTodoItems() {
    return await this.driver.findElements(By.css(this.selectors.todoItem));
  }

  async getTodoText(index = 0) {
    const todos = await this.getTodoItems();
    if (todos[index]) {
      return await todos[index].getText();
    }
    return null;
  }

  async completeTodo(index = 0) {
    const checkboxes = await this.driver.findElements(By.css(this.selectors.todoCheckbox));
    if (checkboxes[index]) {
      await checkboxes[index].click();
    }
  }

  async deleteTodo(index = 0) {
    const deleteButtons = await this.driver.findElements(By.css(this.selectors.deleteButton));
    if (deleteButtons[index]) {
      await deleteButtons[index].click();
    }
  }

  async waitForTodoCount(expectedCount) {
    await this.driver.wait(async () => {
      const todos = await this.getTodoItems();
      return todos.length === expectedCount;
    }, 5000);
  }
}

module.exports = TodoPage;

Using the Page Object Model in tests:
// tests/todo-pom.test.js
const TestSetup = require('./setup');
const TodoPage = require('../pages/TodoPage');

describe('Todo Application with POM', () => {
  let testSetup;
  let driver;
  let todoPage;

  beforeAll(async () => {
    testSetup = new TestSetup();
    driver = await testSetup.initialize();
    todoPage = new TodoPage(driver);
  });

  afterAll(async () => {
    await testSetup.cleanup();
  });

  beforeEach(async () => {
    await todoPage.navigate();
  });

  test('should manage todo items using POM', async () => {
    // Add a todo
    await todoPage.addTodo('Learn Page Object Model');
   
    // Verify todo was added
    const todoText = await todoPage.getTodoText(0);
    expect(todoText).toContain('Learn Page Object Model');
   
    // Complete the todo
    await todoPage.completeTodo(0);
   
    // Add another todo
    await todoPage.addTodo('Master E2E Testing');
   
    // Delete the first todo
    await todoPage.deleteTodo(0);
   
    // Verify only one todo remains
    await todoPage.waitForTodoCount(1);
    const remainingTodo = await todoPage.getTodoText(0);
    expect(remainingTodo).toContain('Master E2E Testing');
  });
});

Handling Asynchronous Operations

React applications often involve asynchronous operations. Here’s how to handle them effectively:

// tests/async-operations.test.js
const { By, until } = require('selenium-webdriver');

describe('Handling Async Operations', () => {
  // ... setup code ...

  test('should handle API calls and loading states', async () => {
    // Navigate to a page that makes API calls
    await driver.get('http://localhost:3000/users');
   
    // Wait for loading indicator to appear
    await driver.wait(
      until.elementLocated(By.css('[data-testid="loading-spinner"]')),
      2000
    );
   
    // Wait for loading to complete and data to load
    await driver.wait(
      until.elementLocated(By.css('[data-testid="user-list"]')),
      10000
    );
   
    // Verify data is loaded
    const userItems = await driver.findElements(By.css('[data-testid="user-item"]'));
    expect(userItems.length).toBeGreaterThan(0);
  });

  test('should handle form submission with validation', async () => {
    await driver.get('http://localhost:3000/contact');
   
    // Fill form with invalid data first
    const nameInput = await driver.findElement(By.css('[name="name"]'));
    await nameInput.sendKeys('a'); // Too short
   
    const submitBtn = await driver.findElement(By.css('[type="submit"]'));
    await submitBtn.click();
   
    // Wait for validation error
    const errorMessage = await driver.wait(
      until.elementLocated(By.css('[data-testid="name-error"]')),
      3000
    );
   
    expect(await errorMessage.getText()).toContain('Name must be at least');
   
    // Fix the input
    await nameInput.clear();
    await nameInput.sendKeys('John Doe');
   
    const emailInput = await driver.findElement(By.css('[name="email"]'));
    await emailInput.sendKeys('john@example.com');
   
    const messageInput = await driver.findElement(By.css('[name="message"]'));
    await messageInput.sendKeys('This is a test message');
   
    await submitBtn.click();
   
    // Wait for success message
    const successMessage = await driver.wait(
      until.elementLocated(By.css('[data-testid="success-message"]')),
      5000
    );
   
    expect(await successMessage.getText()).toContain('Message sent successfully');
  });
});

Cross-Browser Testing

Set up tests to run across multiple browsers:

// tests/cross-browser.test.js
const { Builder } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
const firefox = require('selenium-webdriver/firefox');

const browsers = [
  {
    name: 'chrome',
    builder: () => {
      const options = new chrome.Options();
      if (process.env.CI) {
        options.addArguments('--headless');
      }
      return new Builder()
        .forBrowser('chrome')
        .setChromeOptions(options);
    }
  },
  {
    name: 'firefox',
    builder: () => {
      const options = new firefox.Options();
      if (process.env.CI) {
        options.addArguments('--headless');
      }
      return new Builder()
        .forBrowser('firefox')
        .setFirefoxOptions(options);
    }
  }
];

browsers.forEach(({ name, builder }) => {
  describe(`Cross-browser testing - ${name}`, () => {
    let driver;

    beforeAll(async () => {
      driver = await builder().build();
    });

    afterAll(async () => {
      await driver.quit();
    });

    test(`should work correctly in ${name}`, async () => {
      await driver.get('http://localhost:3000');
     
      // Your test logic here
      const title = await driver.getTitle();
      expect(title).toBe('React App');
    });
  });
});

Testing React Router Navigation

Testing single-page applications with routing requires special attention:

// tests/routing.test.js
describe('React Router Navigation', () => {
  // ... setup code ...

  test('should navigate between routes', async () => {
    await driver.get('http://localhost:3000');
   
    // Click navigation link
    const aboutLink = await driver.findElement(By.css('[data-testid="nav-about"]'));
    await aboutLink.click();
   
    // Wait for route change
    await driver.wait(
      until.urlContains('/about'),
      3000
    );
   
    // Verify content changed
    const aboutContent = await driver.wait(
      until.elementLocated(By.css('[data-testid="about-content"]')),
      3000
    );
   
    expect(await aboutContent.getText()).toContain('About Us');
  });

  test('should handle browser back/forward navigation', async () => {
    await driver.get('http://localhost:3000');
   
    // Navigate to different pages
    const aboutLink = await driver.findElement(By.css('[data-testid="nav-about"]'));
    await aboutLink.click();
   
    await driver.wait(until.urlContains('/about'), 3000);
   
    const contactLink = await driver.findElement(By.css('[data-testid="nav-contact"]'));
    await contactLink.click();
   
    await driver.wait(until.urlContains('/contact'), 3000);
   
    // Use browser back button
    await driver.navigate().back();
    await driver.wait(until.urlContains('/about'), 3000);
   
    // Use browser forward button
    await driver.navigate().forward();
    await driver.wait(until.urlContains('/contact'), 3000);
   
    // Verify correct content is displayed
    const contactContent = await driver.findElement(By.css('[data-testid="contact-content"]'));
    expect(await contactContent.getText()).toContain('Contact Us');
  });
});

Best Practices for E2E Testing

1. Use Test IDs

Always use data-testid attributes for reliable element selection:

// React component
function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <div data-testid="todo-item" className={todo.completed ? 'completed' : ''}>
      <input
        type="checkbox"
        data-testid="todo-checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span data-testid="todo-text">{todo.text}</span>
      <button data-testid="delete-todo-btn" onClick={() => onDelete(todo.id)}>
        Delete
      </button>
    </div>
  );
}

2. Create Utility Functions

Build reusable utility functions for common operations:

// utils/test-helpers.js
const { By, until } = require('selenium-webdriver');

class TestHelpers {
  constructor(driver) {
    this.driver = driver;
  }

  async waitForElement(selector, timeout = 5000) {
    return await this.driver.wait(
      until.elementLocated(By.css(selector)),
      timeout
    );
  }

  async waitForElementToBeClickable(selector, timeout = 5000) {
    const element = await this.waitForElement(selector, timeout);
    return await this.driver.wait(until.elementIsEnabled(element), timeout);
  }

  async fillForm(formData) {
    for (const [selector, value] of Object.entries(formData)) {
      const element = await this.waitForElement(selector);
      await element.clear();
      await element.sendKeys(value);
    }
  }

  async takeScreenshot(filename) {
    const screenshot = await this.driver.takeScreenshot();
    require('fs').writeFileSync(filename, screenshot, 'base64');
  }

  async scrollToElement(selector) {
    const element = await this.waitForElement(selector);
    await this.driver.executeScript(
      'arguments[0].scrollIntoView(true);',
      element
    );
  }
}

module.exports = TestHelpers;

3. Handle Test Data Management

Implement proper test data setup and cleanup:

// utils/test-data.js
class TestDataManager {
  constructor() {
    this.createdRecords = [];
  }

  async createTestUser(userData) {
    // Create test user via API
    const response = await fetch('http://localhost:3001/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(userData)
    });
   
    const user = await response.json();
    this.createdRecords.push({ type: 'user', id: user.id });
    return user;
  }

  async cleanup() {
    // Clean up all created test data
    for (const record of this.createdRecords) {
      try {
        await fetch(`http://localhost:3001/api/${record.type}s/${record.id}`, {
          method: 'DELETE'
        });
      } catch (error) {
        console.warn(`Failed to cleanup ${record.type} ${record.id}:`, error);
      }
    }
    this.createdRecords = [];
  }
}

module.exports = TestDataManager;

CI/CD Integration

Configure your E2E tests for continuous integration:

// scripts/run-e2e-tests.js
const { spawn } = require('child_process');

async function runE2ETests() {
  // Start the React application
  const appProcess = spawn('npm', ['start'], {
    stdio: 'inherit',
    env: { ...process.env, PORT: '3000' }
  });

  // Wait for app to start
  await new Promise(resolve => setTimeout(resolve, 10000));

  try {
    // Run the tests
    const testProcess = spawn('npm', ['run', 'test:e2e'], {
      stdio: 'inherit'
    });

    await new Promise((resolve, reject) => {
      testProcess.on('close', (code) => {
        if (code === 0) {
          resolve();
        } else {
          reject(new Error(`Tests failed with code ${code}`));
        }
      });
    });
  } finally {
    // Clean up
    appProcess.kill();
  }
}

runE2ETests().catch(console.error);

# .github/workflows/e2e-tests.yml
name: E2E Tests

on: [push, pull_request]

jobs:
  e2e-tests:
    runs-on: ubuntu-latest
   
    steps:
    - uses: actions/checkout@v2
   
    - name: Setup Node.js
      uses: actions/setup-node@v2
      with:
        node-version: '18'
   
    - name: Install dependencies
      run: npm ci
   
    - name: Install Chrome
      run: |
        sudo apt-get update
        sudo apt-get install -y google-chrome-stable
   
    - name: Run E2E tests
      run: npm run test:e2e
      env:
        CI: true

Troubleshooting Common Issues

1. Element Not Found Errors

// Instead of immediate element finding
const element = await driver.findElement(By.css('[data-testid="my-element"]'));

// Use explicit waits
const element = await driver.wait(
  until.elementLocated(By.css('[data-testid="my-element"]')),
  10000
);

2. Stale Element References
// Re-find elements after page changes
async function clickElementSafely(selector) {
  const element = await driver.findElement(By.css(selector));
  try {
    await element.click();
  } catch (error) {
    if (error.name === 'StaleElementReferenceError') {
      // Re-find and try again
      const freshElement = await driver.findElement(By.css(selector));
      await freshElement.click();
    } else {
      throw error;
    }
  }
}

3. Timing Issues

// Use explicit waits instead of sleep
await driver.sleep(5000); // Bad

// Good - wait for specific condition
await driver.wait(
  until.elementTextContains(
    driver.findElement(By.css('[data-testid="status"]')),
    'Loaded'
  ),
  5000
);

Summary

End-to-End testing with Selenium provides comprehensive coverage of your React application from the user’s perspective. Key takeaways from this chapter:

  • E2E tests validate complete user workflows and catch integration issues
  • Selenium WebDriver provides powerful browser automation capabilities
  • The Page Object Model pattern improves test maintainability
  • Proper waiting strategies and error handling are crucial for reliable tests
  • Test data management and cleanup prevent test pollution
  • CI/CD integration ensures tests run consistently across environments

E2E testing should complement, not replace, unit and integration tests. Use E2E tests for critical user flows and high-value scenarios while relying on faster unit tests for detailed component behavior validation.

In the next chapter, we’ll explore advanced testing techniques including visual regression testing, performance testing, and test reporting strategies.

Related Articles

Scroll to Top