09 · Cross-Browser Testing¶
"Works on my machine" has a browser-shaped variant: works in Chrome. Date inputs
render differently in Firefox, Safari treats position: sticky inside a scroll
container differently, and Edge's autofill drops an element your click was aimed
at. Cross-browser testing is how you find those before a customer does.
It is also the fastest way to double, triple, or quadruple your suite's runtime for very little extra signal — so most of this module is about being selective.
1. Parametrize the driver fixture¶
The whole mechanism is one fixture with params.
# conftest.py
import pytest
from selenium import webdriver
@pytest.fixture(params=["chrome", "firefox"])
def browser_name(request):
return request.param
@pytest.fixture
def driver(browser_name):
if browser_name == "chrome":
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1440,900")
driver = webdriver.Chrome(options=options)
elif browser_name == "firefox":
options = webdriver.FirefoxOptions()
options.add_argument("--headless")
driver = webdriver.Firefox(options=options)
else:
raise ValueError(f"unsupported browser: {browser_name}")
driver.implicitly_wait(0) # explicit waits only — see module 02
yield driver
driver.quit()
Every test that requests driver now runs once per browser, with no change to
the test itself:
def test_form_submits(driver, base_url):
page = WebFormPage(driver, base_url).load()
page.submit_text("cross-browser")
assert page.confirmation_message() == "Received!"
test_form.py::test_form_submits[chrome] PASSED [ 50%]
test_form.py::test_form_submits[firefox] PASSED [100%]
The [chrome] / [firefox] suffix is what makes the report readable and lets
you re-run one browser with -k firefox.
Since Selenium 4.6, Selenium Manager downloads the matching driver binary
automatically — no webdriver-manager, no chromedriver on your PATH. If you
still have webdriver_manager imports in an older suite, deleting them usually
fixes more problems than it causes.
2. Make the browser a command-line option¶
Running every browser on every local run is wasteful. Make it selectable, with a
fast default (this builds on pytest_addoption from module 06).
# conftest.py
def pytest_addoption(parser):
parser.addoption("--browsers", default="chrome",
help="comma-separated: chrome,firefox,edge")
def pytest_generate_tests(metafunc):
if "browser_name" in metafunc.fixturenames:
browsers = metafunc.config.getoption("--browsers").split(",")
metafunc.parametrize("browser_name", browsers, scope="session")
pytest # chrome only — the fast local loop
pytest --browsers chrome,firefox # pre-merge
pytest --browsers chrome,firefox,edge -n 6 # nightly
pytest_generate_tests is the hook for parametrizing based on runtime
information. params=[...] on the fixture can't read a CLI flag; this can.
3. Selenium Grid and cloud providers¶
Running four browsers locally means four browser installs on every machine and no Safari unless you're on a Mac. A Grid centralises them.
@pytest.fixture
def driver(browser_name, request):
grid_url = request.config.getoption("--grid-url")
if grid_url:
options = {"chrome": webdriver.ChromeOptions,
"firefox": webdriver.FirefoxOptions,
"edge": webdriver.EdgeOptions}[browser_name]()
driver = webdriver.Remote(command_executor=grid_url, options=options)
else:
driver = _local_driver(browser_name)
yield driver
driver.quit()
Note --shm-size=2g in the docker command. The default 64 MB of shared memory
makes Chrome crash mid-session with session deleted because of page crash —
an error that reads like an application bug and isn't. It is the single most
common Grid-in-Docker failure.
Cloud grids (BrowserStack, Sauce Labs, LambdaTest) use the same webdriver.Remote
call with credentials in the URL or capabilities, and add real iOS/Android Safari
— the browsers you cannot install locally.
4. What actually differs between browsers¶
| Area | Typical difference | Test impact |
|---|---|---|
| Native form controls | <input type="date">, <select> render and open differently |
A click that works in Chrome misses in Firefox |
| Fonts and metrics | Different default fonts change element widths | Element pushed off-screen; click intercepted |
| Scrolling | Firefox scrolls to a different offset before clicking | Sticky headers overlap the target |
| Shadow DOM / web components | Support and piercing behaviour vary | Locators that work in one browser find nothing in another |
| Downloads | Prefs/paths configured per browser | Download tests need per-browser setup |
| Alerts | Firefox is stricter about unhandled dialogs | UnexpectedAlertPresentException in one browser only |
| Timing | Different JS engines, different render timing | Races that only surface on the slower browser |
That last row is the important one: most "cross-browser failures" are actually
timing bugs your tests always had, exposed because a different engine changed
the order of events. Fix them with explicit waits (module 02), not with
browser-specific sleep calls.
Browser-conditional logic in tests
You now maintain two tests per test, and the Firefox path silently stops exercising a real click — so a genuine Firefox click bug can never be detected. If a browser needs different handling, put it in the driver fixture (options, preferences, window size), never in the test body.5. Which tests to run everywhere¶
Running 400 tests × 4 browsers means 1,600 executions to find perhaps three rendering bugs. Tier instead:
| Tier | Scope | Browsers | When |
|---|---|---|---|
| Smoke | 10–20 critical paths | All supported | Every merge |
| Full regression | Everything | Primary browser only | Every merge |
| Full cross-browser | Everything | All supported | Nightly / pre-release |
| Visual checks | Layout-heavy pages | All supported | Pre-release |
pytest -m smoke --browsers chrome,firefox,edge -n 6
pytest -m "ui and not smoke" --browsers chrome -n 4
Pick the supported browser list from your analytics, not from principle. If 0.4% of your users are on Firefox and none on Safari, four-browser CI is spending real money to protect almost nobody.
Cross-browser tests parallelise unusually well — different browsers touch
different processes and rarely contend — so -n (module 08) is where you buy the
runtime back.
6. Traps¶
Headless is not the same browser
Headless Chrome has no real window manager. Print dialogs, some file-picker
flows, and certain focus behaviours differ, and the default headless window
size is often smaller than a real desktop — which can silently activate your
site's mobile layout and break locators. Always set an explicit
--window-size, and run the pre-release pass headed.
Version drift between local and CI
Chrome auto-updates; your CI image doesn't. A test that fails only in CI is often a browser-version difference. Pin the version in CI, log it into the report metadata (module 07), and update deliberately.
Safari is not optional if you have iPhone users
WebKit is the only engine on iOS — even "Chrome on iPhone" is WebKit. Chrome and Edge both being Chromium means testing both adds far less than adding one WebKit browser does.
One driver fixture for all browsers, silently misconfigured
An else branch that falls through to Chrome means --browsers safari
quietly runs Chrome and reports [safari] PASSED. Raise on an unknown name,
as section 1 does.
How It Actually Works¶
Running the same Selenium test against Chrome, Firefox, and WebKit works because
WebDriver is a protocol specification (W3C WebDriver), not a Chrome-specific API —
your test code talks the same JSON-over-HTTP commands regardless of target; only the
driver executable underneath changes (chromedriver, geckodriver,
msedgedriver), and each driver translates the identical incoming command into
whatever native automation hooks its specific browser engine exposes. This
protocol-level abstraction is also exactly where cross-browser flakiness originates:
each browser engine's actual rendering, timing, and JavaScript execution differs
underneath an identical WebDriver command, so a wait tuned against Chromium's render
timing can legitimately race against Firefox's Gecko engine finishing a layout pass
at a different rate for the same page.
A cloud grid (Selenium Grid, BrowserStack) is the same protocol one hop further: your test's HTTP requests go to a remote hub instead of a local driver process, and the hub proxies them to whichever real (or virtualized) browser/OS combination is provisioned for that session — the wire protocol between your test code and "the browser" never changes; what changes is how many network hops and how much latency sit between the two ends, which is why cloud-grid suites need generally longer wait timeouts than a local run against the same test logic.
Cheat sheet¶
| Need | Code |
|---|---|
| Run every browser | @pytest.fixture(params=["chrome", "firefox"]) |
| Browser from CLI | pytest_generate_tests + --browsers |
| Run one browser | pytest -k chrome |
| Headless Chrome | options.add_argument("--headless=new") |
| Headless Firefox | options.add_argument("--headless") |
| Fixed viewport | options.add_argument("--window-size=1440,900") |
| Remote/Grid | webdriver.Remote(command_executor=url, options=options) |
| Grid in Docker | --shm-size=2g — not optional |
| Driver binaries | Selenium Manager handles it (Selenium ≥ 4.6) |
| Watch a Grid session | Port 7900, noVNC, password secret |
| Speed it up | Combine with -n from module 08 |
Exercise¶
Browsers required
These steps drive real browsers. Install Chrome and Firefox (and Edge if you can) before starting; the Docker step needs Docker running.
- Write the parametrized
driverfixture from section 1 and run one existing POM test against Chrome and Firefox. Confirm the test ids show[chrome]and[firefox]. - Add
--browsersviapytest_generate_tests. Verify the default runs Chrome only and that--browsers chrome,firefoxdoubles the collected count with--collect-only -q. - Deliberately set a headless window size of
500x800and run a test that clicks something in your site's desktop navigation. Record what fails, then explain it in one sentence. - Start
selenium/standalone-chromein Docker without--shm-size=2g, run a test that loads several heavy pages, and record the exact error. Restart with the flag and confirm it goes away. - Find one test in your suite that passes in Chrome and fails in Firefox. Fix it
with an explicit wait rather than an
if browser_name ==branch, and write one sentence on what the original failure said about your locator strategy.