Multiple browsers, devices and emulation

Run the same tests on Chromium, Firefox and WebKit projects, emulate a phone properly, and control locale, timezone, permissions and geolocation.

One suite, several browsers

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },

    { name: "mobile-safari", use: { ...devices["iPhone 15"] } },
    { name: "mobile-chrome", use: { ...devices["Pixel 8"] } },
  ],
});
npx playwright test --project=chromium
npx playwright test --project=webkit --project=firefox
npx playwright install --with-deps        # fetch browser binaries and OS deps
  • WebKit is not Safari, but it is the same engine, and it catches most engine-specific layout and API differences before a release.
  • Run the full matrix nightly and a single browser on every pull request; three browsers on every commit triples the feedback time for a small amount of extra signal.
  • A device descriptor sets viewport, user agent, device scale factor, touch support and default browser together.
  • Project names appear in the report and in the snapshot path, so a baseline is per project automatically.
💡
Playwright's mobile emulation runs a desktop browser with mobile parameters - it is not a real device. Use it to catch responsive and touch-specific bugs cheaply, and keep a small real-device check for anything that depends on actual hardware behaviour.

Locale, timezone and colour scheme

test.use({
  locale: "de-DE",
  timezoneId: "Europe/Berlin",
  colorScheme: "dark",
  viewport: { width: 390, height: 844 },
  deviceScaleFactor: 3,
  hasTouch: true,
  isMobile: true,
});

test("formats prices for the German market", async ({ page }) => {
  await page.goto("/pricing");
  await expect(page.getByTestId("price")).toContainText("1.234,56");
});
OptionControls
localeAccept-Language and navigator.language
timezoneIdDate formatting and Date.getTimezoneOffset()
colorSchemeprefers-color-scheme
reducedMotionprefers-reduced-motion
forcedColorsHigh-contrast mode
deviceScaleFactorDevice pixel ratio, for image sizing
hasTouch / isMobileTouch events and viewport meta behaviour
// per-test overrides when you only need one variation
test.describe("light theme", () => {
  test.use({ colorScheme: "light" });
  test("renders a readable contrast ratio", async ({ page }) => { /* ... */ });
});

Permissions and geolocation

test.use({
  permissions: ["geolocation", "notifications"],
  geolocation: { latitude: 51.5074, longitude: -0.1278 },
  locale: "en-GB",
});

test("shows the nearest store", async ({ page, context }) => {
  await page.goto("/stores");
  await expect(page.getByText("London")).toBeVisible();

  // grant or revoke at runtime
  await context.grantPermissions(["clipboard-read", "clipboard-write"]);
  await context.clearPermissions();
});

test("handles a denied permission", async ({ page, context }) => {
  await context.clearPermissions();
  await page.goto("/stores");
  await expect(page.getByRole("alert")).toContainText("location");
});
  1. Grant only the permissions the test needs; a blanket grant hides the denied-permission path, which is the one that usually breaks.
  2. Always test the denied case. A page that shows a blank map with no message when location is refused is a real defect that a granted-permission test cannot find.
  3. Set geolocation through the context, not by injecting script - the page then sees a consistent API result.
  4. Combine locale and timezone with geolocation when the feature is location-aware, or you will test a combination no user has.
// a second context with different permissions, in one test
test("compares two regions", async ({ browser }) => {
  const uk = await browser.newContext({ locale: "en-GB", timezoneId: "Europe/London" });
  const us = await browser.newContext({ locale: "en-US", timezoneId: "America/New_York" });

  const ukPage = await uk.newPage();
  const usPage = await us.newPage();
  await ukPage.goto("/pricing");
  await usPage.goto("/pricing");

  await expect(ukPage.getByTestId("price")).toContainText("GBP");
  await expect(usPage.getByTestId("price")).toContainText("USD");

  await uk.close();
  await us.close();
});

FAQ

Do I need to test in all three browsers?
Run the full suite in one browser on every pull request, and the full matrix nightly. Engine bugs are real but rare relative to the cost of tripling your feedback loop; the nightly run still catches them before release.
Why does my test pass in Chromium and fail in WebKit?
Common causes are a CSS feature WebKit does not support, a timing difference in how an animation settles, and API behaviour such as date formatting or storage limits. Open the trace for the WebKit project - the difference is usually visible in the failing step.

Visual comparisons, screenshots and video Component testing and advanced configuration

Last refreshed 2026-09-18.