A test that passes only in Chromium isn't really tested. Real users open your app in Chrome, Firefox, Safari (which is WebKit under the hood), and on phones in every viewport between 320 and 1920 pixels wide. Playwright Python ships browsers, device profiles, and configuration hooks for all of it — and pytest-playwright wires them in via simple CLI flags and conftest overrides. This lesson covers the multi-browser CLI workflow, browser-specific test skipping, mobile and tablet emulation, geolocation, locale, and the device presets bundled with Playwright.
Running on multiple browsers — the CLI
The whole multi-browser story is one CLI flag, repeated:
pytest --browser chromium --browser firefox --browser webkitEvery test in the suite runs three times — once per browser. No code changes, no parametrization, no test-file duplication. The reports show the browser in each test id:
tests/test_login.py::test_login[chromium] PASSED
tests/test_login.py::test_login[firefox] PASSED
tests/test_login.py::test_login[webkit] PASSED
Run a single browser:
pytest --browser firefoxPin the default in pytest.ini for everyday development:
[pytest]
addopts = --browser chromium --headedCI typically runs all three for the full regression and a subset (Chromium only) for fast PR feedback. The trade-off is wall time — a 50-test suite running on three browsers takes 3x longer unless you parallelise (chapter 7).
Browser-specific skips
Some tests don't make sense on every browser — clipboard APIs differ, file uploads have OS quirks, WebKit has known behaviour gaps. Use @pytest.mark.skip_browser (registered by pytest-playwright):
@pytest.mark.skip_browser("webkit", reason="Clipboard API not supported in WebKit")
def test_copy_to_clipboard(page: Page):
page.goto("/share")
page.get_by_role("button", name="Copy link").click()
# ... assertions ...The test runs on Chromium and Firefox, gets skipped on WebKit, and the report shows the reason. For more dynamic conditions, use the browser_name fixture and pytest.skip():
def test_specific(page: Page, browser_name: str):
if browser_name == "webkit":
pytest.skip("Not supported on WebKit")
page.goto("/")
# ...The marker version is preferred when the skip is permanent; the browser_name version is useful for runtime decisions (e.g., feature flags).
Pinning a browser per test
The opposite case — a test that only makes sense on one browser:
@pytest.mark.only_browser("firefox")
def test_firefox_specific_quirk(page: Page):
page.goto("/")
# Firefox has a unique scrollbar overlay we want to verifyonly_browser("firefox") skips the test on Chromium and WebKit. Useful for browser-specific bug regression tests.
Mobile emulation — the basic shape
Playwright doesn't run on real mobile devices. It emulates them — a mobile-sized viewport, the right user agent, touch events enabled, the right device-scale-factor for retina screens. Configure it via browser_context_args:
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"viewport": {"width": 375, "height": 667},
"is_mobile": True,
"has_touch": True,
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1",
"device_scale_factor": 2,
}The five things that make emulation faithful:
viewport— the size the page thinks the screen is (375×667 = iPhone SE).is_mobile— flips the page into mobile-friendly mode (some apps key off this).has_touch— replaces mouse events with touch events.user_agent— some apps serve different markup based on UA.device_scale_factor— 2 for retina, 1 for standard. Affects screenshot resolution.
Device presets — the easy way
Playwright ships ~140 named device profiles. Use them instead of building configs from scratch:
import pytest
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args, playwright):
iphone_13 = playwright.devices["iPhone 13"]
return {**browser_context_args, **iphone_13}playwright.devices["iPhone 13"] returns a dict with viewport, user_agent, has_touch, is_mobile, and device_scale_factor pre-set. Spread it into browser_context_args and every test runs in iPhone 13 mode. Common entries:
"iPhone 13","iPhone 13 Pro Max","iPhone SE""Pixel 5","Pixel 7","Galaxy S9+""iPad Pro 11","iPad Mini","Galaxy Tab S4""Desktop Chrome","Desktop Firefox","Desktop Safari"
The full list is at playwright.devices.keys() — print it once to see the options.
Geolocation, locale, timezone, color scheme
Same browser_context_args fixture handles every contextual setting:
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"geolocation": {"longitude": -0.1276, "latitude": 51.5074}, # London
"permissions": ["geolocation"],
"locale": "en-GB",
"timezone_id": "Europe/London",
"color_scheme": "dark",
}What each does:
geolocation— fakesnavigator.geolocation.getCurrentPosition. Required for testing location-aware features (store finder, weather widget).permissions: ["geolocation"]— auto-grants the permission so the page doesn't show a permission prompt.locale— theAccept-Languageheader andnavigator.language(en-GB,fr-FR,de-DE).timezone_id— the page's perceived timezone. Date/time UI behaves as if you're in that zone.color_scheme—"dark"or"light". Affectsprefers-color-schemeCSS media queries — flips your dark mode tests on without manual toggling.
Browser × device coverage matrix
Typical browser and device matrix for a Playwright Python suite
Common pattern: WebKit handles the iPhone-emulation slot (since Safari is what real iPhones use). Chromium and Firefox cover desktop browsers; tablet and mobile coverage tends to lean WebKit-only because that's what real users on those devices have.
Per-test viewport overrides
Sometimes you want a single test to run at a non-default size:
def test_mobile_navigation(page: Page):
page.set_viewport_size({"width": 375, "height": 667})
page.goto("/")
expect(page.get_by_test_id("hamburger-menu")).to_be_visible()
expect(page.get_by_test_id("desktop-nav")).to_be_hidden()
def test_desktop_navigation(page: Page):
page.set_viewport_size({"width": 1920, "height": 1080})
page.goto("/")
expect(page.get_by_test_id("hamburger-menu")).to_be_hidden()
expect(page.get_by_test_id("desktop-nav")).to_be_visible()page.set_viewport_size resizes the current page only — doesn't switch user-agent or touch mode. For full mobile emulation mid-test, use browser.new_context(...) with a device dict, but typically you'd parametrize with viewports (lesson 3 of chapter 3) when only the size matters.
Coming from Playwright TypeScript?
The TS course's playwright.config.ts has a projects array — one per browser/device combo — and the runner fans out automatically. The Python equivalent is:
- TS
projects: [{ name: 'chromium', use: devices['Desktop Chrome'] }, ...]→ CLI--browser chromium --browser firefox - TS
use: { viewport, locale, geolocation }→ Pythonbrowser_context_argsfixture - TS
test.skip(browserName === 'webkit', '...')→ Python@pytest.mark.skip_browser("webkit", reason="...") - TS
devices['iPhone 13']→ Pythonplaywright.devices["iPhone 13"]
Functionally identical. The Python CLI flag approach is arguably simpler than the TS projects config; the TS project array is more powerful when you need different timeouts or different use values per browser. Match the workflow to the team.
⚠️ Common mistakes
- Forgetting to spread
**browser_context_argswhen overriding it.** Returning{"viewport": ...}instead of{**browser_context_args, "viewport": ...}replaces the plugin defaults. Symptoms: tests that worked yesterday suddenly fail because options liketest_id_attributearen't being passed through. Always spread the parent first. - Setting
is_mobile: Truewithouthas_touch: True. A "mobile" page that responds to mouse events isn't really emulating a phone — your CSS:hoverstyles fire when they shouldn't. Always set both flags together, or use a device preset that pairs them. - Running cross-browser tests serially in CI.
pytest --browser chromium --browser firefox --browser webkitruns every test 3 times sequentially by default. On a 200-test suite that's 3× wall time. Parallelise viapytest-xdist(-n auto) — covered properly in chapter 7.
🎯 Practice task
Run the same suite against three browsers and two device profiles. 25-30 minutes.
-
With your existing test file (e.g.,
tests/test_pom.pyfrom the previous lesson), run:pytest tests/test_pom.py -v --browser chromium --browser firefox --browser webkitEach test runs three times. Count the test ids:
[chromium],[firefox],[webkit]. -
Add a browser-specific skip:
import pytest @pytest.mark.skip_browser("webkit", reason="WebKit has different login error messaging") def test_locked_out_error(login_page: LoginPage): login_page.goto() login_page.login("locked_out_user", "secret_sauce") login_page.expect_error("locked out")Re-run all three browsers. The test runs on Chromium and Firefox, skips on WebKit (visible in the report).
-
Add mobile emulation. In
tests/conftest.pyadd a fixture (or update an existing override):import pytest @pytest.fixture(scope="session") def browser_context_args(browser_context_args, playwright): return { **browser_context_args, **playwright.devices["iPhone 13"], }Run the suite again. Tests now execute in iPhone 13 viewport with mobile UA. (Some Sauce Demo tests may fail because mobile layouts behave differently — that's exactly the kind of bug mobile emulation finds.)
-
Mix CLI and conftest. Override
browser_context_argsto useplaywright.devices["Desktop Chrome"]for desktop runs, and run--browser webkitto simulate Mac Safari. Notice: device presets are independent of the browser engine — you can mix. -
Stretch: add geolocation. Override
browser_context_argswithgeolocation: {longitude, latitude}andpermissions: ["geolocation"]. Find a public site that uses geolocation (e.g., a weather widget on a news site) and write a test that asserts the page detects your simulated location. Confirm the same test fails or shows different content when you change the coordinates.
You've got the multi-browser/mobile toolkit. The next lesson tackles authentication at scale — storage_state, session-scoped login, and the API-backed auth pattern that keeps a 500-test suite from re-logging-in 500 times.