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.