09 · Cross-Browser Testing¶
"Works on my machine" has a browser-shaped variant: works in Chrome. Firefox renders a date input differently, Safari handles cookies more strictly, and Edge inherits Chromium's engine but not its defaults. Cross-browser testing is how you find those differences before a customer does — and the framework work is mostly one class: a driver factory that takes a browser name.
1. Decide what actually needs a second browser¶
Running the whole suite on four browsers quadruples your runtime and your maintenance for very little extra defect-finding. A sane split:
| Layer | Where it should run |
|---|---|
| Business logic, API tests | One browser — or none |
| Full functional regression | One primary browser (usually Chrome) |
| Smoke / critical user journeys | Every supported browser |
| Rendering and layout | Every supported browser, plus mobile viewports |
Get the supported list from analytics, not from opinion. "Chrome, Firefox, Safari, Edge, current and previous major version" is a typical policy; write it into the test plan so the scope is a decision, not an accident.
2. A driver factory¶
package com.example.driver;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.*;
import org.openqa.selenium.edge.*;
import org.openqa.selenium.firefox.*;
import org.openqa.selenium.safari.SafariDriver;
import java.time.Duration;
import java.util.Locale;
public final class DriverFactory {
private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();
public static WebDriver get() {
WebDriver driver = DRIVER.get();
if (driver == null) {
throw new IllegalStateException("No driver on this thread -- call start() first");
}
return driver;
}
public static void start(String browser, boolean headless) {
WebDriver driver = switch (browser.toLowerCase(Locale.ROOT)) {
case "chrome" -> new ChromeDriver(chromeOptions(headless));
case "firefox" -> new FirefoxDriver(firefoxOptions(headless));
case "edge" -> new EdgeDriver(edgeOptions(headless));
case "safari" -> new SafariDriver(); // macOS only, no headless mode
default -> throw new IllegalArgumentException("Unsupported browser: " + browser);
};
driver.manage().timeouts().implicitlyWait(Duration.ZERO);
driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(30));
DRIVER.set(driver);
}
private static ChromeOptions chromeOptions(boolean headless) {
ChromeOptions options = new ChromeOptions();
if (headless) options.addArguments("--headless=new");
options.addArguments("--window-size=1920,1080", "--disable-gpu",
"--no-sandbox", "--disable-dev-shm-usage",
"--lang=en-GB");
return options;
}
private static FirefoxOptions firefoxOptions(boolean headless) {
FirefoxOptions options = new FirefoxOptions();
if (headless) options.addArguments("-headless");
options.addArguments("--width=1920", "--height=1080");
options.addPreference("intl.accept_languages", "en-GB");
return options;
}
private static EdgeOptions edgeOptions(boolean headless) {
EdgeOptions options = new EdgeOptions();
if (headless) options.addArguments("--headless=new");
options.addArguments("--window-size=1920,1080");
return options;
}
public static void stop() {
WebDriver driver = DRIVER.get();
if (driver != null) {
driver.quit();
DRIVER.remove();
}
}
private DriverFactory() { }
}
Note the differences the factory hides: Chrome and Edge take
--headless=new, Firefox takes -headless with a single dash, Firefox sizes
via --width/--height, and Safari has no headless mode at all. Every one
of those is a trap someone hits on their first cross-browser day.
Selenium Manager handles the driver binaries
Selenium 4.6+ downloads and caches the matching chromedriver,
geckodriver or msedgedriver automatically. You do not need
WebDriverManager or System.setProperty any more — but the browser
itself must be installed, and Safari needs
safaridriver --enable run once.
3. Parameterising the suite¶
TestNG — one <test> block per browser¶
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Cross-Browser Smoke" parallel="tests" thread-count="3">
<test name="Chrome">
<parameter name="browser" value="chrome"/>
<classes><class name="com.example.tests.LoginSmokeIT"/></classes>
</test>
<test name="Firefox">
<parameter name="browser" value="firefox"/>
<classes><class name="com.example.tests.LoginSmokeIT"/></classes>
</test>
<test name="Edge">
<parameter name="browser" value="edge"/>
<classes><class name="com.example.tests.LoginSmokeIT"/></classes>
</test>
</suite>
package com.example.tests;
import com.example.driver.DriverFactory;
import org.testng.annotations.*;
import static org.testng.Assert.assertTrue;
public class LoginSmokeIT {
@Parameters({"browser"})
@BeforeMethod(alwaysRun = true)
public void startBrowser(@Optional("chrome") String browser) {
DriverFactory.start(browser, Boolean.parseBoolean(
System.getProperty("headless", "true")));
}
@Test(groups = "smoke", description = "The login page renders on every supported browser")
public void loginPageRenders() {
DriverFactory.get().get("https://the-internet.herokuapp.com/login");
assertTrue(DriverFactory.get().getTitle().contains("The Internet"),
"Unexpected title: " + DriverFactory.get().getTitle());
}
@AfterMethod(alwaysRun = true)
public void stopBrowser() {
DriverFactory.stop();
}
}
===============================================
Cross-Browser Smoke
Total tests run: 3, Passes: 3, Failures: 0, Skips: 0
===============================================
parallel="tests" runs the three browsers simultaneously, so three browsers
cost roughly the wall-clock time of one.
JUnit 5 — a parameterised test¶
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@ParameterizedTest(name = "login page renders in {0}")
@ValueSource(strings = {"chrome", "firefox"})
void loginPageRenders(String browser) {
DriverFactory.start(browser, true);
try {
DriverFactory.get().get("https://the-internet.herokuapp.com/login");
assertThat(DriverFactory.get().getTitle()).contains("The Internet");
} finally {
DriverFactory.stop();
}
}
4. Selenium Grid and cloud providers¶
One machine cannot host Safari and Edge and three Chrome versions. A Grid (or a cloud vendor) runs browsers remotely; your test connects over HTTP.
# The whole grid, one container
docker run -d -p 4444:4444 -p 7900:7900 --shm-size=2g selenium/standalone-chrome:latest
import org.openqa.selenium.remote.RemoteWebDriver;
import java.net.URL;
public static WebDriver remote(String gridUrl, String browser) throws Exception {
Capabilities caps = switch (browser) {
case "firefox" -> new FirefoxOptions();
case "edge" -> new EdgeOptions();
default -> new ChromeOptions();
};
return new RemoteWebDriver(new URL(gridUrl), caps);
}
// Cloud vendors take extra capabilities in a vendor-prefixed map
ChromeOptions options = new ChromeOptions();
options.setCapability("browserVersion", "126");
options.setCapability("platformName", "Windows 11");
options.setCapability("cloud:options", Map.of(
"build", System.getenv("BUILD_NUMBER"),
"name", "Login smoke",
"video", true));
Make the factory return a RemoteWebDriver when a grid.url property is
set, and a local driver otherwise. Tests then run unchanged locally and on
CI — the only property that changes is a URL.
--shm-size=2g is not optional in Docker
The default 64 MB of shared memory makes Chrome crash intermittently
inside containers with session deleted because of page crash. It looks
exactly like a flaky test and it is not.
5. Responsive and viewport testing¶
driver.manage().window().setSize(new Dimension(375, 812)); // iPhone-ish
driver.manage().window().setSize(new Dimension(768, 1024)); // tablet
driver.manage().window().maximize(); // desktop
// Chrome's device emulation
ChromeOptions options = new ChromeOptions();
options.setExperimentalOption("mobileEmulation", Map.of("deviceName", "Pixel 7"));
Emulation resizes the viewport and spoofs the user agent. It does not reproduce a real mobile browser engine, touch behaviour or device performance — good for catching layout breakpoints, not a substitute for a real device when the defect is behavioural.
6. Testing traps¶
Trap 1 — locators that only work in one browser
By.xpath("//div[@class='x']/text()") and CSS pseudo-elements behave
differently across engines. If a locator fails only in Firefox, suspect
the locator before the application.
Trap 2 — timing differences read as bugs
Firefox is often slower to fire load events than Chrome. A test that passes in Chrome with a 5-second wait and fails in Firefox usually has an inadequate wait, not a Firefox defect. Fix the wait; do not add a browser-specific sleep.
Trap 3 — headless behaves differently from headed
Headless has no real window size, no GPU compositing, and in some versions different font rendering. A layout bug that only appears headless may not exist for users. Reproduce headed before raising it.
Trap 4 — click() intercepted by different scroll positions
Browsers scroll elements into view differently, so an element clickable
in Chrome can be under a sticky header in Firefox. Scroll deliberately
(scrollIntoView({block:'center'})) instead of relying on the default.
Trap 5 — one failure across three browsers is three failures
A cross-browser report showing 3 failed of 90 hides that it is the same test failing everywhere. Group your report by test, not by browser, or you will triage the same defect three times.
Cheat sheet¶
| Task | Code |
|---|---|
| Chrome headless | options.addArguments("--headless=new") |
| Firefox headless | options.addArguments("-headless") |
| Edge headless | options.addArguments("--headless=new") |
| Safari | new SafariDriver() — macOS only, no headless |
| Enable safaridriver | safaridriver --enable (once, as admin) |
| Window size, Chrome/Edge | --window-size=1920,1080 |
| Window size, Firefox | --width=1920 --height=1080 |
| Resize at runtime | driver.manage().window().setSize(new Dimension(375, 812)) |
| Mobile emulation | options.setExperimentalOption("mobileEmulation", ...) |
| Remote driver | new RemoteWebDriver(new URL(gridUrl), options) |
| Grid in Docker | docker run -p 4444:4444 --shm-size=2g selenium/standalone-chrome |
| Watch a Grid session | VNC on port 7900 |
| Browser per TestNG block | <test><parameter name="browser" value="firefox"/></test> |
| Browsers in parallel | <suite parallel="tests" thread-count="3"> |
| Thread safety | ThreadLocal<WebDriver> + remove() |
How It Actually Works¶
ThreadLocal<WebDriver> exists to solve a specific memory-model problem:
when TestNG runs tests across N threads (parallel="methods",
thread-count=4), each thread executes on its own call stack but shares
the same static class variables in the JVM's heap — a plain static
WebDriver driver field would be one object every thread reads and writes,
so thread A's driver.get(url) could run against a session thread B just
navigated away from. ThreadLocal fixes this without any manual locking:
internally, each Thread object carries its own private
ThreadLocalMap, and ThreadLocal.get()/.set() key into that specific
calling thread's map using the ThreadLocal instance itself as the key —
so DriverFactory.get() called from thread A and the identical call from
thread B look up entirely separate map entries even though they're
executing the exact same static method. Each thread transparently gets its
own WebDriver (its own chromedriver process and browser session)
without the factory code needing to know which thread is calling it.
This is also why cross-browser factories are written as a switch over an
enum/string rather than one shared driver class: ChromeDriver,
FirefoxDriver, EdgeDriver and SafariDriver are each thin Java classes
that construct a RemoteWebDriver pointed at a different local driver
executable and a different underlying browser process/protocol dialect —
Safari's driver, for instance, talks to a safaridriver binary Apple
ships with macOS itself rather than a downloaded binary — so "cross-browser"
at the code level is really "point the same WebDriver-protocol client at a
different vendor's bridge process," while the protocol contract your test
code depends on (findElement, click, sendKeys) stays identical across
all of them by design, per the W3C WebDriver spec.
Exercise¶
- Build
DriverFactoryexactly as in section 2 and get one test passing on Chrome and Firefox locally. Record every difference you had to encode in the options methods. - Write a
cross-browser.xmlwith three<test>blocks andparallel="tests". Compare total runtime against running them sequentially. - Take one Module 01 page object test and run it on both browsers. If it passes on one and fails on the other, decide — with evidence — whether it is a locator problem, a wait problem, or a genuine application defect, and write the reasoning down.
- Run the same suite headed and headless on Chrome. Note any difference in runtime and in results, and explain trap 3 in your own words.
- Start
selenium/standalone-chromein Docker, point aRemoteWebDriverathttp://localhost:4444, and run your smoke test against it. Watch it via VNC on port 7900. - Extend the factory so a
-Dgrid.url=...property switches between local and remote with no change to any test class. - Resize the viewport to 375x812, run your login test, and note whether the navigation is still reachable. If a hamburger menu appeared, your page object now needs a responsive strategy — describe it in three sentences.
- Write the browser-support section of a test plan: which browsers, which versions, which subset of tests runs on each, and the justification.