Blog
Selenium WebDriver Tutorial for QA Engineers
Learn Selenium WebDriver as a QA engineer: architecture, sessions, locators, navigation, assertions, waits preview, suite structure, headless runs, and CI patterns that stay maintainable.
- selenium
- webdriver
- test-automation
- qa
Selenium WebDriver remains a common choice for browser automation in enterprises that standardize on Java or Python, share grid infrastructure, or already own years of page-object investment. This Selenium WebDriver tutorial is written for QA engineers and SDETs who need a practical mental model—not a copy-paste of every API method.
If you are still deciding where UI automation belongs in your strategy, read the automation testing guide first. If your team is Java-first and wants a deeper language-specific path, pair this article with the Java Selenium tutorial.
What Selenium WebDriver actually is
Selenium is a family of components. For day-to-day test authoring you mostly care about:
- WebDriver language bindings (Java, Python, C#, JavaScript, Ruby) that send commands from your test code.
- The W3C WebDriver protocol that defines those commands (navigate, find element, click, execute script, and so on).
- Browser drivers (chromedriver, geckodriver, and equivalents) that translate protocol commands into browser automation.
- Optional grid / cloud nodes that run browsers remotely.
Architecturally, your test process does not “live inside” the browser the way some newer runners do. It speaks to a driver over HTTP (or a local equivalent), and the driver controls the browser. That separation is why Selenium fits heterogeneous stacks and remote grids well—and why you must be deliberate about synchronization. Unlike tools that auto-wait aggressively by default, classic WebDriver will happily click a stale or not-yet-rendered element unless you add explicit waiting. We preview waits here and point to a dedicated Selenium explicit waits guide for depth.
Setup: Java and Python paths
Java (Maven)
Typical dependencies in pom.xml:
<dependencies>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.27.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
</dependencies>With Selenium Manager (Selenium 4.6+), you often no longer download chromedriver by hand—WebDriver resolves a matching driver for your installed Chrome. Still pin browser versions in CI so “works on my machine” does not become the release strategy.
Minimal JUnit 5 test:
import org.junit.jupiter.api.*;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LoginIT {
WebDriver driver;
@BeforeEach
void setUp() {
ChromeOptions options = new ChromeOptions();
if (System.getenv("CI") != null) {
options.addArguments("--headless=new", "--window-size=1280,800");
}
driver = new ChromeDriver(options);
driver.manage().window().maximize();
}
@AfterEach
void tearDown() {
if (driver != null) {
driver.quit();
}
}
@Test
void validUserReachesDashboard() {
driver.get("https://example.test/login");
driver.findElement(By.id("email")).sendKeys("qa.user@example.com");
driver.findElement(By.id("password")).sendKeys("CorrectHorse-Battery1");
driver.findElement(By.cssSelector("button[type='submit']")).click();
assertTrue(driver.getCurrentUrl().contains("/dashboard"));
}
}Python (pytest)
pip install selenium pytestimport os
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
@pytest.fixture
def driver():
options = Options()
if os.getenv("CI"):
options.add_argument("--headless=new")
options.add_argument("--window-size=1280,800")
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
def test_valid_user_reaches_dashboard(driver):
driver.get("https://example.test/login")
driver.find_element(By.ID, "email").send_keys("qa.user@example.com")
driver.find_element(By.ID, "password").send_keys("CorrectHorse-Battery1")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
assert "/dashboard" in driver.current_urlBoth examples are intentionally plain. Production suites add waits, page objects, configuration, and reporting—but starting from a visible session lifecycle helps newcomers understand what later abstractions hide.
Sessions, browsers, and capabilities
A session begins when you construct a WebDriver instance and ends when you call quit(). close() closes a window; quit() ends the session and cleans up the driver process. Leaking sessions is a common CI resource problem—always quit in teardown, even on failure.
Capabilities (and browser options) control headless mode, binary path, proxy, accept-insecure-certs, and mobile emulation. Keep environment-specific options behind configuration:
- Local: headed Chrome for debugging
- CI: headless, fixed window size, maybe disabled GPU flags as required by the image
- Grid: remote URL plus browserName / browserVersion / platformName
For cross-browser testing, define one test code path and vary capabilities per job—do not fork the suite per browser unless a browser-specific bug forces it.
Locators: choose for stability
Selenium’s By strategies:
| Strategy | When it helps |
|---|---|
id | Stable engineering IDs |
name | Form controls with unique names |
cssSelector | Flexible, readable for many teams |
xpath | DOM relationships CSS cannot express cleanly |
linkText / partialLinkText | Simple navigation links |
tagName / className | Rarely unique enough alone |
Prefer locators that match product contracts:
By email = By.cssSelector("[data-testid='login-email']");
By submit = By.cssSelector("[data-testid='login-submit']");Agree data-testid (or equivalent) with frontend. Avoid XPath that indexes siblings (div[3]/span[2])—layout changes will break you. Text-based locators are fine for unique buttons; they are fragile when copy is A/B tested or localized unless you isolate locale in the environment.
Navigation and page state
Core navigation APIs:
driver.get("https://example.test/orders")
driver.back()
driver.forward()
driver.refresh()
print(driver.title)
print(driver.current_url)Window and tab handling matters for SSO popups and “open in new tab” flows:
String original = driver.getWindowHandle();
// trigger action that opens a new window
for (String handle : driver.getWindowHandles()) {
if (!handle.equals(original)) {
driver.switchTo().window(handle);
break;
}
}
// assert, then
driver.close();
driver.switchTo().window(original);Frames and iframes still appear in legacy admin tools and payment widgets. Switch into the frame before locating inner elements; switch back to default content when done. Forgetting the switch is a classic “element not found” false alarm.
Assertions: keep them in the test framework
Selenium finds and drives; your unit-test framework asserts. In Java that is often JUnit or TestNG; in Python, pytest or unittest.
Assertions.assertEquals("Dashboard", driver.getTitle());
Assertions.assertTrue(driver.findElements(By.cssSelector(".toast-error")).isEmpty());assert driver.title == "Dashboard"
assert driver.find_elements(By.CSS_SELECTOR, ".toast-error") == []Write assertions against user-visible outcomes: title, URL, row text, enabled state. Asserting ephemeral CSS classes couples tests to styling. Map automated checks back to clear login test cases and broader how to write QA test cases standards so automation and manual suites share vocabulary.
Synchronization: why waits matter
WebDriver commands are synchronous at the protocol level, but the application is not. SPAs render after network calls; buttons enable after validation; toasts appear then vanish.
Three common approaches:
- Implicit waits — a global “poll for presence” timeout on findElement. Easy to turn on; hard to reason about when mixed with explicit waits. Many teams disable implicit waits entirely.
- Explicit waits — wait for a condition (visible, clickable, URL contains, text present) with a timeout. Prefer this.
- Hard sleeps —
Thread.sleep/time.sleep. Use only for temporary diagnostics.
Java sketch with WebDriverWait:
import java.time.Duration;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector("h1")));
wait.until(ExpectedConditions.urlContains("/dashboard"));Python:
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, "h1")))
wait.until(EC.url_contains("/dashboard"))Treat this as a preview. Production suites need custom expected conditions, fluent wait tuning, and clear timeout budgets—covered in Selenium explicit waits. Poor waiting strategy is the leading cause of flaky tests in Selenium shops.
Suite structure that scales past demos
A maintainable layout:
src/test/java/
config/ # base URL, credentials via env
pages/ # page objects
flows/ # multi-page journeys (optional)
tests/ # JUnit/TestNG classes
support/ # waits, screenshots, driversPython equivalent with pages/, tests/, conftest.py for fixtures.
Use the page object model once duplication appears. Keep page objects focused on elements and user actions; keep assertions in tests (or small assertion helpers) so failures read clearly in reports.
Organize suites by risk, not by folder fashion:
- Smoke — login, primary create/view path
- Regression — mapped to regression test cases for release trains
- Browser matrix — subset on Firefox/WebKit/Safari via grid
Headless and CI execution
Headless Chrome/Edge is standard in pipelines:
options.addArguments("--headless=new");
options.addArguments("--window-size=1920,1080");Fixed window size avoids “element not interactable” differences between headed laptop viewports and tiny CI defaults. Capture screenshots on failure and attach them to your reporter (Allure, ReportPortal, or plain CI artifacts).
CI checklist:
- Install matching browser + rely on Selenium Manager or pin driver versions deliberately
- Inject
BASE_URLand secrets via environment variables - Fail fast on missing config rather than hanging on blank pages
- Run smoke on pull requests; fuller regression on schedule or pre-release
- Parallelize carefully against shared environments—colliding test users recreate flake
Remote WebDriver example:
from selenium.webdriver import Remote
from selenium.webdriver.chrome.options import Options
options = Options()
options.set_capability("browserName", "chrome")
driver = Remote(command_executor="http://localhost:4444/wd/hub", options=options)Grid URLs and capability keys vary by Selenium Grid version and cloud vendor—read your provider’s current docs rather than memorizing legacy DesiredCapabilities snippets.
Working with forms, alerts, and downloads
Real products are not only click and getText. Form work should mirror how you document cases: clear inputs, explicit submits, and observable outcomes.
from selenium.webdriver.support.ui import Select
email = driver.find_element(By.ID, "email")
email.clear()
email.send_keys("qa.user@example.com")
Select(driver.find_element(By.ID, "country")).select_by_visible_text("Canada")
driver.find_element(By.CSS_SELECTOR, "input[type='checkbox'][name='terms']").click()
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()Native alert dialogs still appear in older admin tools:
driver.switchTo().alert().accept();
// or
String text = driver.switchTo().alert().getText();
driver.switchTo().alert().dismiss();Prefer waiting until the alert is present before switching. For file uploads, send the absolute path to an input[type=file] when the DOM exposes one; for opaque custom widgets, ask frontend for a testable input. Downloads are environment-specific—configure the browser profile’s download directory in CI and assert file presence with the language’s filesystem APIs after the UI reports success.
Shadow DOM requires either piercing strategies available in your Selenium version or JavaScript execution helpers agreed with the team. Do not sprinkle executeScript clicks as a default; use them when the accessibility tree genuinely cannot expose the control.
Reporting and failure evidence
A green/red count is not enough for triage. At minimum:
- Capture a screenshot in
@AfterEach/ fixture teardown when the test failed - Log the current URL and page title beside the failure
- Attach the browser console log when debugging frontend exceptions
- Keep test names identical to case IDs or titles from your management tool when traceability matters
Example teardown sketch in Python:
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
driver = item.funcargs.get("driver")
if driver:
driver.save_screenshot(f"artifacts/{item.name}.png")Wire artifacts into your CI so a failing job opens with evidence, not a stack trace alone. That habit shortens the loop between “automation failed” and “product bug vs test bug.”
Mapping automation to case design
Selenium code should not invent coverage in a vacuum. Start from acceptance criteria and structured cases—happy path, negatives, and edges—then mark which belong in the browser. Exhaustive password-policy matrices often live better as API checks; the UI suite should prove composition: navigation, permission-gated menus, and end-to-end persistence the user can see.
When stories arrive faster than you can outline cases, draft structured coverage first. QA Workflow Assistant generates structured test cases from Jira stories so SDETs inherit a reviewed backlog instead of guessing which flows deserve WebDriver time. Keep automation candidates tagged in the same place you track regression test cases.
A practical split many teams use:
- Manual / exploratory for brand-new UX still changing weekly
- API automation for rules and validation
- Selenium smoke for login and one primary create/view path per critical module
- Selenium regression for release trains, mapped to case IDs
That split prevents the classic failure mode where every checkbox on a story becomes a brittle UI test.
Anti-patterns that age Selenium suites poorly
- Thread.sleep as architecture. Masks real timing issues and slows every run.
- God page objects. One class that knows the entire app becomes unmergeable.
- Capturing production passwords in repo config. Use secrets managers and dedicated test tenants.
- Asserting on loading spinners only. Prove the business outcome.
- One driver shared across parallel tests. Sessions are not thread-safe; isolate per test or per worker carefully.
- Ignoring iframe and shadow DOM boundaries. Failures look like “bad locators” when the context is wrong.
- Automating every manual case. Exhaustive field matrices often belong below the UI. Draft coverage from stories first—generate structured cases from Jira, review them, then automate the stable subset.
- Silent catch blocks around finds. Swallowing
NoSuchElementExceptionhides product breakage. - Copy-pasting driver setup into every class. Centralize options and session lifecycle so CI flags stay consistent.
FAQ
Is Selenium outdated compared with Playwright or Cypress?
No—outdated practices are. Selenium 4’s W3C protocol, Selenium Manager, and relative locators are modern enough for large orgs. Choose based on language standards, grid investment, and team skills. Greenfield Node teams often prefer Playwright; Java centers of excellence often stay on Selenium for good reasons.
Should we rewrite our Selenium suite?
Only with a clear pain: unbearable flake, unsupported browsers, or inability to hire. Rewrites pause feature coverage. Often fixing waits, data isolation, and page objects yields more value than a framework swap.
How do we handle login in every test?
Prefer a reusable auth helper or API-set cookie/session when the product allows it. Keep a small number of full UI login tests aligned with login test cases.
Where do explicit waits belong—tests or page objects?
Either works if consistent. Many teams put clickable/visible waits inside page actions so tests read as business steps. Avoid mixing implicit and explicit waits without a policy.
How should stories drive Selenium work?
Write clear cases from acceptance criteria, then automate the stable subset. For faster drafting from Jira, use QA Workflow Assistant; setup details live in Docs, and plan limits on Pricing.
Closing
Selenium WebDriver rewards disciplined sessions, stable locators, explicit synchronization, and suite design that mirrors risk. Master those before chasing exotic grids. Pair the technical suite with solid case design—and when Jira stories arrive faster than you can outline coverage, generate structured cases with QA Workflow Assistant, review them with your team, and automate what belongs in the browser.
Stay ahead in QA
Get practical QA guides, Jira & Xray tutorials, testing checklists, AI testing insights, and occasional product updates.
Related articles
Cross-Browser Testing Strategy for Automated Suites
Build a practical cross-browser testing strategy—risk-based matrices, Playwright and Selenium approaches, viewport coverage, CI cost control, and when to keep manual sampling.
August 7, 2026 · 10 min read
Java Selenium Tutorial: Build Reliable UI Automation
A practical Java Selenium tutorial covering Maven setup, WebDriver, locators, waits, page objects, assertions, parallel runs, reporting, and CI packaging for QA teams.
August 7, 2026 · 9 min read
Page Object Model in Test Automation: Patterns That Scale
Learn the Page Object Model for Selenium and Playwright: scalable patterns, composition vs inheritance, Page Factory pitfalls, and when POM is the wrong fit.
August 7, 2026 · 9 min read