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.
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.
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.
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.
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.
| Browser | Driver binary | Maintained by | Native channel behind the W3C surface |
|---|---|---|---|
| Chrome | chromedriver | Chromium team | DevTools (CDP) internals |
| Edge | msedgedriver | Microsoft | DevTools (CDP) internals, Chromium base |
| Firefox | geckodriver | Mozilla | Marionette remote protocol |
| Safari | safaridriver | Apple, ships with macOS | WebKit'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.
| # | Component | Speaks | One-line job |
|---|---|---|---|
| 1 | TestNG / JUnit | Java reflection | Discover, order, and invoke tests; report outcomes |
| 2 | Selenium client (RemoteWebDriver) | Java in, JSON out | Map WebDriver calls to W3C commands, hold the session id |
| 3 | HTTP client (java.net.http) | HTTP/1.1 | Deliver one request per command to the driver's server |
| 4 | Driver executable | W3C in, native out | Validate and translate commands for one specific browser |
| 5 | Browser + renderer | CDP / Marionette / WebKit | Resolve elements, synthesize trusted input, mutate the DOM |
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.
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.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.
| Aspect | Selenium 3 (JSON Wire) | Selenium 4 (W3C WebDriver) |
|---|---|---|
| Status | Project convention, never a standard | W3C Recommendation, implemented by browser vendors |
| Capabilities | desiredCapabilities wish list; unknown keys silently dropped | alwaysMatch / firstMatch; strict validation; vendor keys prefixed |
| Errors | HTTP 200 plus a numeric status field in the body | Real HTTP status codes plus error / message / stacktrace |
| Complex input | Singular endpoints: /moveto, /buttondown | Unified Actions API (/actions) with pointer, key, wheel sources |
| Element geometry | Separate /location and /size | Single /rect endpoint |
| Dialect handshake | Hybrid payload plus response-sniffing in late v3 | One 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.
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.
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.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.
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.
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.
| Type | Kind | What it gives you |
|---|---|---|
SearchContext | interface | findElement / findElements; implemented by both drivers and elements |
WebDriver | interface | the session contract; the type your framework should reference everywhere |
RemoteWebDriver | concrete class | the real implementation speaking W3C over HTTP; also implements JavascriptExecutor, TakesScreenshot |
ChromeDriver / EdgeDriver | subclasses via ChromiumDriver | start and manage a local driver process, then behave exactly like RemoteWebDriver |
FirefoxDriver | subclass | same idea over geckodriver |
ChromeOptions / FirefoxOptions | Capabilities implementations | typed session configuration, serialized into the New Session request |
drivers/ folder in git, delete it: one less thing to break on every browser release.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.
| Option | Where | Why it matters in CI |
|---|---|---|
--headless=new | Chrome, Edge | real browser without a display; far closer to headed behavior than legacy headless |
--window-size=1920,1080 | Chrome, Edge | headless defaults to a small viewport; cures "element not interactable" that only reproduces in CI |
--no-sandbox | Chrome, Edge | required when the container user is root, which most Docker CI images are |
--disable-dev-shm-usage | Chrome, Edge | prevents tab crashes caused by Docker's default 64 MB /dev/shm |
prefs: download.default_directory | Chrome, Edge | deterministic download folder so file assertions do not search the filesystem |
-headless | Firefox | headless Gecko; note the single dash |
browser.download.folderList=2 + browser.download.dir | Firefox | preference pair that pins the download location |
setAcceptInsecureCerts(true) | all | staging environments behind self-signed TLS |
setPageLoadStrategy(EAGER) | all | get() returns at DOMContentLoaded; pair it with explicit waits |
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.
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.// 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.
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="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.
| Bug | What you observe | Root cause and fix |
|---|---|---|
| Leaked drivers | build agent slows; dozens of orphaned chromedriver processes | quit() lived inside test methods, or teardown lacked alwaysRun; move it to @AfterMethod(alwaysRun = true) |
close() used as teardown | tests pass, memory climbs, sessions linger | close() shuts one window and leaves the session and process alive; teardown must call quit() |
| Stale ThreadLocal after retry | retried test dies instantly with NoSuchSessionException | quit() ran but remove() did not, so the pooled thread kept a dead reference; always pair them in a finally |
| Driver in a field initializer | browsers open at suite start, before any test runs | TestNG instantiates all test classes up front; construct drivers only inside @BeforeMethod via the factory |
| Static shared driver | interleaved navigation, wrong-page screenshots under parallel | one 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.
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.
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.
For the blueprint, the explicit wait vs implicit wait comparison comes down to this:
| Aspect | Implicit wait | Explicit wait |
|---|---|---|
| Where retries run | Inside the driver (remote end) | In your code (client side) |
| Can wait for | DOM presence only | Visibility, clickability, text, staleness, URL, any lambda |
| Failure detail | Generic NoSuchElementException | TimeoutException naming the condition and locator |
| When mixed | Poisons every explicit poll | Predictable only when implicit is zero |
| Verdict | Set to zero in the driver factory | The only wait mechanism in the suite |
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.
| Condition | What it proves | Reach for it when |
|---|---|---|
presenceOfElementLocated | In the DOM, possibly hidden | Reading hidden inputs or pre-render state |
visibilityOfElementLocated | Present, displayed, non-zero size | Reading text, asserting something appeared |
elementToBeClickable | Visible and enabled | Before every click or sendKeys |
invisibilityOfElementLocated | Hidden or removed | Spinners, overlays, toasts clearing |
textToBePresentInElementLocated | Text now contains a string | In-place status flips ("Processing" to "Paid") |
stalenessOf | A held reference is detached | Fencing a re-render before re-finding |
frameToBeAvailableAndSwitchToIt | Frame loaded, driver switched | Payment iframes, embedded editors |
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.
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.
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.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();
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:
| Signal | What it proves | Blind spot |
|---|---|---|
document.readyState is "complete" | Initial HTML and subresources loaded | Fires before any fetch or XHR data renders in a SPA |
jQuery.active == 0 | No jQuery-managed requests in flight | Meaningless if the app does not use jQuery; most modern SPAs do not |
| Injected fetch counter | Tracked fetch calls settled | Misses 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.
Codify this in the framework README and enforce it in review:
implicitlyWait(Duration.ZERO) so nobody guesses. Any nonzero value in the codebase is a defect, because it corrupts every explicit wait in the suite.safeClick, reads via visible(). A naked driver.findElement in a page object bypasses the entire waiting layer.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.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.
| Layer | Owns | Must never do |
|---|---|---|
| Tests | Scenario flow, test data, TestNG groups, every assertion | Locate elements, touch WebDriver APIs, sleep |
| Pages | Locators for one screen, user actions, navigation, load contracts | Assert outcomes, create or quit drivers, hold test data |
| Components | One widget's locators and behavior (NavBar, DataTable, DatePicker) | Know which page hosts them, navigate, assert |
| Core | DriverFactory, Waits, Config, screenshots, listeners | Contain 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.
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.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.
By rather than WebElement, so the lookup runs at action time against the current DOM instead of going stale inside a field.this; methods that navigate return the destination page. Flows become compile-checked chains: you cannot call a checkout action from the login page because the type system refuses.statusOf or errorText; the test decides what correct looks like. One page then serves positive tests, negative tests, and scenarios nobody has invented yet.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(); } }
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.
@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.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.
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.
| Concern | BaseTest | BasePage |
|---|---|---|
| Driver creation and quit (via DriverFactory) | Yes | Never |
| Screenshot on failure, report listeners | Yes | Never |
| Seeding data through the API before UI steps | Yes | Never |
| Protected driver and Waits fields for subclasses | No | Yes |
| Shared load-contract helper (waitForAnchor) | No | Yes |
| Assertions and soft-assert factories | Yes, inside the tests themselves | Never |
| Application locators or URLs | Never | Never; 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.
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.
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-pattern | What it looks like | The concrete cost |
|---|---|---|
| God BasePage | Eighty static helpers (jsClick, scrollTo, switchFrame, randomEmail) inherited by every page | Every 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 pages | verifyOrderVisible() asserting deep inside OrdersPage | The page hard-codes one scenario's expectation, so negative paths need cloned methods; failure reports speak in page internals instead of business intent |
| Public WebElements | public WebElement signInButton resolved in the constructor | Tests 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 idiom | Any 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.
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.
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.
| Key | config.properties (base) | config-staging.properties | config-ci.properties |
|---|---|---|---|
base.url | http://localhost:8080 | https://staging.shop.internal | https://app.thetestingacademy.com/playwright/ |
api.url | http://localhost:8787 | https://staging-api.shop.internal | https://ci-api.shop.internal |
browser.headless | false | (inherits false) | true |
wait.explicit.seconds | 10 | (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.
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 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.
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.
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 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.
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.
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
| Situation | Assert style | Why |
|---|---|---|
| Login succeeded, page navigated | Hard | Everything after depends on it; continuing produces noise failures |
| Five fields of one order summary | Soft | Independent facts; you want the complete list in one run |
| Seeding API returned 201 | Hard | No point driving the UI against state that does not exist |
| Labels, badges, totals on one screen | Soft | Parallel 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().
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.
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:
| Concern | TestNG | JUnit 5 |
|---|---|---|
| Parallelism | Declarative in suite XML: methods/classes/tests, plus a separate data-provider pool | junit-platform.properties + @Execution; no separate pool for parameterized data |
| Data-driven tests | TestNG DataProvider: any Object[][], reusable provider classes, parallel = true | @ParameterizedTest with sources; clean for inline values, more ceremony for external files |
| Listeners | ITestListener, ISuiteListener, IAnnotationTransformer, registered suite-wide in XML | Extensions (TestWatcher etc.), registered per class or via service loader |
| Retries | IRetryAnalyzer built in | Nothing built in; third-party extension or build-tool reruns (e.g. Surefire rerunFailingTestsCount) |
| Suite slicing | Groups + XML include/exclude, no rebuild | @Tag + build-tool filtering |
| Natural home | Large end-to-end and integration suites | Unit 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.
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.
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.
| Component | Job |
|---|---|
| Router | The single entry point on port 4444. Sends new-session requests to the queue and commands for running sessions straight to the owning Node. |
| Distributor | Reads queued requests, matches requested capabilities against Node stereotypes, picks a free slot, orders that Node to create the session. |
| Session Map | The lookup table: session id to Node address. Consulted by the Router on every command. |
| New Session Queue | Holds session requests until a matching slot frees up or the request times out. |
| Node | Owns actual browsers. Advertises stereotypes ("4 slots of Chrome 126 on Linux") and executes the sessions. |
| Event Bus | Internal 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.
| Mode | What runs where | Right for | Limit |
|---|---|---|---|
| Standalone | One process plays every role, browsers included | Developer machines, single-runner CI jobs | Cores and RAM of that one machine |
| Hub and node | Hub bundles Router, Distributor, Session Map, Queue, Event Bus; Nodes register to it | Most teams, roughly 5 to 100 parallel sessions | Hub is a single point of failure |
| Fully distributed | Each component its own process behind a load balancer | Platform teams offering grid as an internal service, hundreds of sessions | Your 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.
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.
/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.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.
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.
/ 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.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.
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.
| Dimension | Local machine | Self-hosted grid (Docker) | Cloud grid |
|---|---|---|---|
| Parallel ceiling | 4-5 sessions | Your hardware or cluster budget | Your subscription tier |
| Speed per command | Fastest | Fast (LAN latency) | Slowest (internet latency) |
| Cost shape | Free | Infra plus your time | Recurring per-slot fee |
| Debugging | Watch it live | noVNC on port 7900, container logs | Video, logs, network capture built in |
| Maintenance | None | Image bumps, capacity planning | None |
| Browser/OS matrix | Whatever is installed | Linux Chrome, Firefox, Edge | Thousands 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.
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.
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.
| Capability | TestNG default | Extent Reports | Allure |
|---|---|---|---|
| Setup cost | Zero | Low | Medium |
| Step-level narrative | No | Manual log calls | Automatic via @Step |
| Screenshots / artefacts | Manual links | Embedded manually | @Attachment, inline |
| History trend | No | No | Yes, first-class |
| Failure categories | No | No | categories.json rules |
| Merging parallel shards | No | Awkward | Native |
| Best fit | Local debugging | Emailed reports | CI 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); } }
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.
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.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.
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:
| Cause | Typical symptom | Fix |
|---|---|---|
| Missing or wrong waits (no auto-wait) | NoSuchElementException that passes on re-run | Explicit waits on state; implicit zero; delete Thread.sleep |
| Shared test data | Passes alone, fails in the full suite | Per-test data via API builders with unique keys |
| Grid node variance | Fails only on node 3, or only at 02:00 | Containerised nodes, pinned versions, fixed window, locale, timezone |
| Stale elements | StaleElementReferenceException after re-render | Re-locate at the moment of use; never cache WebElement |
| Animation timing | Clicks land on moving targets | Disable 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.
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.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.
| Situation | Reasonable default in 2026 |
|---|---|
| Mature Java framework, trained team | Stay on Selenium; invest in flake control |
| Widest browser matrix, legacy included | Selenium over W3C vendor drivers |
| In-house grid running well | Selenium; the grid is a paid-for asset |
| Language mandate (Java, C#, Python) | Selenium's first-party bindings |
| Greenfield app, TypeScript-heavy team | Two-week selenium vs playwright pilot; measure |
| Most risk below the UI | Push tests to API level; driver choice matters less |
The article compressed into a selenium best practices checklist. One point per honest tick; below ten, fix the gaps before adding tests.
ThreadLocal isolation; no test calls new ChromeDriver().RemoteWebDriver against the grid; no code edits per environment.Thread.sleep does not survive review.data-test attributes, and are named for intent.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 →