Skip to content
QA Workflow Assistant

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.

QA Workflow Assistant10 min read
  • 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 pytest
import 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_url

Both 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:

StrategyWhen it helps
idStable engineering IDs
nameForm controls with unique names
cssSelectorFlexible, readable for many teams
xpathDOM relationships CSS cannot express cleanly
linkText / partialLinkTextSimple navigation links
tagName / classNameRarely 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.

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:

  1. 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.
  2. Explicit waits — wait for a condition (visible, clickable, URL contains, text present) with a timeout. Prefer this.
  3. Hard sleepsThread.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, drivers

Python 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_URL and 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:

  1. Manual / exploratory for brand-new UX still changing weekly
  2. API automation for rules and validation
  3. Selenium smoke for login and one primary create/view path per critical module
  4. 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

  1. Thread.sleep as architecture. Masks real timing issues and slows every run.
  2. God page objects. One class that knows the entire app becomes unmergeable.
  3. Capturing production passwords in repo config. Use secrets managers and dedicated test tenants.
  4. Asserting on loading spinners only. Prove the business outcome.
  5. One driver shared across parallel tests. Sessions are not thread-safe; isolate per test or per worker carefully.
  6. Ignoring iframe and shadow DOM boundaries. Failures look like “bad locators” when the context is wrong.
  7. 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.
  8. Silent catch blocks around finds. Swallowing NoSuchElementException hides product breakage.
  9. 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.

No spam. Unsubscribe anytime.