The Testing Academy · Selenium Practice

Selenium WebDriver End-to-End
Architecture Blueprint

How a Selenium test actually runs: test file to TestNG to the client library, one HTTP request per command in the W3C WebDriver protocol, through the optional Grid, into chromedriver, and out to the browser and DOM. Play the interactive map, then go deep: driver factories, waits, page objects, Grid, Docker, reporting, and flake control.

Interactive architecture mapW3C protocol anatomyThreadLocal driver factoryGrid + Docker + CI

Play the architecture

Pick a command and press Play: the map lights up layer by layer as the command travels through the stack, with a one-line explanation at every hop. Then read the full blueprint below.

Command
Step 0 / 0
YOUR SIDE (Java + TestNG) SELENIUM GRID (optional) DRIVER + BROWSER Test fileOrdersTest.javafindElement + click TestNG runnertestng.xmlparallel threads Client libraryWebDriver ifaceRemoteWebDriver HTTP clientone requestper command Selenium Managerresolves driverssince 4.6 Routerreceives session+ command traffic Distributormatches capabilitiesto a free node Grid Noderuns the browserreports status Browser driverchromedriver etc.W3C to native Browser processChrome, Firefox,Edge, Safari Renderer + DOMelement lives hereevents fire here W3C protocolPOST /session/:id/element/:eid/click EvidenceAllure results, screenshots,logs, page source Framework coreDriverFactory (ThreadLocal),WaitUtils, Config, BaseTest HTTP + JSON (W3C) forwards session

The Grid lane only participates when you run remote: point RemoteWebDriver at a local driver and the same commands skip straight from the HTTP client to the browser driver.

The full blueprint
  1. What happens when a test runs
  2. Client side: options + driver factory
  3. The waiting strategy
  4. Page objects + framework layers
  5. Data, config, TestNG machinery
  6. Scale: Grid, Docker, cloud
  7. Reporting, flake control, checklist

1. What actually happens when a Selenium test runs

Run the snippet below and a browser opens, navigates, and clicks a button. It reads like a local method call. It executes like a small distributed system: five components, two protocol translations, and one HTTP request per command. Most flaky-test folklore dissolves once you can picture this pipeline, so before page objects, waits, or Grid, we will trace one click all the way down the selenium webdriver architecture.

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.testng.annotations.Test;

public class CheckoutSmokeTest {

    @Test
    public void studentCanReachCheckout() {
        WebDriver driver = new ChromeDriver();               // a whole process tree starts here
        driver.get("https://app.thetestingacademy.com/playwright/");
        driver.findElement(By.id("checkout-btn")).click();   // the line we will trace
        driver.quit();
    }
}

The interesting work hides inside two calls: findElement and click. Here is every hop they make.

The relay: from your test method to a DOM node

Hop 1: the test runner. TestNG or JUnit discovers studentCanReachCheckout() through annotations and reflection, wires up lifecycle hooks, and invokes it. The runner knows nothing about browsers. Its entire contribution to selenium architecture is orchestration: which tests run, in what order, on how many threads, and how failures are reported.

Hop 2: the Selenium client library. WebDriver is just a Java interface. ChromeDriver, FirefoxDriver, and EdgeDriver are thin subclasses of RemoteWebDriver that also manage the driver process: every Selenium test is a remote test; some just talk to localhost. RemoteWebDriver maps your call to a named protocol command and serializes it to JSON. Your locator is rewritten too: the standard defines only five location strategies (CSS selector, XPath, link text, partial link text, tag name), so By.id("checkout-btn") quietly becomes the CSS selector #checkout-btn before leaving the JVM.

Hop 3: the HTTP client. The serialized command is handed to an embedded HTTP client (the JDK's own java.net.http in current Selenium 4 builds) and POSTed to a local web server. Which server? The one the driver executable opened.

Hop 4: the browser driver executable. When new ChromeDriver() ran, Selenium launched chromedriver as a separate OS process, and chromedriver started an HTTP server on a local port (9515 when started by hand, a random free port otherwise). This small server is the heart of how selenium works internally: it accepts w3c webdriver protocol requests on one side and speaks the browser's private automation dialect on the other.

BrowserDriver binaryMaintained byNative channel behind the W3C surface
ChromechromedriverChromium teamDevTools (CDP) internals
EdgemsedgedriverMicrosoftDevTools (CDP) internals, Chromium base
FirefoxgeckodriverMozillaMarionette remote protocol
SafarisafaridriverApple, ships with macOSWebKit's built-in automation session

Hop 5: the browser process, then the renderer. chromedriver forwards the command over the browser's internal channel to the browser process, which routes it to the renderer that owns your tab. For a click, the renderer resolves the element reference to a live DOM node, scrolls it into view, verifies interactability, computes the in-view center point, and synthesizes trusted input events at those coordinates: pointer down, mouse down, pointer up, mouse up, click. Your application's listeners fire exactly as for a human. The result travels the chain back as an HTTP response.

#ComponentSpeaksOne-line job
1TestNG / JUnitJava reflectionDiscover, order, and invoke tests; report outcomes
2Selenium client (RemoteWebDriver)Java in, JSON outMap WebDriver calls to W3C commands, hold the session id
3HTTP client (java.net.http)HTTP/1.1Deliver one request per command to the driver's server
4Driver executableW3C in, native outValidate and translate commands for one specific browser
5Browser + rendererCDP / Marionette / WebKitResolve elements, synthesize trusted input, mutate the DOM

One command, one HTTP request

Here is the design decision that explains almost everything else in selenium architecture: the client and the browser share no memory and no persistent channel. The only link is HTTP, and every WebDriver command is exactly one request-response pair. Three matter for our test: create a session, find an element, click it. Here is what crosses the wire, trimmed but structurally faithful:

// 1) Create the session: the client leases a browser
POST http://localhost:9515/session
{
  "capabilities": {
    "alwaysMatch": {
      "browserName": "chrome",
      "goog:chromeOptions": { "args": ["--headless=new"] }
    }
  }
}

HTTP/1.1 200 OK
{
  "value": {
    "sessionId": "b7a9c2e4d15f8a03c6e1",
    "capabilities": { "browserName": "chrome", "browserVersion": "126.0.6478.62" }
  }
}

// 2) Find the element: note the rewritten locator strategy
POST /session/b7a9c2e4d15f8a03c6e1/element
{ "using": "css selector", "value": "#checkout-btn" }

HTTP/1.1 200 OK
{
  "value": {
    "element-6066-11e4-a52e-4f735466cecf": "f3d1a7b2-4c88-4e21-9d5a-1b2c3d4e5f60"
  }
}

// 3) Click it: element id in the URL, empty JSON body
POST /session/b7a9c2e4d15f8a03c6e1/element/f3d1a7b2-4c88-4e21-9d5a-1b2c3d4e5f60/click
{}

HTTP/1.1 200 OK
{ "value": null }

Three details repay attention. First, the session. POST /session is a lease on a browser: the returned sessionId namespaces every later request, which is how one driver executable serves several isolated sessions. driver.quit() is just DELETE /session/:id, and skipping it leaks a real OS process: that is why abandoned chromedriver processes pile up on CI agents.

Second, the element reference. findElement does not return "the element". It returns an opaque handle, a UUID the driver associates with one node in the current page. The strange JSON key wrapping it, element-6066-11e4-a52e-4f735466cecf, is not corruption: the w3c webdriver protocol fixes that exact string as the universal web element identifier, so every client and driver agrees on where to find the id. Your Java WebElement is just this handle, the session id, and a pointer to the HTTP plumbing.

Third, the click posts to /session/:sessionId/element/:elementId/click with an empty body. All the intelligence (scrolling, interactability checks, coordinate math) lives on the driver and browser side; your code only states intent.

Staleness, explained by the wire. That element handle points at one DOM node in one page state. Navigate, reload, or let your framework re-render the subtree, and the node behind the handle is destroyed; the next command using it gets a stale element reference error straight from the driver. No wait resurrects a dead handle: you re-find the element and get a fresh one. Staleness is the protocol telling you your reference outlived the DOM.

Selenium 4 speaks W3C; Selenium 3 spoke JSON Wire

Everything above describes Selenium 4, where the wire format is the w3c webdriver protocol, a formal W3C Recommendation implemented natively by browser vendors. Selenium 3 spoke JSON Wire, a convention the Selenium project invented before any standard existed; vendors had to chase a moving, informally specified target. The two look superficially similar (JSON over HTTP) and differ in ways that used to burn teams weekly.

The sharpest difference is capabilities negotiation. JSON Wire's desiredCapabilities was a free-form wish list: you could ask for anything, and a server that did not understand a key silently ignored it, so tests "passed" for months with settings that were never applied. W3C replaced it with a strict capabilities object holding alwaysMatch (honored or the session is refused) and firstMatch (ordered alternatives), and requires vendor-specific settings to carry a prefix, as in goog:chromeOptions or moz:firefoxOptions. An unknown unprefixed key is an error, not a shrug. That is why DesiredCapabilities died in Selenium 4 in favor of typed ChromeOptions and FirefoxOptions: the old merge semantics were undefined, unverifiable, and impossible to standardize.

AspectSelenium 3 (JSON Wire)Selenium 4 (W3C WebDriver)
StatusProject convention, never a standardW3C Recommendation, implemented by browser vendors
CapabilitiesdesiredCapabilities wish list; unknown keys silently droppedalwaysMatch / firstMatch; strict validation; vendor keys prefixed
ErrorsHTTP 200 plus a numeric status field in the bodyReal HTTP status codes plus error / message / stacktrace
Complex inputSingular endpoints: /moveto, /buttondownUnified Actions API (/actions) with pointer, key, wheel sources
Element geometrySeparate /location and /sizeSingle /rect endpoint
Dialect handshakeHybrid payload plus response-sniffing in late v3One dialect, no guessing

The transition years were painful precisely because of that handshake row: late Selenium 3 clients sent a hybrid new-session payload carrying both dialects and guessed from the response which one the server spoke. Selenium 4 deleted the guesswork: client speaks W3C, driver speaks W3C, and a whole class of "works locally, fails on the vendor cloud" bugs disappeared.

Selenium Manager: the end of manual driver downloads

For a decade, the first selenium webdriver architecture lesson every beginner learned the hard way was version drift. Chrome auto-updates on a Tuesday, your pinned chromedriver falls one major version behind, and every build fails with session not created: this version of ChromeDriver only supports Chrome version N. Teams kept wiki pages on driver downloads, webdriver.chrome.driver properties, and drivers committed to git (please do not).

Since Selenium 4.6, that chore is gone. The client library ships with Selenium Manager, a small Rust binary. When you construct a driver and Selenium finds no explicit system property and no matching driver on PATH, it shells out to Selenium Manager, which detects the installed browser version, downloads the exact matching driver into a local cache (~/.cache/selenium), and returns its path. Since 4.11 it can even provision the browser itself via Chrome for Testing builds. Resolution order: explicit system property, then PATH, then Selenium Manager. A zero-config new ChromeDriver() now genuinely works on a clean machine.

See the traffic yourself. Start a driver by hand with chromedriver --port=9515 --verbose, point a RemoteWebDriver at it, and watch the log: every JSON payload from this section appears in plain text, one request per command. Ten minutes reading that log teaches more about how selenium works internally than most courses.

Why one-request-per-command matters: latency is part of your test

Because client and browser converse in individual HTTP round trips, network distance is a hidden multiplier on every test. On a laptop, the trip to localhost costs well under a millisecond, so nobody notices how chatty the protocol is. Move the browser to another machine and the arithmetic turns brutal. A modest end-to-end test easily issues 300 commands: every findElement, every getText, every poll inside an explicit wait is its own request. At 1 ms each that is background noise. At 80 ms of cross-region latency it is 24 seconds of pure network wait before the browser does any real work.

Understanding the pipeline turns this into habits. Prefer one precise locator over a broad findElements list filtered in Java, because client-side filtering drags every candidate across the wire. Keep polling intervals sane, because each poll is a real request. And when you need fifty values from an orders table, one JavascriptExecutor call returning all of them beats 150 getText round trips.

Notice what this design quietly bought us: since the only link between client and driver is HTTP, nothing requires them to share a machine. Point RemoteWebDriver at a different URL and the identical commands flow across the network to a hub, which forwards them to a node running the browser in a datacenter. That is the entire basis of Selenium Grid, and why the Grid section later in this blueprint will feel familiar: it is the same protocol you just watched, with a router in the middle.

2. The client side: WebDriver interface, options, and a thread-safe driver factory

Your test code never touches a browser. It talks to a client library, and the client library talks HTTP. That makes the client side the cheapest place to enforce good architecture: every decision here is plain Java, with no infrastructure required. Three decisions carry most of the weight: coding against the right interface, configuring sessions through options instead of flags scattered across the suite, and confining each driver to one thread so Selenium parallel execution works on the first try instead of the fifth rewrite. This section builds all three, ending in a complete Selenium driver factory and the BaseTest wiring that uses it.

The interface family: it is RemoteWebDriver all the way down

WebDriver is an interface, not a class. It extends SearchContext, which contributes the two find methods, and adds the session-level contract: get(), navigate(), manage(), getWindowHandles(), close(), quit(). The concrete class behind it is RemoteWebDriver, and here is the fact that reorganizes your mental model: ChromeDriver, EdgeDriver, and FirefoxDriver all extend RemoteWebDriver. A local ChromeDriver is not a different kind of thing from a Grid connection. The only differences are who launches the browser-driver process and which URL the HTTP client targets. new ChromeDriver() asks Selenium Manager to locate a matching chromedriver binary, spawns it, and sends W3C commands to localhost on a random port. new RemoteWebDriver(gridUrl, options) sends byte-identical payloads to a Grid or cloud endpoint instead. Test logic changes not at all between the two, which is why the factory below can flip between them with one system property.

Two practical rules fall out of the hierarchy. First, declare every field, parameter, and return type as WebDriver, never as ChromeDriver: page objects that demand a concrete class weld your suite to one browser. Second, capability interfaces such as JavascriptExecutor and TakesScreenshot are implemented by RemoteWebDriver itself, so casts like ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES) are safe for any mainstream driver, local or remote. Code that checks instanceof ChromeDriver to branch behavior is almost always hiding a configuration problem that belongs in options.

TypeKindWhat it gives you
SearchContextinterfacefindElement / findElements; implemented by both drivers and elements
WebDriverinterfacethe session contract; the type your framework should reference everywhere
RemoteWebDriverconcrete classthe real implementation speaking W3C over HTTP; also implements JavascriptExecutor, TakesScreenshot
ChromeDriver / EdgeDriversubclasses via ChromiumDriverstart and manage a local driver process, then behave exactly like RemoteWebDriver
FirefoxDriversubclasssame idea over geckodriver
ChromeOptions / FirefoxOptionsCapabilities implementationstyped session configuration, serialized into the New Session request
No more driver-binary chores. Since Selenium 4.6, Selenium Manager ships inside the client and resolves the correct chromedriver, geckodriver, or msedgedriver for the installed browser automatically. If your framework still bundles a driver-download library or a drivers/ folder in git, delete it: one less thing to break on every browser release.

Options classes: typed capabilities that survive the wire

Selenium 3 configured browsers through DesiredCapabilities, a free-form map of strings where a typo failed silently. Selenium 4 replaced direct use of it with typed, per-browser Options classes: ChromeOptions, FirefoxOptions, EdgeOptions, SafariOptions. Each implements the Capabilities interface, so under the hood it is still a map: when a driver is constructed, the object serializes into the capabilities field of the New Session request from the previous section. Standard entries (browserName, acceptInsecureCerts, pageLoadStrategy, unhandledPromptBehavior) sit at the top level, and everything vendor-specific travels under a prefixed key: goog:chromeOptions for Chromium arguments and prefs, moz:firefoxOptions for Gecko preferences. That is the entire Selenium options capabilities model: a typed builder on the client, standardized JSON on the wire, vendor extensions namespaced so a remote end can ignore what it does not understand.

// Everything a CI-grade Chrome session usually needs
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new", "--window-size=1920,1080");

// User-profile preferences: pin downloads, kill the save-password popup
Map<String, Object> prefs = new HashMap<>();
prefs.put("download.default_directory", "/tmp/order-invoices");
prefs.put("credentials_enable_service", false);
options.setExperimentalOption("prefs", prefs);

// Cheap responsive smoke test: phone viewport via the devtools emulator
Map<String, Object> mobileEmulation = Map.of("deviceName", "Pixel 7");
options.setExperimentalOption("mobileEmulation", mobileEmulation);

options.setAcceptInsecureCerts(true);                // staging TLS
options.setPageLoadStrategy(PageLoadStrategy.EAGER); // return at DOMContentLoaded

WebDriver driver = new ChromeDriver(options);

Three lines deserve a closer look. --headless=new is not the old headless: legacy headless Chrome was a separate stripped-down implementation with its own rendering quirks, while the new mode runs the real browser code path without a window, so what passes headed passes headless far more often. The prefs map writes user-profile settings, which is how you pin a download directory for an invoice-download test or stop the password bubble from covering a logout menu. And mobileEmulation drives the device toolbar from capabilities: useful for checking an orders table at phone width, with the honest caveat that it emulates viewport and user agent, not a real device.

The table below is the Selenium options capabilities shortlist that repeatedly earns its place in pipelines. Defaults are fine on a laptop; CI containers are where sessions crash without these.

OptionWhereWhy it matters in CI
--headless=newChrome, Edgereal browser without a display; far closer to headed behavior than legacy headless
--window-size=1920,1080Chrome, Edgeheadless defaults to a small viewport; cures "element not interactable" that only reproduces in CI
--no-sandboxChrome, Edgerequired when the container user is root, which most Docker CI images are
--disable-dev-shm-usageChrome, Edgeprevents tab crashes caused by Docker's default 64 MB /dev/shm
prefs: download.default_directoryChrome, Edgedeterministic download folder so file assertions do not search the filesystem
-headlessFirefoxheadless Gecko; note the single dash
browser.download.folderList=2 + browser.download.dirFirefoxpreference pair that pins the download location
setAcceptInsecureCerts(true)allstaging environments behind self-signed TLS
setPageLoadStrategy(EAGER)allget() returns at DOMContentLoaded; pair it with explicit waits

The parallel problem: one static driver, many threads

Now the part that breaks frameworks. TestNG happily runs methods concurrently the moment testng.xml says parallel="methods". If the suite holds its browser in one static field, every worker thread shares one session:

public class CheckoutTests {
    // DO NOT do this: one session shared by every TestNG worker thread
    private static WebDriver driver = new ChromeDriver();
}

Thread A navigates to checkout while thread B asserts the login header. Thread C finishes first, calls quit(), and threads A and B start throwing NoSuchSessionException mid-click. Screenshots attach to the wrong tests, failures move between runs, and someone concludes Selenium parallel execution is inherently flaky. It is not. A WebDriver instance represents exactly one browser session, and the bindings make no thread-safety promises. The entire discipline fits in one sentence: one driver per thread, and no thread ever touches another thread's driver.

The JDK ships the exact tool for that sentence. A ThreadLocal WebDriver holder is a single static field where each thread sees its own private slot: thread 1's set() is invisible to thread 2's get(). No locks, no maps keyed by thread name. Wrap it in a class that owns creation, lookup, and teardown, and you have the ThreadLocal WebDriver pattern that nearly every serious Java framework converges on.

ThreadLocal is confinement, not magic. The isolation holds only while the driver stays on its thread. Cache getDriver() in a static field, hand it to a shared singleton, or stash it in a listener, and you have rebuilt the shared-driver bug with extra steps. Fetch the driver through the factory at the point of use, every time.

A production-shaped Selenium driver factory

// DriverFactory.java - the only class allowed to construct drivers
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.edge.EdgeDriver;
import org.openqa.selenium.edge.EdgeOptions;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.remote.LocalFileDetector;
import org.openqa.selenium.remote.RemoteWebDriver;

import java.net.MalformedURLException;
import java.net.URL;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

public final class DriverFactory {

    private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();

    private DriverFactory() { }

    /** Driver bound to the current thread. Fails loudly if there is none. */
    public static WebDriver getDriver() {
        WebDriver driver = DRIVER.get();
        if (driver == null) {
            throw new IllegalStateException(
                "No WebDriver on thread " + Thread.currentThread().getName()
                    + ". BaseTest.setUp() must run first.");
        }
        return driver;
    }

    /** Creates this thread's driver from -Dbrowser, -Dremote, -Dheadless. */
    public static void initDriver() {
        if (DRIVER.get() != null) {
            return; // this thread already owns a live browser
        }
        String browser = System.getProperty("browser", "chrome").toLowerCase();
        boolean remote = Boolean.parseBoolean(System.getProperty("remote", "false"));

        WebDriver driver = remote ? createRemoteDriver(browser)
                                  : createLocalDriver(browser);

        driver.manage().timeouts().implicitlyWait(Duration.ZERO); // explicit waits only
        DRIVER.set(driver);
    }

    private static WebDriver createLocalDriver(String browser) {
        switch (browser) {
            case "chrome":  return new ChromeDriver(chromeOptions());
            case "firefox": return new FirefoxDriver(firefoxOptions());
            case "edge":    return new EdgeDriver(edgeOptions());
            default:
                throw new IllegalArgumentException("Unsupported browser: " + browser);
        }
    }

    private static WebDriver createRemoteDriver(String browser) {
        String gridUrl = System.getProperty("grid.url", "http://localhost:4444");
        Capabilities caps;
        switch (browser) {
            case "chrome":  caps = chromeOptions();  break;
            case "firefox": caps = firefoxOptions(); break;
            case "edge":    caps = edgeOptions();    break;
            default:
                throw new IllegalArgumentException("Unsupported browser: " + browser);
        }
        try {
            RemoteWebDriver driver = new RemoteWebDriver(new URL(gridUrl), caps);
            driver.setFileDetector(new LocalFileDetector()); // uploads work via Grid
            return driver;
        } catch (MalformedURLException e) {
            throw new IllegalStateException("Bad grid.url: " + gridUrl, e);
        }
    }

    private static ChromeOptions chromeOptions() {
        ChromeOptions options = new ChromeOptions();
        if (Boolean.parseBoolean(System.getProperty("headless", "true"))) {
            options.addArguments("--headless=new");
        }
        options.addArguments("--window-size=1920,1080",
                             "--no-sandbox",
                             "--disable-dev-shm-usage");
        Map<String, Object> prefs = new HashMap<>();
        prefs.put("credentials_enable_service", false);
        prefs.put("download.default_directory",
                  System.getProperty("user.dir") + "/target/downloads");
        options.setExperimentalOption("prefs", prefs);
        return options;
    }

    private static FirefoxOptions firefoxOptions() {
        FirefoxOptions options = new FirefoxOptions();
        if (Boolean.parseBoolean(System.getProperty("headless", "true"))) {
            options.addArguments("-headless");
        }
        options.addPreference("browser.download.folderList", 2);
        options.addPreference("browser.download.dir",
                              System.getProperty("user.dir") + "/target/downloads");
        return options;
    }

    private static EdgeOptions edgeOptions() {
        EdgeOptions options = new EdgeOptions();
        if (Boolean.parseBoolean(System.getProperty("headless", "true"))) {
            options.addArguments("--headless=new");
        }
        return options;
    }

    /** Quits this thread's browser and clears the slot. Safe to call twice. */
    public static void quitDriver() {
        WebDriver driver = DRIVER.get();
        if (driver != null) {
            try {
                driver.quit();   // ends the session, kills child processes
            } finally {
                DRIVER.remove(); // never leave a dead driver on a pooled thread
            }
        }
    }
}

Walk the seams of this Selenium driver factory, because each is a deliberate decision. getDriver() throws instead of lazily creating a browser: silent creation hides ordering bugs where a utility grabs a driver before setup ran. initDriver() is idempotent per thread, so a re-entered configuration cannot stack two browsers on one worker. Browser choice and the local-versus-remote split hang off system properties (-Dbrowser=firefox -Dremote=true -Dgrid.url=http://grid:4444), so the same jar runs on a laptop and in a pipeline, and a CI matrix is just property combinations. The remote branch builds a RemoteWebDriver against the grid URL using the same options objects as the local branch, so capabilities never fork between environments. setFileDetector(new LocalFileDetector()) is the line everyone forgets: without it, sendKeys() with a file path uploads fine locally and fails through Grid, because the file exists on the client machine, not the node. And quitDriver() pairs quit() with remove() inside a finally, which matters enough to earn its own row in the bug table below.

Wiring it into BaseTest

public abstract class BaseTest {

    @BeforeMethod(alwaysRun = true)
    public void setUp() {
        DriverFactory.initDriver();
        DriverFactory.getDriver().get("https://app.thetestingacademy.com/playwright/");
    }

    @AfterMethod(alwaysRun = true)
    public void tearDown() {
        DriverFactory.quitDriver();
    }

    protected WebDriver driver() {
        return DriverFactory.getDriver();
    }
}
<!-- testng.xml: four browsers at a time, one per worker thread -->
<suite name="regression" parallel="methods" thread-count="4">
  <test name="checkout">
    <classes>
      <class name="com.tta.tests.CheckoutTests"/>
    </classes>
  </test>
</suite>

Every test class extends BaseTest and never thinks about drivers again. Both annotation details are load-bearing. alwaysRun = true keeps setup and teardown executing when you run a subset of groups or when an earlier configuration method fails; without it, TestNG skips non-matching config methods during group runs and browsers leak by the dozen. And teardown lives in @AfterMethod, never at the end of a test method, because an assertion failure aborts the method body: any quit() after the failing line simply never executes, which is exactly how one red build strands thirty Chrome processes on an agent.

Scope is per method deliberately. A browser per class runs marginally faster but leaks cookies, localStorage, and logged-in state between tests, and one crashed session fails every remaining method in the class. A fresh browser per test across four threads beats shared browsers with ordering dependencies almost every time, and it is the only configuration where retry analyzers behave predictably. If startup cost genuinely hurts, raise thread-count against your Grid capacity before you compromise isolation.

Parallel data providers count too. parallel="methods" is not the only source of concurrency: @DataProvider(parallel = true) runs rows on its own thread pool sized by data-provider-thread-count. Those threads also pass through @BeforeMethod, so the ThreadLocal WebDriver factory covers them automatically, but any hand-rolled "current driver" map keyed by test name will not.

Finally, the lifecycle bugs that account for most parallel-suite pain. Each one has burned a real team; every fix is already present in the factory above.

BugWhat you observeRoot cause and fix
Leaked driversbuild agent slows; dozens of orphaned chromedriver processesquit() lived inside test methods, or teardown lacked alwaysRun; move it to @AfterMethod(alwaysRun = true)
close() used as teardowntests pass, memory climbs, sessions lingerclose() shuts one window and leaves the session and process alive; teardown must call quit()
Stale ThreadLocal after retryretried test dies instantly with NoSuchSessionExceptionquit() ran but remove() did not, so the pooled thread kept a dead reference; always pair them in a finally
Driver in a field initializerbrowsers open at suite start, before any test runsTestNG instantiates all test classes up front; construct drivers only inside @BeforeMethod via the factory
Static shared driverinterleaved navigation, wrong-page screenshots under parallelone session, many threads; use the ThreadLocal factory plus parallel="methods"

With this in place the client side is settled: code speaks to the WebDriver interface, options describe sessions declaratively, and the driver factory guarantees one browser per thread. Notice what the factory never had to know: whether chromedriver lives on your laptop or on a Grid node fifty containers away. That indifference is the entire point, and the server side that makes it possible is where we go next.

3. The waiting strategy: the part that decides if your suite is stable

Locators get the attention, but the waiting strategy decides whether your suite gets trusted or deleted. A bad locator fails identically every run; a bad waiting strategy fails one run in ten, usually on release day, until nobody believes red builds. Before designing page objects, settle the layer underneath: how the framework waits.

The uncomfortable fact: Selenium does not wait for anything unless you tell it to. The WebDriver protocol is deliberately dumb. A findElement call sends one command to the browser driver, the driver queries the DOM at that instant, and one of two things comes back: an element reference, or NoSuchElementException. That is the entire contract. No retry, no actionability check, no waiting for renders or pending requests. Newer tools like Playwright bake auto-waiting into every action; the WebDriver protocol answers "is it there right now" and nothing more. So in Selenium, waiting is an architectural layer you design, not a nicety you sprinkle in, and selenium waits done wrong are the single biggest source of instability in real suites.

The physics: your test runs at CPU speed while the application renders at network speed. Every line is a race between those two clocks, and the waiting layer is the referee.

Implicit wait: understand it, then turn it off

An implicit wait is a driver-level setting: driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)). From then on, any lookup that comes up empty is silently retried inside the driver for up to ten seconds before the exception is thrown. It feels like free stability, which is why it spreads.

Problem one: it is global and blind. Absence checks pay the worst price: asserting an error banner is not displayed now burns the full ten seconds, and a findElements used to count rows waits out the whole timeout before returning empty. Your fastest checks become your slowest.

Problem two is the classic trap: mixing implicit and explicit waits produces timeouts nobody can predict. WebDriverWait polls on the client side: evaluate the condition, sleep 500 ms, repeat until the clock runs out. But most ExpectedConditions call findElement internally, and each inner call is separately subject to the implicit wait at the driver end. A single poll of a five-second explicit wait can therefore stall ten seconds inside the driver. Waits for disappearance turn pathological: every "is it gone yet" check spends the full implicit timeout hoping the element appears. Selenium's own documentation warns against mixing the two, calling the result unpredictable. Explicit wait vs implicit wait is not a style debate: one mechanism silently corrupts the other.

The classic trap. Implicit 10 s plus explicit 5 s does not mean "whichever hits first". Every poll can stall up to 10 s at the driver, so a 5 s wait can run 20 s or more, and invisibility checks crawl. Zero the implicit wait and never look back.

For the blueprint, the explicit wait vs implicit wait comparison comes down to this:

AspectImplicit waitExplicit wait
Where retries runInside the driver (remote end)In your code (client side)
Can wait forDOM presence onlyVisibility, clickability, text, staleness, URL, any lambda
Failure detailGeneric NoSuchElementExceptionTimeoutException naming the condition and locator
When mixedPoisons every explicit pollPredictable only when implicit is zero
VerdictSet to zero in the driver factoryThe only wait mechanism in the suite

Explicit waits: WebDriverWait plus ExpectedConditions

The workhorse pattern is two lines:

WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement total = wait.until(
        ExpectedConditions.visibilityOfElementLocated(By.id("order-total")));

until re-evaluates the condition every 500 ms until it returns a truthy value, then hands it back with the right type. On failure you get a TimeoutException naming the condition and the locator: exactly the diagnostic detail an implicit wait never gives you.

Where mid-level engineers slip is condition choice, because the three core ExpectedConditions form a ladder, not synonyms. presenceOfElementLocated proves only that the node exists in the DOM: it can be invisible, zero-sized, or under an overlay. visibilityOfElementLocated proves presence plus a rendered, displayed box. elementToBeClickable adds the enabled state. Climb exactly as high as the action requires: read from visible, click on clickable, and use bare presence only when you want a hidden node, such as a hidden input carrying server state. One honest caveat: clickable does not mean uncovered. A translucent loading overlay above the button passes the condition, and the click still dies with ElementClickInterceptedException. Overlay handling is covered below.

ConditionWhat it provesReach for it when
presenceOfElementLocatedIn the DOM, possibly hiddenReading hidden inputs or pre-render state
visibilityOfElementLocatedPresent, displayed, non-zero sizeReading text, asserting something appeared
elementToBeClickableVisible and enabledBefore every click or sendKeys
invisibilityOfElementLocatedHidden or removedSpinners, overlays, toasts clearing
textToBePresentInElementLocatedText now contains a stringIn-place status flips ("Processing" to "Paid")
stalenessOfA held reference is detachedFencing a re-render before re-finding
frameToBeAvailableAndSwitchToItFrame loaded, driver switchedPayment iframes, embedded editors

FluentWait: the general form

WebDriverWait is a FluentWait<WebDriver> preloaded with defaults: 500 ms polling and NotFoundException ignored. FluentWait exposes the dials: polling interval, exception types to swallow between attempts, the failure message, and it waits on any object, not only the driver. Reach for it when the default rhythm is wrong: a status badge that flips within a second deserves 200 ms polling; a minute-long report should poll every two seconds instead of hammering the grid. The utility class below builds on it so the dials live in one place.

StaleElementReferenceException: why references die

Every suite eventually meets this exception. A WebElement is not the node itself. It is a handle: an opaque id minted by the driver, pointing at one specific node in the browser's live DOM. Modern frontends rebuild DOM subtrees constantly: a React state update unmounts a table and mounts a pixel-identical replacement in one frame. Same id, class, and text, but a different node, and your handle still points at the corpse of the old one. The next command through that handle fails with the classic selenium stale element error. Nothing is wrong with your locator; the reference simply outlived the DOM it described.

That asymmetry is the cure: references die on re-render, locators do not. A By describes how to find the element; a WebElement is one perishable result of that search. So never sleep and retry the same dead reference. Throw it away, re-run the locator, and retry the action a bounded number of times. It is also the deeper reason page objects should store By fields, not pre-resolved WebElement fields.

The staleness fence. stalenessOf turns the problem into a tool. Grab a reference to a row, trigger the refresh, wait for stalenessOf(oldRow), then re-find. The old reference going stale is proof the re-render happened, which beats guessing with a sleep.

A WaitUtils class worth copying

Centralize the policy. The class below wraps the common selenium waits behind intention-revealing names, funnels every timeout through named constants, and layers on a safeClick that retries the selenium stale element and intercepted-click cases without ever retrying a genuine timeout.

public final class WaitUtils {

    // Central timeout policy. Override via -Dtta.wait.default=20 on slow grids.
    public static final Duration DEFAULT =
            Duration.ofSeconds(Long.getLong("tta.wait.default", 10L));
    public static final Duration SLOW = Duration.ofSeconds(30);
    private static final Duration POLL = Duration.ofMillis(250);

    private final WebDriver driver;

    public WaitUtils(WebDriver driver) {
        this.driver = driver;
    }

    private FluentWait<WebDriver> fluent(Duration timeout) {
        return new FluentWait<>(driver)
                .withTimeout(timeout)
                .pollingEvery(POLL)
                .ignoring(NoSuchElementException.class)
                .ignoring(StaleElementReferenceException.class);
    }

    public WebElement visible(By locator) {
        return fluent(DEFAULT)
                .withMessage("Not visible: " + locator)
                .until(ExpectedConditions.visibilityOfElementLocated(locator));
    }

    public WebElement clickable(By locator) {
        return fluent(DEFAULT)
                .withMessage("Not clickable: " + locator)
                .until(ExpectedConditions.elementToBeClickable(locator));
    }

    public boolean gone(By locator) {
        return fluent(SLOW)
                .withMessage("Still visible: " + locator)
                .until(ExpectedConditions.invisibilityOfElementLocated(locator));
    }

    // Retries stale and intercepted clicks; never retries a real timeout.
    public void safeClick(By locator) {
        int attempts = 0;
        while (true) {
            try {
                clickable(locator).click();
                return;
            } catch (StaleElementReferenceException
                    | ElementClickInterceptedException e) {
                if (++attempts >= 3) {
                    throw new IllegalStateException(
                            "Click failed after 3 attempts: " + locator, e);
                }
                // Loop re-finds via the locator; the dead reference is discarded.
            }
        }
    }
}

Note what the loop refuses to do. It re-resolves the locator on each attempt, so a re-render between attempts heals naturally. It does not catch TimeoutException: an element that never becomes clickable is a real failure to report, not something to retry into a failure three times slower. And it caps attempts: unbounded retries turn a bug into a hang. Usage:

WaitUtils waits = new WaitUtils(driver);

waits.safeClick(By.id("place-order"));           // click when truly ready
waits.gone(By.cssSelector(".spinner-overlay"));   // let the app settle
String total = waits.visible(By.id("order-total")).getText();

JS-heavy apps: spinners and the network-idle illusion

Single-page apps add one more layer: the document finishes loading long before the data arrives. Two patterns cover most of it, one honest and one that needs a warning label.

The honest one is the spinner contract. If the app shows a loading overlay while fetching, waiting for it to become invisible is your most reliable signal, and it is plain ExpectedConditions: the gone() helper above. One subtlety: never wait for the spinner to appear first. On a fast response it can flash and vanish between two polls, and the appearance wait times out on a page that already finished. Trigger the action, wait for the overlay to be gone, then wait for the concrete consequence the assertion cares about: the row exists, the total changed. The dynamic widgets on the practice pages at https://app.thetestingacademy.com/playwright/ load on delays and behave the same under Selenium.

The second pattern approximates a network-idle signal through JavascriptExecutor, since plain Selenium cannot see network traffic. You interrogate the page itself:

// 1. Document parsed and subresources loaded (initial load only)
new WebDriverWait(driver, WaitUtils.DEFAULT).until(d ->
        "complete".equals(((JavascriptExecutor) d)
                .executeScript("return document.readyState")));

// 2. jQuery request counter (only meaningful if the app uses jQuery)
new WebDriverWait(driver, WaitUtils.DEFAULT).until(d ->
        (Boolean) ((JavascriptExecutor) d).executeScript(
                "return window.jQuery ? jQuery.active === 0 : true"));

// 3. Home-grown fetch counter: inject after load, before the action
((JavascriptExecutor) driver).executeScript(
        "window.__pending = 0;" +
        "const orig = window.fetch;" +
        "window.fetch = function() {" +
        "  window.__pending++;" +
        "  return orig.apply(this, arguments)" +
        "    .finally(() => window.__pending--);" +
        "};");

new WebDriverWait(driver, Duration.ofSeconds(15)).until(d ->
        ((Long) ((JavascriptExecutor) d)
                .executeScript("return window.__pending")) == 0L);

Each signal lies in a specific way; the fine print:

SignalWhat it provesBlind spot
document.readyState is "complete"Initial HTML and subresources loadedFires before any fetch or XHR data renders in a SPA
jQuery.active == 0No jQuery-managed requests in flightMeaningless if the app does not use jQuery; most modern SPAs do not
Injected fetch counterTracked fetch calls settledMisses pre-injection requests, XHR, websockets; breaks if the app rewraps fetch

The rule that falls out: JS signals are accelerants, not assertions. Use them to skip past obviously unsettled states, but the wait your assertion depends on must be an ExpectedConditions check on visible UI, because "the request completed" and "the user can see the result" are different facts separated by a render.

The waiting rule set

Codify this in the framework README and enforce it in review:

  1. One implicit wait policy: zero. The driver factory sets implicitlyWait(Duration.ZERO) so nobody guesses. Any nonzero value in the codebase is a defect, because it corrupts every explicit wait in the suite.
  2. All timeouts live in one config. Named constants with intent (DEFAULT, SLOW, page load), overridable via a system property so a slow CI grid can stretch them. A timeout literal at a call site is a review comment.
  3. Interactions go through the wrapper. Clicks via safeClick, reads via visible(). A naked driver.findElement in a page object bypasses the entire waiting layer.
  4. Wait for consequences, not durations. After every action, the next line waits for the observable result of that action.
  5. Thread.sleep is banned by default. The only acceptable form carries a comment explaining why no condition can express the wait, plus a ticket to remove it. A CI grep failing the build on undocumented sleeps ends the argument.

4. Page objects and the framework layers above raw WebDriver

Raw WebDriver calls scattered across test methods are the default failure state of every Selenium suite. That style works for ten tests, wobbles at fifty, and collapses at two hundred, because one UI change fans out into dozens of edits and nobody can say anymore what a given test actually verifies. The fix is not a bigger utils class. It is layering: a deliberate selenium framework design in which every class has exactly one job and dependencies point strictly downward.

Four layers cover everything a UI suite needs. Tests sit on top and read like requirements. Pages translate business intent into element interactions. Components model widgets that repeat across pages, like a navbar or a data table. Core owns plumbing that knows nothing about your application: driver creation, waiting, configuration, evidence capture. The dependency rule is absolute: tests call pages, pages call components, everyone calls core, and nothing ever calls upward. The day a page imports a test class, the architecture is already dead.

The four layers and their contracts

LayerOwnsMust never do
TestsScenario flow, test data, TestNG groups, every assertionLocate elements, touch WebDriver APIs, sleep
PagesLocators for one screen, user actions, navigation, load contractsAssert outcomes, create or quit drivers, hold test data
ComponentsOne widget's locators and behavior (NavBar, DataTable, DatePicker)Know which page hosts them, navigate, assert
CoreDriverFactory, Waits, Config, screenshots, listenersContain any application locator, URL path, or business term

The third column is where selenium framework design actually lives. Ownership lists are easy; suites rot when layers quietly absorb jobs that belong elsewhere. The first time a page asserts, or a test calls findElement, the boundary is gone, because every future author copies the example in front of them.

Here is the full skeleton for an original checkout product, with the pom.xml essentials listed at the bottom:

checkout-suite/
  pom.xml
  testng.xml                    <- master suite; references smoke.xml / regression.xml
  src/test/java/com/tta/
    core/
      DriverFactory.java        <- ThreadLocal<WebDriver>: create(browser), driver(), quit()
      Waits.java                <- visible(By), clickable(By), textIn(By, String); wraps WebDriverWait
      Config.java               <- baseUrl(), browser(), headless(); -D flags over config.properties
      Screenshots.java          <- capture(driver, testName) into target/evidence/
    components/
      NavBar.java
      DataTable.java
    pages/
      LoginPage.java
      OrdersPage.java
      OrderDetailsPage.java
      CheckoutPage.java
    tests/
      BaseTest.java
      LoginTests.java
      OrdersTests.java
      CheckoutTests.java
  src/test/resources/
    config.properties           <- default environment values
    testdata/orders.json        <- seed payloads used by API setup

pom.xml essentials:
  selenium-java                 (one pinned version; Selenium Manager ships inside it)
  testng                        (runner; parallel settings live in testng.xml)
  assertj-core                  (fluent assertions, used in tests only)
  maven-surefire-plugin         (suiteXmlFiles -> testng.xml; passes -D properties through)

Everything sits under src/test/java because this code exists only to serve tests; nothing in production imports it. The testng.xml files pick groups and set parallel mode, Surefire points at them, and Config layers system properties over a properties file, so mvn test -Dbrowser=firefox -DbaseUrl=https://staging.example.dev runs on any laptop and in CI with zero edits.

Parallel-ready from day one. DriverFactory should hand out drivers through a ThreadLocal<WebDriver> so TestNG can run methods in parallel without two tests sharing one browser. Retrofitting thread safety into a suite that kept its driver in a static field is a week of archaeology; starting with the ThreadLocal costs five lines.

The selenium page object model, done right

A page object is a class that owns exactly one screen: its locators and its behavior, nothing else. Whether the selenium page object model helps you or slowly strangles you comes down to four rules.

public class LoginPage {
    private final WebDriver driver;
    private final Waits waits;

    private final By emailInput    = By.id("email");
    private final By passwordInput = By.id("password");
    private final By signInButton  = By.cssSelector("button[type='submit']");
    private final By errorBanner   = By.cssSelector("[data-test='login-error']");

    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.waits  = new Waits(driver);
    }

    public LoginPage open() {
        driver.get(Config.baseUrl() + "/login");
        waits.visible(emailInput);              // load contract
        return this;
    }

    public OrdersPage signInAs(String email, String password) {
        waits.visible(emailInput).sendKeys(email);
        driver.findElement(passwordInput).sendKeys(password);
        waits.clickable(signInButton).click();
        return new OrdersPage(driver);      // navigation returns the destination
    }

    public LoginPage signInExpectingError(String email, String password) {
        waits.visible(emailInput).sendKeys(email);
        driver.findElement(passwordInput).sendKeys(password);
        waits.clickable(signInButton).click();
        return this;                        // still on the login page
    }

    public String errorText() {
        return waits.visible(errorBanner).getText();
    }
}
Load contracts beat isLoaded() checks. Because the wait lives in the constructor, holding an OrdersPage instance is proof the Orders screen rendered. Tests can never operate on a half-loaded page, and when a deploy breaks navigation, the failure names the exact transition instead of a downstream symptom.

Why this blueprint skips PageFactory

Older tutorials lean on the page factory pattern: annotate fields with @FindBy, call PageFactory.initElements(driver, this) in the constructor, and let reflection inject a lazy proxy for every element. I do not use it in new frameworks, and mainstream Selenium guidance has quietly moved the same way. Three concrete reasons.

Caching surprises. Add @CacheLookup and the proxy pins the first WebElement it resolves; the moment a React or Vue frontend re-renders that node, you get StaleElementReferenceException in seemingly random tests. Leave caching off and the proxy re-finds on every access, which merely re-implements what a plain By lookup already does.

Reflection magic. Proxies hide when a lookup actually happens, stack traces detour through generated classes, and a mistyped locator only explodes at first use. A private By field behaves exactly like what it is: a value you can inspect in a debugger at the line you are staring at.

No waiting, no parameters. @FindBy resolves with implicit-wait semantics at best, composes poorly with explicit waits, and cannot express a dynamic target like the row containing a given order id. Plain By objects can be built at runtime and passed through wait wrappers. The page factory saves one line per element and bills you back in debugging time.

Inheriting a page factory suite? Delete @CacheLookup first; that alone removes most stale-element flake. Then convert pages to plain By fields as you touch them. Proxy-based and By-based pages coexist fine, so no big-bang rewrite is needed.

Components: build pages out of widgets

The same navbar renders on every authenticated screen, and the same table widget renders orders, invoices, and students. Copy those locators into every page and you will eventually maintain fifteen definitions of one search box. A component is a small class that owns one widget wherever it appears. Pages receive components by composition, never by inheritance, and each component scopes every lookup under a root locator passed to its constructor, so two tables on the same page can never cross-match.

public class DataTable {
    private final Waits waits;
    private final By root;

    public DataTable(WebDriver driver, By root) {
        this.waits = new Waits(driver);
        this.root  = root;
    }

    private WebElement table() { return waits.visible(root); }

    public int rowCount() {
        return table().findElements(By.cssSelector("tbody tr")).size();
    }

    public List<String> column(int oneBasedIndex) {
        String cells = "tbody tr td:nth-child(" + oneBasedIndex + ")";
        return table().findElements(By.cssSelector(cells))
                      .stream().map(WebElement::getText).toList();
    }

    public WebElement rowContaining(String text) {
        return table().findElements(By.cssSelector("tbody tr")).stream()
                      .filter(row -> row.getText().contains(text))
                      .findFirst()
                      .orElseThrow(() -> new NoSuchElementException("no row matching: " + text));
    }

    public void clickInRow(String rowText, By control) {
        rowContaining(rowText).findElement(control).click();
    }
}

A page composes components and speaks business language above them. The OrdersPage below assumes the standard thead-plus-tbody shape used by the tables on the practice pages at https://app.thetestingacademy.com/playwright/, which are a convenient place to rehearse row-scoped locators against a live grid before pointing the framework at your own product.

public class OrdersPage {
    private final WebDriver driver;
    private final Waits waits;
    private final NavBar nav;
    private final DataTable orders;

    private final By heading   = By.cssSelector("[data-test='orders-title']");
    private final By searchBox = By.id("order-search");

    public OrdersPage(WebDriver driver) {
        this.driver = driver;
        this.waits  = new Waits(driver);
        this.nav    = new NavBar(driver);
        this.orders = new DataTable(driver, By.id("orders-table"));
        waits.visible(heading);                 // load contract: fail here, not three steps later
    }

    public OrdersPage searchFor(String term) {
        WebElement box = waits.visible(searchBox);
        box.clear();
        box.sendKeys(term);
        return this;
    }

    public String statusOf(String orderId) {
        return orders.rowContaining(orderId)
                     .findElement(By.cssSelector("[data-test='status']"))
                     .getText();
    }

    public OrderDetailsPage openOrder(String orderId) {
        orders.clickInRow(orderId, By.linkText("View"));
        return new OrderDetailsPage(driver);
    }

    public int visibleOrders() { return orders.rowCount(); }

    public ProfilePage openProfile() { return nav.openProfile(); }
}

Notice what the page adds over the raw component: vocabulary. clickInRow plus a locator becomes openOrder(orderId). Tests never learn that orders happen to live in a table at all, which is exactly why a redesign from table to cards costs one component swap and zero test edits. That insulation is the entire economic argument for the selenium page object model.

BaseTest vs BasePage: what belongs where

Both classes exist to kill duplication, and both become dumping grounds without a written boundary. The selenium base test owns lifecycle and evidence: it opens the browser, hands it to the test, and cleans up with artifacts on failure. A BasePage, if you keep one at all, owns shared page mechanics and nothing more.

ConcernBaseTestBasePage
Driver creation and quit (via DriverFactory)YesNever
Screenshot on failure, report listenersYesNever
Seeding data through the API before UI stepsYesNever
Protected driver and Waits fields for subclassesNoYes
Shared load-contract helper (waitForAnchor)NoYes
Assertions and soft-assert factoriesYes, inside the tests themselvesNever
Application locators or URLsNeverNever; they belong to concrete pages
public abstract class BaseTest {
    protected WebDriver driver;

    @BeforeMethod(alwaysRun = true)
    public void startDriver() {
        driver = DriverFactory.create(Config.browser());
        driver.manage().window().setSize(new Dimension(1440, 900));
    }

    @AfterMethod(alwaysRun = true)
    public void stopDriver(ITestResult result) {
        if (!result.isSuccess()) {
            Screenshots.capture(driver, result.getMethod().getMethodName());
        }
        DriverFactory.quit();
    }
}

Keep the selenium base test short enough to read in ten seconds. When it grows a database helper, a random-email generator, and a retry wrapper, you are watching a God class form in real time; push those into core or into dedicated data builders. The same discipline applies to BasePage, which brings us to the failure gallery.

A flow that reads like English

public class OrdersTests extends BaseTest {

    @Test(groups = "smoke")
    public void refundedOrderShowsRefundedStatus() {
        String status = new LoginPage(driver)
                .open()
                .signInAs("[email protected]", "correct-horse-42")
                .searchFor("ORD-1042")
                .statusOf("ORD-1042");

        assertThat(status).isEqualTo("Refunded");
    }
}

Every line is business vocabulary: the actor, the data, the expected outcome. No locator, no wait, no driver call. When this test fails, the stack trace lands in one of two places, an assertion (candidate product bug) or a single page method (locator or timing problem), and that clean split is the fastest triage mechanism a selenium framework design can buy you. The fluent chain is a side effect of the return-a-page rule, not a feature you bolt on afterward.

Anti-patterns and their invoice

Each of these looks like a harmless shortcut on the day it is written. The table is the invoice that arrives six months later.

Anti-patternWhat it looks likeThe concrete cost
God BasePageEighty static helpers (jsClick, scrollTo, switchFrame, randomEmail) inherited by every pageEvery page depends on everything, so one edit risks the whole suite; behavior is findable only by scrolling base; merge conflicts concentrate in a single file
Assertions inside pagesverifyOrderVisible() asserting deep inside OrdersPageThe page hard-codes one scenario's expectation, so negative paths need cloned methods; failure reports speak in page internals instead of business intent
Public WebElementspublic WebElement signInButton resolved in the constructorTests bypass waits and load contracts and click elements directly; fields go stale after re-renders; the page can never change its DOM without breaking callers
XPath soup//div[3]/table//tr[7]/td[2]/a as the default locator idiomAny layout shift breaks it; nobody understands the selector, so failures get patched with sleeps; prefer ids and data-test hooks, then CSS, and keep XPath for text or axis queries only

None of this requires clever code. It requires saying no: no assertion in a page, no locator in a test, no widget logic pasted twice, no helper added to base because it was convenient. Hold those boundaries and the page object model stays what it was meant to be, a one-to-one map from screens to classes that a new SDET can navigate on their first day.

5. Data, configuration, and the TestNG machinery

Locators and waits get the attention, but frameworks rarely die there. They die in the plumbing: a base URL hardcoded in forty files, one shared test user that two parallel threads fight over, a build that quietly retried the same broken test until it passed. This section builds that plumbing properly: Selenium configuration you can trust across environments, data driven testing in Selenium that survives parallel execution, and the TestNG machinery (listeners, retry analyzers, groups) that turns a pile of test classes into an operable suite.

Configuration: one base file, one overlay per environment

The rule is short: code never contains environment facts. URLs, timeouts, browser flags, and API hosts live in config.properties; each environment then gets a small overlay file that overrides only what differs. You pick the environment with a JVM flag, -Denv=ci, and nothing else changes.

Keyconfig.properties (base)config-staging.propertiesconfig-ci.properties
base.urlhttp://localhost:8080https://staging.shop.internalhttps://app.thetestingacademy.com/playwright/
api.urlhttp://localhost:8787https://staging-api.shop.internalhttps://ci-api.shop.internal
browser.headlessfalse(inherits false)true
wait.explicit.seconds10(inherits 10)20
grid.url(empty = local driver)(inherits)http://selenium-hub:4444

Load order carries the design: base first, overlay second, and a -D system property beats both, so you can flip one value for a single run without editing files. Then wrap everything in a typed reader so the rest of the framework never touches raw property strings. A missing key should kill the run at startup with a clear message, not surface as a NullPointerException forty minutes into regression. The overlays stay tiny by design: reading config-ci.properties tells you, in five lines, exactly how CI differs from a laptop.

public final class Config {
    private static final Properties PROPS = load();

    private Config() {}

    private static Properties load() {
        String env = System.getProperty("env", "local");
        Properties merged = new Properties();
        try (InputStream base = Config.class.getResourceAsStream("/config.properties");
             InputStream overlay = Config.class.getResourceAsStream("/config-" + env + ".properties")) {
            merged.load(base);
            if (overlay != null) {
                merged.load(overlay);   // overlay wins on duplicate keys
            }
        } catch (IOException e) {
            throw new IllegalStateException("Cannot load config for env=" + env, e);
        }
        return merged;
    }

    // Typed accessors: the only way the framework reads config
    public static String baseUrl()        { return get("base.url"); }
    public static String apiUrl()         { return get("api.url"); }
    public static boolean headless()      { return Boolean.parseBoolean(get("browser.headless")); }
    public static Duration explicitWait() { return Duration.ofSeconds(Long.parseLong(get("wait.explicit.seconds"))); }

    // Secrets NEVER come from files. Env vars only.
    public static String secret(String envVar) {
        String value = System.getenv(envVar);
        if (value == null || value.isBlank()) {
            throw new IllegalStateException("Missing required env var: " + envVar);
        }
        return value;
    }

    private static String get(String key) {
        String override = System.getProperty(key);   // -Dbase.url=... wins
        if (override != null) return override;
        String value = PROPS.getProperty(key);
        if (value == null) throw new IllegalStateException("Missing config key: " + key);
        return value;
    }
}

Two details in that class carry most of the value. First, every accessor is typed: explicitWait() returns a Duration and headless() returns a boolean, so a typo like "ture" fails loudly in one place instead of half-parsing in five. Second, secret() reads only environment variables. Passwords, API tokens, and seeding keys never enter a properties file, because properties files get committed, zipped into build artifacts, and pasted into tickets. In CI, secrets come from the pipeline's secret store; locally, from your shell profile.

Secrets in files are already leaked. The moment a credential lands in config.properties, assume it is in git history, in every CI artifact, and in a screenshot on someone's desk. Rotate it and move it to an environment variable. A Selenium configuration layer that reads secrets from files is a security incident on a timer.

Data driven testing that survives parallel runs

Data driven testing in Selenium almost always starts with the TestNG DataProvider, and that is the right instinct: it feeds one test method many rows, reports each row as its own result, and runs rows concurrently when you declare parallel = true. Keep the data outside the code. A CSV in src/test/resources/data versions together with the tests that consume it and lets non-coders review the cases:

public class CheckoutData {

    // shipping.csv:  method,region,expectedCost
    // Standard,IN,49.00  |  Express,IN,149.00  |  Standard,US,9.99 ...
    @DataProvider(name = "shippingOptions", parallel = true)
    public static Object[][] shippingOptions() throws IOException {
        List<Object[]> rows = new ArrayList<>();
        try (BufferedReader reader =
                 Files.newBufferedReader(Path.of("src/test/resources/data/shipping.csv"))) {
            reader.readLine();   // skip header
            String line;
            while ((line = reader.readLine()) != null) {
                String[] c = line.split(",");
                rows.add(new Object[] { c[0], c[1], new BigDecimal(c[2]) });
            }
        }
        return rows.toArray(new Object[0][]);
    }
}

public class CheckoutShippingTest extends BaseTest {

    @Test(dataProvider = "shippingOptions", dataProviderClass = CheckoutData.class,
          groups = {"regression"})
    public void shippingCostAppearsInOrderSummary(String method, String region, BigDecimal cost) {
        OrderSummaryPage summary = new CheckoutPage(driver)
                .selectRegion(region)
                .selectShipping(method)
                .continueToSummary();
        Assert.assertEquals(summary.shippingCost(), cost);
    }
}

With parallel = true, TestNG executes rows on a dedicated pool sized by data-provider-thread-count in the suite XML, separate from the method-level pool. That is exactly where the classic mistake detonates: shared static users. If every row logs in as [email protected], two threads now share one cart, one order history, one set of profile settings. Thread A empties the cart while thread B asserts on its contents, and you get a failure nobody can reproduce serially. In a parallel suite, every piece of mutable test data must be owned by exactly one thread.

The fix is a factory that mints unique data per test instead of a fixture everyone shares:

public final class UserFactory {
    private static final AtomicLong SEQ = new AtomicLong(System.currentTimeMillis());

    public static TestUser uniqueStudent() {
        long id = SEQ.incrementAndGet();   // unique across threads AND consecutive runs
        return new TestUser("student" + id + "@qa.shop.test",
                            "Str0ng!" + id,
                            "Student " + id);
    }
}

A timestamp-seeded AtomicLong is enough: no database coordination, no UUID soup in your reports, collision-free within and across runs. Unique data also dissolves the cleanup problem: you never restore a shared user's state, you just let disposable users accumulate and purge them on a schedule. JSON-backed providers follow the same shape as the CSV one, just mapped through Jackson into typed objects, which pays off once a row has more than four fields.

Seed state through the API, not the UI

Every minute a UI test spends creating its own preconditions is extra flake surface. If the scenario is "a paid order shows an invoice link", the order is a precondition, not the behavior under test, so create it with an HTTP call and spend the browser only on the assertion that matters. A RestAssured-style call in @BeforeMethod does it:

public class OrderInvoiceTest extends BaseTest {
    private TestUser user;
    private String orderId;

    @BeforeMethod(alwaysRun = true)
    public void seedPaidOrder() {
        user = UserFactory.uniqueStudent();
        orderId = given()
                .baseUri(Config.apiUrl())
                .header("Authorization", "Bearer " + Config.secret("SEED_API_TOKEN"))
                .contentType(ContentType.JSON)
                .body(Map.of(
                    "email", user.email(),
                    "status", "PAID",
                    "items", List.of(Map.of("sku", "COURSE-PW-101", "qty", 1))))
            .when()
                .post("/api/test-data/orders")
            .then()
                .statusCode(201)
                .extract().path("orderId");
    }

    @Test(groups = {"smoke", "regression"})
    public void paidOrderShowsInvoiceLink() {
        OrdersPage orders = loginAs(user).openOrders();
        Assert.assertTrue(orders.row(orderId).hasInvoiceLink());
    }
}

This turns a ninety-second signup-login-shop-checkout preamble into a 300 ms POST, and it decouples the test from unrelated screens: a checkout redesign should not break every orders test. Keep seeding endpoints test-only, authenticated, and disabled in production. If your application has no seeding endpoint, that is a conversation with developers worth more than any framework trick in this article.

Practice target. The pattern is framework-agnostic: the practice pages at https://app.thetestingacademy.com/playwright/ give you stable forms, tables, and frames to point a data-driven suite at while you get the provider and factory mechanics right, before you also have to fight your own application's quirks.

TestNG listeners: evidence on failure, automatically

TestNG listeners are hooks the runner invokes around the suite lifecycle, and the first one every Selenium suite needs is an ITestListener that captures a screenshot at the moment of failure, while the driver still holds the broken page:

public class ScreenshotListener implements ITestListener {

    @Override
    public void onTestFailure(ITestResult result) {
        Object instance = result.getInstance();
        if (!(instance instanceof BaseTest base)) return;

        WebDriver driver = base.getDriver();          // thread-local driver from section 3
        if (!(driver instanceof TakesScreenshot camera)) return;

        try {
            byte[] png = camera.getScreenshotAs(OutputType.BYTES);
            Path target = Path.of("target", "screenshots",
                    result.getMethod().getMethodName() + "_" + System.currentTimeMillis() + ".png");
            Files.createDirectories(target.getParent());
            Files.write(target, png);
            System.out.println("[screenshot] " + target.toAbsolutePath());
        } catch (Exception e) {
            // A listener must never mask the real failure with its own exception
            System.err.println("Screenshot failed: " + e.getMessage());
        }
    }
}

Register it once in the suite XML (below) so it applies everywhere, or with @Listeners on a base class. Keep listeners dumb: no assertions, no clicking, and they swallow their own exceptions, because a listener that throws buries the failure you actually care about. The same interface offers onTestStart for logging and onFinish for summaries; report frameworks plug into the identical TestNG listeners mechanism, so this wiring is reusable.

Retries: once, in CI only, and loudly

A Selenium retry analyzer is TestNG's IRetryAnalyzer: when a test fails, the runner asks it whether to re-execute. Here is the honest version:

public class CiRetryAnalyzer implements IRetryAnalyzer {
    private static final boolean IS_CI = System.getenv("CI") != null;
    private static final int MAX_RETRIES = 1;
    private int attempts = 0;

    @Override
    public boolean retry(ITestResult result) {
        if (!IS_CI) return false;                // never retry on a developer machine
        if (attempts >= MAX_RETRIES) return false;
        attempts++;
        System.out.printf("[flaky] %s failed, retrying (attempt %d)%n",
                result.getMethod().getMethodName(), attempts + 1);
        return true;
    }
}

// Apply globally instead of annotating every @Test:
public class RetryTransformer implements IAnnotationTransformer {
    @Override
    public void transform(ITestAnnotation a, Class<?> c, Constructor<?> ctor, Method m) {
        a.setRetryAnalyzer(CiRetryAnalyzer.class);
    }
}

Three deliberate choices. Retry once, not three times: a test that needs two retries is not flaky, it is broken. Never retry locally: at your desk you want the raw failure, because your desk is where debugging happens. And print a [flaky] marker on every retried test, then have CI collect those markers into a report someone reviews weekly. A test that trips the retry twice in one week gets moved into a wip quarantine group, out of the PR gate, until it is genuinely fixed.

Be honest about what retries are: anesthetic, not medicine. A test that fails and then passes had a race somewhere, in the test or in the application, and retry-and-forget converts that signal into silence. Teams have shipped real intermittent bugs (a double-submitted checkout, a stale cache read) because the pipeline retried past them for months. Use the Selenium retry analyzer to keep the pipeline usable while you fix root causes, and treat any [flaky] marker that survives two weeks as a bug in its own right.

Soft asserts: many checks, one page state

Hard asserts stop the test at the first mismatch, which is correct whenever the next step depends on the previous one. But when you are verifying five independent facts about one rendered order summary, stopping at the first hides the other four and costs you a full re-run per discovery. SoftAssert collects everything and fails once, at assertAll():

SoftAssert soft = new SoftAssert();
soft.assertEquals(summary.itemCount(), 2, "item count");
soft.assertEquals(summary.shippingMethod(), "Express", "shipping method");
soft.assertEquals(summary.total(), "Rs 1,498", "grand total");
soft.assertAll();   // fails here, listing every mismatch at once
SituationAssert styleWhy
Login succeeded, page navigatedHardEverything after depends on it; continuing produces noise failures
Five fields of one order summarySoftIndependent facts; you want the complete list in one run
Seeding API returned 201HardNo point driving the UI against state that does not exist
Labels, badges, totals on one screenSoftParallel checks of a single rendered state

The rule of thumb: soft asserts for parallel facts about one state, hard asserts for anything a later step depends on. Never soft-assert a navigation, and never let a soft assert leak past its test by forgetting assertAll().

Groups and suite files: smoke on every PR, regression nightly

Tag tests with groups (groups = {"smoke", "regression"} as in the examples above), then let XML decide what runs where. Same code, two suites: a smoke wall under ten minutes for every pull request, the full regression overnight. Keep the group vocabulary tiny (smoke, regression, wip is plenty); once tests carry six ad-hoc tags each, nobody can say what a green smoke run actually guarantees.

<!-- smoke.xml : PR gate -->
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="smoke" parallel="methods" thread-count="4" data-provider-thread-count="4">
  <listeners>
    <listener class-name="framework.listeners.ScreenshotListener"/>
    <listener class-name="framework.listeners.RetryTransformer"/>
  </listeners>
  <test name="smoke-pass">
    <groups>
      <run>
        <include name="smoke"/>
        <exclude name="wip"/>
      </run>
    </groups>
    <packages>
      <package name="tests.*"/>
    </packages>
  </test>
</suite>

The regression suite is the same file with <include name="regression"/> and a higher thread count. Notice the suite XML is also where parallelism and the listener wiring live, so a single file describes what runs, how wide, and with which machinery: that is your Selenium configuration for execution, versioned next to the code.

TestNG or JUnit 5 for this job?

JUnit 5 is a superb unit-test framework and its extension model is elegant. For browser suites specifically, TestNG still holds the ergonomic edge, because everything this section used ships in the box:

ConcernTestNGJUnit 5
ParallelismDeclarative in suite XML: methods/classes/tests, plus a separate data-provider pooljunit-platform.properties + @Execution; no separate pool for parameterized data
Data-driven testsTestNG DataProvider: any Object[][], reusable provider classes, parallel = true@ParameterizedTest with sources; clean for inline values, more ceremony for external files
ListenersITestListener, ISuiteListener, IAnnotationTransformer, registered suite-wide in XMLExtensions (TestWatcher etc.), registered per class or via service loader
RetriesIRetryAnalyzer built inNothing built in; third-party extension or build-tool reruns (e.g. Surefire rerunFailingTestsCount)
Suite slicingGroups + XML include/exclude, no rebuild@Tag + build-tool filtering
Natural homeLarge end-to-end and integration suitesUnit and component tests, modern extension ecosystem

If your organization standardized on JUnit 5, you can absolutely build this architecture there; every gap has a known extension. But you will be assembling machinery that TestNG hands you assembled, which is why this blueprint, like most large Selenium data driven testing setups, standardizes on TestNG: the DataProvider, the retry analyzer, the listener wiring, and the suite XML are the four pieces the next two sections lean on.

6. Scale: Selenium Grid, Docker, and the cloud

A serial Selenium suite dies of its own success. Every sprint adds tests, every test adds minutes, and one quarter later the nightly run finishes after morning standup. You cannot shrink the suite forever; you have to run browsers in parallel, and past four or five concurrent browsers a single machine becomes the bottleneck. Selenium Grid is the answer built into the ecosystem: test code stays exactly where it is, browser sessions move to a fleet of machines, and the only change your framework needs is a URL.

The Selenium 4 Grid architecture

Selenium 3's hub was one opaque process that did everything and, under load, fell over without explaining itself. The Selenium 4 Grid architecture broke that monolith into components with single responsibilities. This is not certification trivia; every scaling and debugging decision you make on a grid maps onto one of these boxes.

ComponentJob
RouterThe single entry point on port 4444. Sends new-session requests to the queue and commands for running sessions straight to the owning Node.
DistributorReads queued requests, matches requested capabilities against Node stereotypes, picks a free slot, orders that Node to create the session.
Session MapThe lookup table: session id to Node address. Consulted by the Router on every command.
New Session QueueHolds session requests until a matching slot frees up or the request times out.
NodeOwns actual browsers. Advertises stereotypes ("4 slots of Chrome 126 on Linux") and executes the sessions.
Event BusInternal message channel on ports 4442/4443. Nodes register and heartbeat over it, so components find each other without HTTP polling.

Follow one test through the grid. Your framework sends POST /session to the Router. The Router makes no placement decision; it parks the request in the New Session Queue. The Distributor polls that queue, compares the requested capabilities (browser, version, platform) against the stereotypes every registered Node advertised over the Event Bus, and the moment a matching slot is free it instructs that Node to start the browser. The Node launches Chrome through its local driver; the Distributor then records the session id and that Node's address in the Session Map, and the session response travels back to your test. Here is the part people miss: after creation, the Distributor and the queue are out of the loop entirely. Every subsequent findElement, click, and screenshot hits the Router, which looks up the owning Node in the Session Map and proxies the command straight to it. New sessions are scheduled; running sessions are point-to-point.

That flow decodes the classic symptoms. A suite that hangs at startup but runs fast once going is queue starvation: add slots. Sessions dying mid-test while the hub looks healthy means a Node crashed or lost its Event Bus heartbeat: investigate that machine, not the hub. A capability typo (requesting browserVersion=126 when nodes advertise 127) sits in the queue until SE_SESSION_REQUEST_TIMEOUT expires, because nothing will ever match it.

Three ways to deploy the same jar

ModeWhat runs whereRight forLimit
StandaloneOne process plays every role, browsers includedDeveloper machines, single-runner CI jobsCores and RAM of that one machine
Hub and nodeHub bundles Router, Distributor, Session Map, Queue, Event Bus; Nodes register to itMost teams, roughly 5 to 100 parallel sessionsHub is a single point of failure
Fully distributedEach component its own process behind a load balancerPlatform teams offering grid as an internal service, hundreds of sessionsYour ops maturity, not Selenium

Standalone is underrated: java -jar selenium-server-4.27.0.jar standalone gives any developer a personal grid at localhost:4444 in ten seconds, with the same UI and the same RemoteWebDriver code path as production. Adopting it locally means zero code difference between a laptop run and the CI fleet, which is exactly the kind of dev-prod parity that kills "works on my machine" arguments.

Selenium Grid on Docker

Nobody should hand-install browsers on grid machines anymore. The official Selenium Grid Docker images pin browser, driver, and OS into one immutable tag, which deletes the entire "driver version mismatch on node 7" category of failure. One compose file is the whole installation:

# grid/docker-compose.yml
services:
  selenium-hub:
    image: selenium/hub:4.27.0
    container_name: selenium-hub
    ports:
      - "4442:4442"   # event bus publish
      - "4443:4443"   # event bus subscribe
      - "4444:4444"   # router + grid UI
    environment:
      - SE_SESSION_REQUEST_TIMEOUT=300

  chrome:
    image: selenium/node-chrome:4.27.0
    shm_size: 2gb                    # see the warning below
    depends_on: [selenium-hub]
    environment:
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
      - SE_NODE_MAX_SESSIONS=4
      - SE_NODE_OVERRIDE_MAX_SESSIONS=true
      - SE_VNC_NO_PASSWORD=1
    # no container_name, no fixed host ports: this service gets scaled

  firefox:
    image: selenium/node-firefox:4.27.0
    shm_size: 2gb
    depends_on: [selenium-hub]
    environment:
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
      - SE_NODE_MAX_SESSIONS=2

Bring it up sized per run: docker compose up -d --scale chrome=6 --scale firefox=3 yields a 30-slot grid (6 Chrome nodes with 4 slots each, 3 Firefox nodes with 2). Two compose rules make scaling legal: a scaled service must not declare container_name and must not bind fixed host ports, otherwise the second replica collides with the first. Debugging is built in: every Grid 4 node image ships VNC and noVNC, so opening a node's port 7900 in a plain browser tab shows the live session, and the Grid UI at localhost:4444/ui shows queue depth and slot usage in real time.

The shm-size gotcha. Chrome renders through /dev/shm, and Docker's default shared memory is 64 MB. Under real page weight the renderer dies and Selenium reports session deleted because of page crash or tab crashed, which teams misread as flaky tests for weeks. Set shm_size: 2gb on every browser node (or mount /dev/shm as a volume). That one line is the highest-value setting in any selenium grid docker deployment.

Autoscaling instead of guessing

A static grid sized for release week burns money every night; sized for quiet nights, it queues on release day. Grid 4 makes elasticity tractable because the New Session Queue is observable. On Kubernetes the pattern is: hub components as a deployment, plus an event-driven autoscaler watching queue depth per capability and creating node pods to match (KEDA ships a Selenium Grid scaler that does precisely this, down to zero pods when idle). Give nodes SE_DRAIN_AFTER_SESSION_COUNT=1 so each pod exits cleanly after one session instead of being killed mid-test. This is the same elasticity selenium cloud vendors operate internally; running it yourself trades their invoice for your cluster bill.

Pointing the framework at the grid

The client side is deliberately anticlimactic: swap the constructor. Everything built in earlier sections (waits, page objects, listeners, screenshot hooks) is untouched, because it all talks to the WebDriver interface, never the implementation.

// DriverFactory.java - the only class that knows the grid exists
String gridUrl = System.getProperty("grid.url", "http://localhost:4444");
String browser = System.getProperty("browser", "chrome");

MutableCapabilities options = switch (browser) {
    case "firefox" -> new FirefoxOptions();
    case "edge"    -> new EdgeOptions();
    default          -> new ChromeOptions();
};
options.setCapability("se:name", "checkout-guest-card-payment");

WebDriver driver = new RemoteWebDriver(new URL(gridUrl), options);
driver.get("https://app.thetestingacademy.com/playwright/");
// ...same page objects, same waits, same assertions as a local run
driver.quit();

Capabilities are your half of the matching contract: browserName, browserVersion, and platformName must be a subset of some node stereotype or the request queues until timeout. Setting se:name from the test method is worth the one line, because it labels the session in the Grid UI and any recording. Cross browser testing then stops being a code problem and becomes a data problem: the same suite runs with -Dbrowser=firefox or a TestNG parameter, and the Distributor routes each request to whichever nodes advertise that browser.

Now the arithmetic that justifies the infrastructure. A 600-test regression averaging 3 minutes per test is 1,800 browser-minutes: run sequentially, that is 30 hours, dead on arrival. With 60 parallel sessions (15 Chrome nodes at 4 slots each, or 60 single-slot pods) it is ten waves of 3 minutes: roughly 30 minutes wall clock, call it 35 with queueing and retries. Same suite, same code, lunch-break feedback. The precondition is the test independence engineered earlier: 60-way parallelism over shared users or mutable fixtures produces 60-way carnage, not speed.

Two endpoint details worth memorizing. Grid 4 accepts commands at both / and the legacy /wd/hub path, so older framework configs keep working unchanged. And before any suite starts, poll GET /status until value.ready is true: it flips only after nodes have registered, which is exactly what naive sleep 10 startup scripts get wrong.

Wiring it into CI

In CI the grid should be cattle: created for the run, destroyed after it, never a pet hub rotting on a forgotten VM. On GitHub Actions the compose file above becomes a service the job boots itself:

# .github/workflows/nightly-regression.yml
name: nightly-regression

on:
  schedule:
    - cron: "30 21 * * *"        # 03:00 IST
  workflow_dispatch:

jobs:
  grid-regression:
    runs-on: ubuntu-latest
    timeout-minutes: 90
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "21"
          cache: maven

      - name: Boot Selenium Grid
        run: |
          docker compose -f grid/docker-compose.yml up -d \
            --scale chrome=4 --scale firefox=2

      - name: Wait until the grid is ready
        run: |
          timeout 90 bash -c \
            'until curl -sf http://localhost:4444/status | jq -e ".value.ready == true" > /dev/null; do sleep 2; done'

      - name: Run the regression suite
        run: |
          mvn -B test -DsuiteXml=regression.xml \
            -Dgrid.url=http://localhost:4444 -Dthreads=20

      - name: Upload reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: regression-reports
          path: target/surefire-reports

      - name: Dump grid logs on failure
        if: failure()
        run: docker compose -f grid/docker-compose.yml logs --tail=300

      - name: Tear down
        if: always()
        run: docker compose -f grid/docker-compose.yml down -v

Two details in that workflow do the heavy lifting. The readiness poll before mvn test prevents the first wave of tests racing node registration and dying with connection refused. And the if: always() teardown plus failure-only log dump means cancelled runs do not leak containers and red runs never vanish without diagnostics.

Cloud grids: rent versus run

A selenium grid docker setup covers Linux Chrome, Firefox, and Edge superbly, and that is where it stops. When the risk profile demands Safari, real iOS and Android hardware, or last year's browser versions, you either rack a device lab or rent one. Commercial cloud grids (BrowserStack and Sauce Labs are the familiar examples) sell exactly that: point RemoteWebDriver at their endpoint with an access key, request from thousands of OS, browser, and device combinations, and get video, console logs, and tunnels into pre-prod environments as table stakes.

The trade-offs are structural, not vendor gossip. Every WebDriver command becomes an HTTP round trip over the public internet, so 80 ms of latency multiplied by two thousand commands quietly adds minutes to each test; a selenium cloud suite will run slower than the same suite against a grid two racks away. Pricing is per parallel slot, so the 60-way math above arrives as an invoice. And your application traffic transits a third party, which your security team must review before anyone pastes an access key into CI. The mature pattern is a split: own the high-volume Linux matrix in Docker where iteration speed matters, rent the long tail where hardware breadth matters.

DimensionLocal machineSelf-hosted grid (Docker)Cloud grid
Parallel ceiling4-5 sessionsYour hardware or cluster budgetYour subscription tier
Speed per commandFastestFast (LAN latency)Slowest (internet latency)
Cost shapeFreeInfra plus your timeRecurring per-slot fee
DebuggingWatch it livenoVNC on port 7900, container logsVideo, logs, network capture built in
MaintenanceNoneImage bumps, capacity planningNone
Browser/OS matrixWhatever is installedLinux Chrome, Firefox, EdgeThousands of combos, real devices

The decision rule for this blueprint: standalone grid on every developer machine, a compose-built Selenium Grid in Docker for CI regression, and a small cloud slice reserved for the cross browser testing your containers cannot host. One suite, three targets, selected by nothing more than a URL and a capability set.

7. Reporting, flake control, and the blueprint checklist

A run that ends with "FAILED: checkoutSpec" and nothing else is a debugging assignment, not a result. The final pillar is the feedback loop: selenium reporting that carries evidence, flake control that protects trust, and the selenium best practices checklist that closes the blueprint.

Choosing the report: Allure vs Extent vs plain TestNG

TestNG's built-in reports (index.html, emailable-report.html) are free and thin: pass and fail with stack traces, no screenshots, no history, no classification. A local debugging console, not a deliverable.

Extent Reports produces a polished single-file HTML dashboard that is easy to mail to a stakeholder. Its limit is history: every run is an island, so "failed 6 of the last 30 runs", the most useful fact about a UI test, must be assembled elsewhere.

Allure treats results as data, and that one decision is why the Allure report Selenium teams standardise on scales where the others stall. Tests emit small JSON files into allure-results/; the site is generated afterwards. CI can therefore merge ten parallel shards into one report, keep a history trend across builds, and classify failures through categories.json: assertions as product defects, TimeoutException as automation debt, session errors as infrastructure.

CapabilityTestNG defaultExtent ReportsAllure
Setup costZeroLowMedium
Step-level narrativeNoManual log callsAutomatic via @Step
Screenshots / artefactsManual linksEmbedded manually@Attachment, inline
History trendNoNoYes, first-class
Failure categoriesNoNocategories.json rules
Merging parallel shardsNoAwkwardNative
Best fitLocal debuggingEmailed reportsCI at scale

Two annotations carry most of the value. @Step on page object methods makes the report a narrative: a failed test reads "Open cart, Apply coupon SAVE20, Place the order" with the failing step highlighted. @Attachment turns any byte array or string a method returns into an artefact on the test. The history trend is the piece of the Allure report Selenium teams lose most often: it only renders when CI copies the previous build's history/ folder into allure-results/ before generating.

public class CheckoutPage extends BasePage {

    @Step("Apply coupon {code} on the checkout page")
    public CheckoutPage applyCoupon(String code) {
        type(couponInput, code);
        click(applyButton);
        return this;
    }

    @Step("Place the order")
    public OrderConfirmationPage placeOrder() {
        click(placeOrderButton);
        return new OrderConfirmationPage(driver);
    }
}

The minimum evidence kit: screenshot, page source, console logs

Selenium reporting is only as good as the evidence behind it. Attach three artefacts to every failure: the screenshot shows what the user saw; the page source shows what the DOM actually contained, settling the "element exists but an overlay covers it" mystery; the console log exposes JavaScript errors and failed network calls, usually the reason a page never reached the expected state. A listener collects all three so evidence never depends on memory:

public class FailureEvidenceListener implements ITestListener {

    private static final Logger log =
            LoggerFactory.getLogger(FailureEvidenceListener.class);

    @Override
    public void onTestFailure(ITestResult result) {
        WebDriver driver = DriverFactory.getDriver();  // same ThreadLocal the tests use
        if (driver == null) {
            log.warn("No driver on this thread, skipping evidence capture");
            return;
        }
        String name = result.getMethod().getMethodName();
        screenshot(name, driver);
        pageSource(name, driver);
        consoleLogs(name, driver);
    }

    @Attachment(value = "{name} screenshot", type = "image/png")
    byte[] screenshot(String name, WebDriver driver) {
        return ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
    }

    @Attachment(value = "{name} page source", type = "text/html")
    String pageSource(String name, WebDriver driver) {
        return driver.getPageSource();
    }

    @Attachment(value = "{name} console logs", type = "text/plain")
    String consoleLogs(String name, WebDriver driver) {
        try {
            StringBuilder out = new StringBuilder();
            for (LogEntry e : driver.manage().logs().get(LogType.BROWSER)) {
                out.append(e.getLevel()).append(' ')
                   .append(e.getMessage()).append('\n');
            }
            return out.length() == 0 ? "Console clean" : out.toString();
        } catch (Exception e) {
            return "Console logs unavailable: " + e.getMessage();
        }
    }
}

Register it once in testng.xml. The driver resolves from the tests' own ThreadLocal factory, so capture is parallel-safe: each failing test attaches its own browser's state.

Console logs are not universal. LogType.BROWSER works on Chromium browsers; Firefox and Safari do not expose the same channel. Guard the call so the listener never throws, and treat capture as best-effort elsewhere.

SLF4J logging, one voice per layer

The report says which step failed; the log says what happened around it. Standardise on SLF4J over Logback, one altitude per layer: the driver factory logs session facts once (browser, version, headless, grid node), page objects log intent at info ("Applying coupon SAVE20"), waits and locator internals log at debug, data builders log the ids they created. A green run reads like a table of contents; a red run, like a witness statement.

Flake control: five causes, five fixes

Most Selenium flaky tests trace back to five causes, and none of them is "Selenium is flaky". Commands execute immediately against a browser that renders asynchronously, so every unguarded gap is a race you eventually lose. Diagnose first, then fix:

CauseTypical symptomFix
Missing or wrong waits (no auto-wait)NoSuchElementException that passes on re-runExplicit waits on state; implicit zero; delete Thread.sleep
Shared test dataPasses alone, fails in the full suitePer-test data via API builders with unique keys
Grid node varianceFails only on node 3, or only at 02:00Containerised nodes, pinned versions, fixed window, locale, timezone
Stale elementsStaleElementReferenceException after re-renderRe-locate at the moment of use; never cache WebElement
Animation timingClicks land on moving targetsDisable animations via injected CSS; wait for a stable rect across two polls

Then measure instead of debating. Flakiness rate is failures divided by attempts on identical code: rerun the suite N times nightly against one build (the practice pages at https://app.thetestingacademy.com/playwright/ make a stable calibration target), and any test that both passes and fails on the same commit is flaky at rate f/N. Set a budget, say 1 percent. Anything above it moves into a quarantine group running as a separate, non-blocking CI job while the main pipeline stays trustworthy. Graduation is a written rule, for example 20 consecutive green runs; a test that cannot graduate within a month gets rewritten or deleted. This loop stops flake from quietly becoming normal.

Retries are a measurement device, not a cure. A TestNG IRetryAnalyzer is useful for one thing: separating "fails always" from "fails sometimes" and recording it. If retried-then-passed tests report plain green, you are destroying evidence and your flakiness rate becomes unmeasurable.

Where Selenium stands in 2026

The selenium vs playwright question deserves facts, not loyalty. Selenium remains the right call in common situations: a large Java estate whose switching costs no rewrite is guaranteed to repay; a need for the broadest real-browser reach, because the W3C WebDriver protocol is a standard implemented by the browser vendors themselves, covering legacy configurations that matter in enterprise; a working in-house grid you own with no per-minute bill; and language mandates, where Selenium meets a Java, C#, or Python team with first-party bindings.

Evaluate alternatives when the profile differs: a greenfield product, a TypeScript-fluent team, pain concentrated in the wait-and-flake category (auto-waiting engines remove the top row of the table above by design), or a need for trace-style debugging out of the box. Leaving Selenium also gives up the standard protocol, vendor-shipped drivers, and the largest hiring pool in test automation. A fair selenium vs playwright evaluation is empirical: implement your five most painful journeys in both stacks, run each fifty times in CI, and compare stability, runtime, and debugging cost with numbers. The answer is a property of your team, not of the tools.

SituationReasonable default in 2026
Mature Java framework, trained teamStay on Selenium; invest in flake control
Widest browser matrix, legacy includedSelenium over W3C vendor drivers
In-house grid running wellSelenium; the grid is a paid-for asset
Language mandate (Java, C#, Python)Selenium's first-party bindings
Greenfield app, TypeScript-heavy teamTwo-week selenium vs playwright pilot; measure
Most risk below the UIPush tests to API level; driver choice matters less

The blueprint checklist

The article compressed into a selenium best practices checklist. One point per honest tick; below ten, fix the gaps before adding tests.

  1. Drivers come only from a factory with ThreadLocal isolation; no test calls new ChromeDriver().
  2. One config switch flips local to RemoteWebDriver against the grid; no code edits per environment.
  3. Implicit wait is zero; every synchronisation is an explicit wait on a condition; Thread.sleep does not survive review.
  4. Locators live in page objects, prefer ids and data-test attributes, and are named for intent.
  5. Page object methods return page objects and hold no assertions; tests assert, pages act.
  6. Every test builds its own data via API builders with unique keys; nothing shared, nothing order-dependent.
  7. Authentication happens via API or injected session state; UI login is one test, not a prerequisite for all.
  8. Parallelism is set at class level with a thread count derived from measured grid capacity.
  9. CI runs on every pull request: headless, containerised, pinned browser versions, artefacts archived.
  10. A listener attaches screenshot, page source, and console logs to every failure automatically.
  11. Allure (or equivalent) publishes steps, failure categories, and a history trend from CI, merged across shards.
  12. Flakiness rate is measured per test; a quarantine group runs non-blocking, with a written graduation rule.

FAQ

Implicit or explicit waits: what is the final answer? Explicit, with the implicit wait pinned to zero, permanently. The documentation warns that mixing the two produces unpredictable total waits because the timeouts interact. An explicit WebDriverWait also encodes the exact condition you care about (clickable, text present, spinner gone), which one global number cannot express.

Why does the W3C protocol actually matter? Since Selenium 4 removed the legacy JSON Wire protocol, every command travels over a standard the browser vendors implement in their own drivers. That is why a click behaves the same across Chrome, Edge, Firefox, and Safari without per-browser hacks, and why capabilities became strict and namespaced. It also future-proofs the stack: WebDriver BiDi extends the same standards track with event streams such as console and network capture.

Own grid or cloud provider? Run your own when you have platform capacity and steady volume: containerised nodes are cheap at scale and you control versions. Buy cloud capacity for breadth you cannot host (real Safari on macOS, physical devices) or when nobody owns maintenance, because a neglected grid becomes its own flake source. Many teams run a hybrid: in-house grid for pull-request smoke, cloud minutes for the nightly matrix. Decide with arithmetic: engineer-hours of upkeep against the per-minute bill at your volume.

How many parallel threads should I run? Start from capacity, not ambition: threads equal nodes multiplied by sessions per node, budgeting one browser session per 2 vCPUs and 2 GB of RAM. Raise the count while suite time keeps falling; when duration flattens or timeouts appear, step back one notch. Oversubscribed nodes produce slow renders indistinguishable from selenium flaky tests, so tune with the flakiness dashboard open. Independent tests and per-thread drivers are prerequisites, not options.

How do I stop StaleElementReferenceException for good? Stop holding element references across DOM changes: a WebElement is a pointer into a page state that stops existing after a re-render or navigation. Resolve elements at the moment of use, which page object methods calling findElement per action give you for free and PageFactory proxies approximate by re-locating on each access. Where a page re-renders on refresh, wrap the read in ExpectedConditions.refreshed(...) so the retry happens inside the wait. Caching elements in fields buys milliseconds and costs stability.

Companion reading: the Playwright architecture blueprint (the same journey on a WebSocket stack), the XPath master cheat sheet, and the CSS selector cheat sheet.

Practice on the live pages →