04 · Selenium Grid & Parallel Execution¶
A suite of 200 UI tests at 5 seconds each is 16 minutes run one at a time. Selenium Grid lets many browsers run those tests concurrently across machines (or containers); TestNG's parallel execution lets your test runner actually dispatch work to them at the same time instead of queuing. Together they turn a coffee-break suite into a two-minute one.
1. Selenium Grid architecture¶
- Hub — receives test requests and routes them to an available node.
- Node — a machine (or container) that actually runs a browser.
- Since Selenium 4, "Grid" ships as a single jar with
standalone,hub, andnoderoles, most commonly run via Docker.
# docker-compose.yml
services:
selenium-hub:
image: selenium/hub:4.20
ports: ["4442:4442", "4443:4443", "4444:4444"]
chrome-node:
image: selenium/node-chrome:4.20
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=4
firefox-node:
image: selenium/node-firefox:4.20
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
shm_size: 2gb matters more than it looks: Chrome's default /dev/shm in a
container is 64MB, and Chrome silently crashes under load without a bump —
one of the most common "works locally, dies in Docker" Selenium bugs.
2. Pointing tests at the Grid with RemoteWebDriver¶
package com.example.grid;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.RemoteWebDriver;
import java.net.URL;
public class RemoteDriverFactory {
public static WebDriver create(String browser) throws Exception {
URL hubUrl = new URL("http://localhost:4444/wd/hub");
return switch (browser) {
case "chrome" -> new RemoteWebDriver(hubUrl, new ChromeOptions());
case "firefox" -> new RemoteWebDriver(hubUrl,
new org.openqa.selenium.firefox.FirefoxOptions());
default -> throw new IllegalArgumentException("Unknown browser: " + browser);
};
}
}
The test code above this factory doesn't change at all versus a local
ChromeDriver() from Level 1 — RemoteWebDriver implements the same
WebDriver interface, so driver.findElement(...), waits, and page objects
all work identically. Only the wiring at setup changes.
3. TestNG parallel execution¶
testng.xml:
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="RegressionSuite" parallel="classes" thread-count="4">
<test name="ChromeTests">
<parameter name="browser" value="chrome"/>
<classes>
<class name="com.example.grid.LoginFlowTest"/>
<class name="com.example.grid.CheckoutFlowTest"/>
</classes>
</test>
</suite>
parallel="classes" runs each <class> on its own thread;
parallel="methods" goes finer-grained and runs each @Test method
concurrently. thread-count caps how many run at once — set it to (roughly)
the number of node sessions your Grid can actually serve, not higher, or
tests will queue at the Grid instead of your test runner.
package com.example.grid;
import org.openqa.selenium.WebDriver;
import org.testng.annotations.*;
public class LoginFlowTest {
private WebDriver driver;
@BeforeMethod
@Parameters("browser")
public void setUp(String browser) throws Exception {
driver = RemoteDriverFactory.create(browser);
}
@AfterMethod
public void tearDown() {
if (driver != null) driver.quit();
}
@Test
public void validLoginReachesDashboard() {
driver.get("https://example.test/login");
// ... same Page Object calls as Level 2 Module 01
}
}
Creating the WebDriver in @BeforeMethod — never as a shared static field
— is what makes this safe under parallel="methods": each thread gets its
own driver instance and its own browser session, so one test's navigation
can't leak into another's.
I did not run an actual Selenium Grid or Docker containers in this
environment (no Docker daemon available headlessly here), so the
docker-compose.yml, RemoteWebDriver wiring, and TestNG config above are
reviewed against Selenium 4's and TestNG's documented behavior, not
executed. The parallel-safety pattern (per-thread driver via @BeforeMethod,
never static) is the same one used correctly in Level 2's Page Object
Model module, which was exercised there.
4. Cross-browser matrix without duplicating test code¶
<suite name="CrossBrowserSuite" parallel="tests" thread-count="2">
<test name="Chrome">
<parameter name="browser" value="chrome"/>
<classes><class name="com.example.grid.LoginFlowTest"/></classes>
</test>
<test name="Firefox">
<parameter name="browser" value="firefox"/>
<classes><class name="com.example.grid.LoginFlowTest"/></classes>
</test>
</suite>
One test class, two <test> blocks, parallel="tests" — the same
LoginFlowTest runs against both browsers concurrently, extending the
cross-browser approach from Level 2 Module 09 from sequential to parallel.
5. Testing traps¶
Trap 1 — shared mutable state across parallel threads
A static Map<String,String> testData written by one test and read by
another is a race condition once parallel="methods" is on. Symptoms:
tests that pass every time alone and fail unpredictably in the full
suite. Fix: no shared mutable statics, ever, in a parallel suite.
Trap 2 — Grid node pool smaller than thread-count
thread-count="10" against a Grid with SE_NODE_MAX_SESSIONS=4 doesn't
fail loudly — the extra sessions just queue, and your "parallel" run is
silently bottlenecked to 4-wide while looking like it should be 10-wide.
Match thread-count to actual node capacity.
Trap 3 — flaky selectors multiply under parallel load
A selector relying on a fixed animation delay (a hard Thread.sleep)
that barely worked under light single-threaded load will fail far more
often once four sessions are competing for the same host's CPU.
Parallel execution is the moment a marginal wait strategy becomes an
unmissable one — replace sleeps with explicit WebDriverWait (Level 1
Module 08) before scaling up.
Trap 4 — Chrome crashing silently from /dev/shm
Symptom: SessionNotCreatedException or the browser process disappearing
mid-test with no clear Selenium-side error, specifically under
Dockerized Grid nodes. Almost always the default 64MB /dev/shm — bump
shm_size.
Trap 5 — one slow test holding up the whole parallel run
parallel="classes" with thread-count="4" still waits for the
single slowest class if the other three finish early and there's no
fifth class queued. Balance test classes by expected duration, not just
count, when distributing across threads.
Cheat sheet¶
| Task | Code / Config |
|---|---|
| Point WebDriver at a Grid | new RemoteWebDriver(hubUrl, options) |
| Grid hub URL (default) | http://localhost:4444/wd/hub |
| Parallel by class | <suite parallel="classes" thread-count="N"> |
| Parallel by method | <suite parallel="methods" thread-count="N"> |
Parallel by <test> block (cross-browser) |
<suite parallel="tests"> |
Pass a parameter per <test> |
<parameter name="browser" value="chrome"/> |
| Read it in Java | @Parameters("browser") on a @BeforeMethod |
| Per-thread driver (required for safety) | create in @BeforeMethod, never static |
| Fix Chrome crashing in Docker | shm_size: 2gb on the node service |
| Match Grid capacity | SE_NODE_MAX_SESSIONS ≈ thread-count |
How It Actually Works¶
RemoteWebDriver pointed at a Grid hub URL uses the exact same W3C
WebDriver JSON-over-HTTP protocol described in Level 1 for local
chromedriver — the only difference is where the HTTP request lands. A
new RemoteWebDriver(new URL("http://hub:4444"), options) sends a
POST /session "new session" request to the hub, which is a stateless
router: it reads the desired capabilities (browser name/version) in that
request, consults its live registry of connected nodes and how many
sessions each is currently running (bounded by SE_NODE_MAX_SESSIONS),
picks a node with capacity and a matching browser, and returns that node's
session ID back to your test — every WebDriver call after that (click,
findElement) is routed by session ID directly to the node that owns it.
The hub itself never runs a browser; it only ever brokers the initial
handshake and proxies traffic, which is why the hub can stay lightweight
even coordinating hundreds of concurrent sessions across many nodes.
The shm_size bump matters because of a completely different mechanism —
Linux shared memory. Chrome uses /dev/shm as a tmpfs-backed scratch area
for passing rendered frames between its renderer and GPU/compositor
processes; Docker containers default /dev/shm to 64MB regardless of the
host's actual RAM, and a heavier page (large images, many tabs, video)
exhausts that 64MB and crashes the renderer process with a signal the
WebDriver layer reports simply as "session deleted" or a generic
WebDriverException — nothing about that error message points at shared
memory, which is exactly why it's a well-known trap: the fix
(shm_size: 2gb) addresses a container runtime limit that has nothing to
do with Selenium's own code.
Exercise¶
- Bring up
selenium-hub+chrome-node+firefox-nodevia the compose file above (or install Selenium Server standalone if Docker isn't available) and confirm the Grid console athttp://localhost:4444shows both nodes registered. - Write
RemoteDriverFactoryand pointLoginFlowTestat the Grid instead of a local driver; run it once against each browser and confirm both pass. - Convert
testng.xmltoparallel="methods" thread-count="4"across a suite of at least 4 test methods, and time the run versus a sequential (parallel="none") run of the same suite. - Deliberately add a
staticfield that one test writes and another reads, run the suite underparallel="methods"ten times, and record how many runs failed versus passed — this is Trap 1 made visible. - Set
SE_NODE_MAX_SESSIONS=1on your Chrome node, setthread-count="4"in TestNG, and time the run. Explain the gap between this time and the Exercise 3 time in terms of Trap 2.