The same login flow needs to work in Chrome, Firefox, and Edge. Each browser has its own quirks — a CSS bug that only shows up in Firefox, a date-picker that renders differently in Edge, a cookie behaviour Chrome handles uniquely. Cross-browser testing is the discipline of running the same tests across multiple browser engines to catch those differences. With WebDriverManager handling driver binaries and TestNG's @Parameters driving the browser choice, you can build one test class that runs identically across three browsers in parallel — no code duplication, one suite file, three results sets. This lesson wires the whole thing together.
A driver factory parameterised on browser
The pattern is a small factory that takes a string and returns a configured WebDriver. Update BaseTest:
package com.mycompany.tests.base;import io.github.bonigarcia.wdm.WebDriverManager;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.testng.annotations.AfterMethod;import org.testng.annotations.BeforeMethod;import org.testng.annotations.Optional;import org.testng.annotations.Parameters;public abstract class BaseTest { protected WebDriver driver; @Parameters("browser") @BeforeMethod public void setup(@Optional("chrome") String browser) { boolean headless = "true".equalsIgnoreCase(System.getProperty("headless", "false")); driver = createDriver(browser, headless); driver.manage().window().maximize(); } @AfterMethod public void teardown() { if (driver != null) driver.quit(); } private WebDriver createDriver(String browser, boolean headless) { switch (browser.toLowerCase()) { case "chrome": { WebDriverManager.chromedriver().setup(); ChromeOptions o = new ChromeOptions(); if (headless) o.addArguments("--headless=new", "--no-sandbox", "--disable-dev-shm-usage"); o.addArguments("--window-size=1920,1080"); return new ChromeDriver(o); } case "firefox": { WebDriverManager.firefoxdriver().setup(); FirefoxOptions o = new FirefoxOptions(); if (headless) o.addArguments("-headless"); return new FirefoxDriver(o); } case "edge": { WebDriverManager.edgedriver().setup(); EdgeOptions o = new EdgeOptions(); if (headless) o.addArguments("--headless=new"); return new EdgeDriver(o); } default: throw new IllegalArgumentException("Unsupported browser: " + browser); } }}
Three things make this production-ready:
@Parameters("browser") with @Optional("chrome"). TestNG reads the browser parameter from testng.xml. If it's missing — for instance, when running a single class from IntelliJ without a suite file — @Optional falls back to chrome.
headless toggled via system property.mvn test -Dheadless=true flips it on for CI; off for local dev.
switch on lowercased input. Tolerates Chrome, CHROME, chrome from the suite file.
The factory is the only place per-browser logic lives. Test classes (and page objects) are unchanged — they just use driver.
Three <test> blocks, same package each time, different browser parameter. parallel="tests" runs the three blocks concurrently — three browsers, three threads, one wall-clock pass.
Run it:
mvn clean test -DsuiteFile=cross-browser.xml -Dheadless=true
Total runtime: roughly the time to run the slowest browser, not three times the single-browser time.
What this looks like in practice
One suite × three browsers, run in parallel
browser param
driver
thread
result
Chrome
browser=chrome
ChromeDriver (headless)
thread 1
✅ 12 passed
Firefox
browser=firefox
GeckoDriver (headless)
thread 2
✅ 12 passed
Edge
browser=edge
EdgeDriver (headless)
thread 3
❌ 11 passed, 1 failed
The Edge column is the kind of thing cross-browser tests catch — a CSS bug, a date-format quirk, a JS API that behaves differently. The whole point of running the suite three times is to find these.
CLI override — single-browser ad-hoc runs
For developer-mode "I just want to run this against Firefox right now," you don't need to edit XML:
mvn test -Dtest=LoginPomTest -Dbrowser=firefox
Wire that through to BaseTest by reading from system property when no @Parameters value is set:
Now the cascade is: testng.xml parameter → CLI -Dbrowser=... → default chrome. Tests pick up the right browser at every level.
A complete cross-browser test
package com.mycompany.tests.tests;import com.mycompany.tests.base.BaseTest;import com.mycompany.tests.pages.InventoryPage;import com.mycompany.tests.pages.LoginPage;import org.testng.Assert;import org.testng.annotations.Test;public class CrossBrowserLoginTest extends BaseTest { @Test public void shouldLogInAndShowSixProducts() { InventoryPage inventory = new LoginPage(driver) .navigateTo() .loginAs("standard_user", "secret_sauce"); Assert.assertEquals(inventory.productCount(), 6, "Sauce Demo always shows six products in any browser"); } @Test public void shouldShowErrorForLockedOutUser() { LoginPage login = new LoginPage(driver).navigateTo(); login.fillUsername("locked_out_user"); login.fillPassword("secret_sauce"); login.submitExpectingError(); Assert.assertTrue(login.errorText().contains("locked out")); }}
This same class runs unchanged across all three browsers. The driver is created in BaseTest based on the browser parameter; the test body is browser-agnostic. That's the whole win.
Coverage strategy — don't run every test on every browser
Running every test on every browser is wasteful. Most pages render identically; cross-browser bugs concentrate in specific UI components (date pickers, complex forms, CSS-heavy layouts, drag-and-drop). The pragmatic strategy:
Smoke suite — full coverage on Chrome only. Runs on every commit. ~5 minutes.
Cross-browser smoke — the 10 most user-visible flows on Chrome + Firefox + Edge. Runs on PR merges. ~15 minutes.
Full regression — all tests on Chrome only. Runs nightly. ~45 minutes.
Cross-browser regression — full tests on all three browsers. Weekly. ~2 hours.
The shape: cross-browser is expensive; spend the budget on flows where browser differences actually matter. We'll wire this into CI in chapter 8.
Cloud alternatives
For real cross-browser scale (Safari on macOS, IE 11, Chrome on Android, Firefox on Linux), local installation isn't enough. Three commercial Selenium Grid-as-a-service providers dominate:
BrowserStack — the largest matrix; corporate-friendly billing.
Sauce Labs — long-running Selenium player with strong analytics.
LambdaTest — newer, often cheaper.
You point a RemoteWebDriver at their hub URL with credentials in capabilities; the rest is identical to local Grid (next lesson). For projects that need iPhone Safari and IE 11 in the matrix, this is the path.
Playwright's projects are the cleanest cross-browser API of the three frameworks — including WebKit, which Selenium and Cypress can't drive directly. Cypress historically supported only Chromium-family browsers; Firefox was added in 2020 and remains a second-class citizen. Selenium's matrix is the broadest in terms of mobile/legacy support but requires the parameter plumbing this lesson built.
Hardcoding new ChromeDriver() somewhere outside BaseTest. A page object or test class that constructs its own Chrome driver bypasses the factory — the cross-browser suite happily runs the same Chrome three times. Audit for raw new ChromeDriver(), new FirefoxDriver(), etc., outside the factory; there should be exactly one such call per browser, in the factory.
Sharing a driver field across threads in parallel runs. With parallel="tests", multiple threads create drivers simultaneously. A static or shared instance gets stomped on. Either keep protected WebDriver driver; (which is per-instance — TestNG creates a fresh instance per <test>) or use ThreadLocal<WebDriver> (chapter 8 covers this for parallel="methods").
Running cross-browser on a tight timeout. Firefox starts ~30% slower than Chrome on most machines; Edge is somewhere in between. Tight timeouts (Duration.ofSeconds(2)) calibrated against Chrome may flake on Firefox. Either size waits generously enough that all three browsers pass, or parameterise timeouts per browser.
🎯 Practice task
Run the same suite across three browsers. 35–45 minutes.
Update your BaseTest with the parameterised driver factory from this lesson. Confirm existing tests still pass on Chrome (the default).
Install Firefox and Edge on your machine if they're not already there. Run a single test against each manually:
mvn test -Dtest=LoginPomTest -Dbrowser=firefoxmvn test -Dtest=LoginPomTest -Dbrowser=edge
All three should pass.
Create src/test/resources/cross-browser.xml from the lesson. Run:
mvn clean test -DsuiteFile=cross-browser.xml
Watch all three browsers run in parallel. Total time should be ~1× single-browser time, not 3×.
Find a real browser difference. Pick a complex UI on a public page (a date picker on Booking.com, the GitHub issue editor, any rich-text component). Write a test that exercises it. Run on all three browsers. Either the test passes everywhere — proof the framework abstracts well — or you find an actual cross-browser bug.
Headless on/off. Run cross-browser locally with -Dheadless=true and again without. Compare runtimes. Headless is roughly 10–20% faster on most machines.
Stretch — cloud Selenium. Sign up for a BrowserStack/Sauce Labs free trial. Wire a RemoteWebDriver against their hub URL with your credentials in capabilities. Run a single test against Safari on macOS — a browser you literally cannot run locally on a Linux box. The free tier gives you ~100 minutes; enough to feel the workflow.
Next lesson: Selenium Grid, the open-source way to scale browser instances across machines. We'll spin one up locally with Docker Compose, point our tests at it, and watch the dashboard fill with concurrent sessions.
// tip to track lessons you complete and pick up where you left off across devices.