Overview
Selenium WebDriver is a W3C-standard browser automation protocol with the broadest language support: Java, Python, JavaScript, C#, Ruby, Kotlin, PHP. Since 4.6 the bindings ship Selenium Manager, so a test no longer downloads or configures a browser driver itself (selenium-manager (opens in new window)).
When to use
For new projects in 2026+: pick Playwright or Cypress unless constraints dictate Selenium.
How to use
Step 1 - Install (Java + JUnit example)
<!-- pom.xml: versions current on Maven Central, 3 Nov 2026 -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.52.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>No driver dependency is needed. Per selenium-manager (opens in new window), Selenium Manager ships "out of the box with each Selenium release as of version 4.6", the bindings use it by default "so you do not need to download it or add anything to your code", and if Chrome is not installed "the current stable Chrome for Testing (CfT) release will be discovered, downloaded, and cached" under ~/.cache/selenium. Cache that folder in CI to skip the download on every run.
Upgrading an older suite: delete the webdrivermanager dependency, every WebDriverManager.chromedriver().setup() call and every webdriver.chrome.driver system property. They now fight Selenium Manager instead of helping.
Step 2 - Author a test (Java)
import org.junit.jupiter.api.*;
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.*;
import java.time.Duration;
class CheckoutTest {
private WebDriver driver;
private WebDriverWait wait;
@BeforeEach
void setup() {
driver = new ChromeDriver(); // Selenium Manager finds the driver
wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
@AfterEach
void teardown() {
driver.quit();
}
@Test
void completeCheckout() {
driver.get("http://localhost:3000/login");
driver.findElement(By.cssSelector("[data-testid=email]"))
.sendKeys("user@example.com");
driver.findElement(By.cssSelector("[data-testid=password]"))
.sendKeys(System.getenv("TEST_USER_PASSWORD"));
driver.findElement(By.cssSelector("button[type=submit]")).click();
wait.until(ExpectedConditions.visibilityOfElementLocated(
By.xpath("//h1[contains(text(), 'Welcome')]")));
driver.get("http://localhost:3000/products/BOOK-001");
driver.findElement(By.cssSelector("[data-testid=add-to-cart]")).click();
// Wait for the count to update instead of reading it immediately
wait.until(ExpectedConditions.textToBe(
By.cssSelector("[data-testid=cart-count]"), "1"));
}
}Step 3 - Locator strategies
| Strategy | When |
|---|---|
By.id | Element has stable id |
By.name | Form field with name attribute |
By.cssSelector | Most cases (preferred over XPath) |
By.xpath | Complex relationship queries (avoid otherwise) |
By.linkText | Anchor by text |
By.partialLinkText | Anchor by partial text |
By.tagName | Generic (e.g., all input elements) |
By.className | Single CSS class (brittle) |
Prefer data-testid selectors; avoid XPath / classes.
For accessibility-first equivalents (Selenium doesn't ship getByRole natively), evaluate selenium-axe-core for a11y testing and consider Selenium 4's relative locators:
// Selenium 4 relative locators
import static org.openqa.selenium.support.locators.RelativeLocator.*;
driver.findElement(with(By.tagName("button")).near(By.id("password-field")));Step 4 - Explicit waits
// WebDriverWait with ExpectedConditions
WebElement element = wait.until(
ExpectedConditions.elementToBeClickable(By.cssSelector("button.submit"))
);
element.click();Never use Thread.sleep() - WebDriverWait polls until the condition is met. Sleep is the most common Selenium flake source.
A lambda works when no ExpectedConditions helper fits, and FluentWait tunes polling and ignored exceptions. Both forms are from selenium-waits (opens in new window):
wait.until(d -> d.findElement(By.id("status")).getText().equals("Paid"));
Wait<WebDriver> fluent = new FluentWait<>(driver)
.withTimeout(Duration.ofSeconds(10))
.pollingEvery(Duration.ofMillis(300))
.ignoring(ElementNotInteractableException.class);
fluent.until(d -> { d.findElement(By.id("pay")).click(); return true; });Per selenium-waits (opens in new window): "Do not mix implicit and explicit waits." An implicit wait of 10 seconds plus an explicit wait of 15 seconds can time out after 20. Pick explicit waits and leave implicitlyWait unset.
Step 5 - Page objects, failure screenshots and JavaScript errors
Page objects. Per selenium-pom (opens in new window), "A page object is an object-oriented class that serves as an interface to a page of your AUT", and "Page objects themselves should never make verifications or assertions." The test asserts; the page object only acts and reads:
public class LoginPage {
private final WebDriver driver;
private final By email = By.cssSelector("[data-testid=email]");
private final By password = By.cssSelector("[data-testid=password]");
private final By submit = By.cssSelector("button[type=submit]");
public LoginPage(WebDriver driver) { this.driver = driver; }
public DashboardPage loginAs(String user, String pass) {
driver.findElement(email).sendKeys(user);
driver.findElement(password).sendKeys(pass);
driver.findElement(submit).click();
return new DashboardPage(driver);
}
}Screenshot on failure. The capture call from selenium-windows (opens in new window) is ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE). Call it from a JUnit TestWatcher (testFailed) or from the @AfterEach before quit(), write it under target/screenshots/, and upload that folder as a CI artifact.
JavaScript errors over BiDi. Per selenium-bidi (opens in new window), WebDriver BiDi is "the W3C standard bidirectional protocol for browser automation" and the "cross-browser replacement for the Chrome DevTools Protocol". Enable it with the webSocketUrl capability, then collect page errors with the handler from selenium-bidi-logging (opens in new window):
ChromeOptions options = new ChromeOptions();
options.setCapability("webSocketUrl", true);
driver = new ChromeDriver(options);
List<JavascriptLogEntry> jsErrors = new CopyOnWriteArrayList<>();
((RemoteWebDriver) driver).script().addJavaScriptErrorHandler(jsErrors::add);
// ... drive the flow ...
Assertions.assertTrue(jsErrors.isEmpty(),
() -> "Page threw: " + jsErrors.get(0).getText());addConsoleMessageHandler works the same way for console.* output.
Step 6 - Selenium Grid for distributed execution
# docker-compose.grid.yml
services:
selenium-hub:
image: selenium/hub:4.51.0-20261010
ports: ["4444:4444"]
chrome-node:
image: selenium/node-chrome:4.51.0-20261010
depends_on: [selenium-hub]
environment:
SE_EVENT_BUS_HOST: selenium-hub
SE_EVENT_BUS_PUBLISH_PORT: 4442
SE_EVENT_BUS_SUBSCRIBE_PORT: 4443
firefox-node:
image: selenium/node-firefox:4.51.0-20261010
# ... same envConnect from tests:
WebDriver driver = new RemoteWebDriver(
new URL("http://localhost:4444"),
new ChromeOptions()
);Per the docker-selenium README (opens in new window): "Always use a Docker image with a full tag to pin a specific browser and Grid version." The 4.51.0-20261010 tag above was the newest Grid image on 3 Nov 2026; Grid images trail the client release, and a 4.52 client talks to a 4.51 Grid over the same W3C protocol.
Grid distributes tests across nodes - handles parallelism. For managed grids, see commercial: BrowserStack, Sauce Labs, LambdaTest.
Verify: curl http://localhost:4444/wd/hub/status reports "ready": true before pointing RemoteWebDriver at the grid; if not, the hub or nodes haven't registered - check docker compose -f docker-compose.grid.yml ps and the node container logs.
Step 7 - Other language bindings
The Java spine above ports 1:1 to Selenium's other official bindings. Python (pytest) and C# (xUnit) worked examples: references/language-bindings.md.
Step 8 - CI integration
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with: { distribution: temurin, java-version: '21', cache: maven }
- uses: actions/cache@v6
with:
path: ~/.cache/selenium # Selenium Manager drivers + browsers
key: selenium-${{ runner.os }}
- run: mvn test
env:
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- uses: actions/upload-artifact@v7
if: always()
with:
name: surefire-reports
path: |
target/surefire-reports/
target/screenshots/JUnit XML lands at target/surefire-reports/; feeds junit-xml-analysis (in the qa-test-reporting plugin).
Verify: confirm target/surefire-reports/*.xml exists after mvn test before handing off to junit-xml-analysis; if the directory is empty the run emitted no reports - check that tests compiled and actually ran rather than being skipped.
Worked example
A legacy Java suite has one CheckoutTest method that drives login, add-to-cart, and checkout in a single flow, synchronizing with Thread.sleep(2000) between steps and never calling driver.quit(). Browser processes leak on CI and the test flakes when the runner is slow.
Result: browsers no longer leak (every test quits its driver), the fixed sleeps are gone, and the flow runs across two browsers on the grid.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Single test class with one giant flow | Failure mid-test obscures cause. | Per-flow tests with @BeforeEach setup. |
Skipping driver.quit() | Browser instance leaks; CI runner OOM. | Always quit() in @AfterEach (Step 2). |
| Hardcoded ChromeDriver path or a pinned WebDriverManager | Drift; brittle to Chrome updates; duplicates what Selenium Manager does. | Delete both; Selenium Manager (Step 1). |
implicitlyWait set alongside WebDriverWait | Timeouts stack unpredictably (10s + 15s can wait 20s). | Explicit waits only (Step 4). |
| Assertions inside page objects | Page objects stop being reusable; failures point at the wrong layer. | Assert in the test (Step 5). |
Floating selenium/node-chrome:latest images | Browser and Grid change under the suite between runs. | Full date tag (Step 6). |
Limitations
References
Selenium language bindings (Python, C#)
View source (opens in a new tab) (opens in new window)The Java + JUnit binding is the worked spine in selenium-testing. Selenium ships official bindings for Python, JavaScript, C#, Ruby, Kotlin, and PHP; the WebDriver calls map 1:1 across them. Two of the most common non-Java bindings are shown below.
Python (pytest)
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def test_checkout():
driver = webdriver.Chrome()
driver.get('http://localhost:3000/login')
driver.find_element(By.CSS_SELECTOR, '[data-testid=email]').send_keys('user@example.com')
driver.find_element(By.CSS_SELECTOR, '[data-testid=password]').send_keys('pwd')
driver.find_element(By.CSS_SELECTOR, 'button[type=submit]').click()
WebDriverWait(driver, 10).until(
EC.visibility_of_element_located((By.XPATH, "//h1[contains(., 'Welcome')]"))
)
driver.quit()C# (xUnit)
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.UI;
using Xunit;
public class CheckoutTest : IDisposable
{
private readonly IWebDriver driver = new ChromeDriver();
private readonly WebDriverWait wait;
public CheckoutTest()
{
wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
}
[Fact]
public void CompleteCheckout()
{
driver.Navigate().GoToUrl("http://localhost:3000/login");
driver.FindElement(By.CssSelector("[data-testid=email]")).SendKeys("user@example.com");
// ...
}
public void Dispose() => driver.Quit();
}- The nightly says Chrome and Firefox both passed; our Firefox users disagree
- The e2e job was green on the release that broke paying for nine hours
- Green on every laptop, red one run in three on the shared runner
- Two teams want onto the one grid before the freeze on the 26th
- The payment specs break every time the card vendor ships