Multi-Browser and Mobile Emulation

8 min read

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 webkit

Every 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 firefox

Pin the default in pytest.ini for everyday development:

[pytest]
addopts = --browser chromium --headed

CI 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 verify

only_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:

  1. viewport — the size the page thinks the screen is (375×667 = iPhone SE).
  2. is_mobile — flips the page into mobile-friendly mode (some apps key off this).
  3. has_touch — replaces mouse events with touch events.
  4. user_agent — some apps serve different markup based on UA.
  5. 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 — fakes navigator.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 — the Accept-Language header and navigator.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". Affects prefers-color-scheme CSS media queries — flips your dark mode tests on without manual toggling.

Browser × device coverage matrix

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 } → Python browser_context_args fixture
  • TS test.skip(browserName === 'webkit', '...') → Python @pytest.mark.skip_browser("webkit", reason="...")
  • TS devices['iPhone 13'] → Python playwright.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_args when overriding it.** Returning {"viewport": ...} instead of {**browser_context_args, "viewport": ...} replaces the plugin defaults. Symptoms: tests that worked yesterday suddenly fail because options like test_id_attribute aren't being passed through. Always spread the parent first.
  • Setting is_mobile: True without has_touch: True. A "mobile" page that responds to mouse events isn't really emulating a phone — your CSS :hover styles 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 webkit runs every test 3 times sequentially by default. On a 200-test suite that's 3× wall time. Parallelise via pytest-xdist (-n auto) — covered properly in chapter 7.

🎯 Practice task

Run the same suite against three browsers and two device profiles. 25-30 minutes.

  1. With your existing test file (e.g., tests/test_pom.py from the previous lesson), run:

    pytest tests/test_pom.py -v --browser chromium --browser firefox --browser webkit

    Each test runs three times. Count the test ids: [chromium], [firefox], [webkit].

  2. 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).

  3. Add mobile emulation. In tests/conftest.py add 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.)

  4. Mix CLI and conftest. Override browser_context_args to use playwright.devices["Desktop Chrome"] for desktop runs, and run --browser webkit to simulate Mac Safari. Notice: device presets are independent of the browser engine — you can mix.

  5. Stretch: add geolocation. Override browser_context_args with geolocation: {longitude, latitude} and permissions: ["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.

// tip to track lessons you complete and pick up where you left off across devices.