Multi-Browser Testing — Chromium, Firefox, WebKit

8 min read

Most QA suites — Cypress, Selenium, anything else — pretend cross-browser testing exists, but in practice teams ship Chromium-only and pray. Playwright's signature feature is that running the exact same tests against Chromium, Firefox, and WebKit (Safari's engine) takes one config block and zero code changes. This lesson is the projects array, the device descriptors, the browser-conditional skipping pattern, and a CI strategy that doesn't make every PR run for 30 minutes. By the end you'll know exactly how to add Safari coverage to a suite that was Chromium-only — and why it matters.

The projects array — the single switch

Every multi-browser run is configured in the projects array of playwright.config.ts:

import { defineConfig, devices } from "@playwright/test";
 
export default defineConfig({
  testDir: "./tests",
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry"
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } }
  ]
});

That's it. Running npx playwright test now runs every spec three times — once per project. Three browser binaries, three contexts, three sets of test results. No code changes anywhere in your specs.

The devices["Desktop Chrome"] import is a preset that bundles viewport, user-agent, and platform settings appropriate for a desktop Chrome. Each preset spreads via ...devices[...], so you can override specific fields:

{
  name: "chromium",
  use: {
    ...devices["Desktop Chrome"],
    viewport: { width: 1920, height: 1080 } // override the default 1280x720
  }
}

Running specific browsers

Three flags cover every common case:

# All projects (default)
npx playwright test
 
# One specific project
npx playwright test --project=firefox
 
# Multiple specific projects
npx playwright test --project=chromium --project=webkit
 
# Negate (everything except WebKit)
npx playwright test --grep-invert "@webkit"

Local development tends to be Chromium-only for speed (--project=chromium); CI runs every project. Add the convenience to package.json:

{
  "scripts": {
    "test": "playwright test --project=chromium",
    "test:all": "playwright test",
    "test:firefox": "playwright test --project=firefox",
    "test:webkit": "playwright test --project=webkit"
  }
}

npm test is fast feedback locally; CI runs npm run test:all.

Mobile and desktop together

Playwright's devices list includes 100+ presets, so a single project array can mix desktop and mobile:

projects: [
  { name: "chromium", use: { ...devices["Desktop Chrome"] } },
  { name: "firefox", use: { ...devices["Desktop Firefox"] } },
  { name: "webkit", use: { ...devices["Desktop Safari"] } },
  { name: "Mobile Chrome", use: { ...devices["Pixel 5"] } },
  { name: "Mobile Safari", use: { ...devices["iPhone 13"] } },
  { name: "iPad", use: { ...devices["iPad (gen 7) landscape"] } }
];

Six configurations, same tests, six runs. A test that fails on Mobile Safari but passes on Desktop Safari immediately flags a viewport-specific bug. The next lesson covers mobile emulation in depth; for now, know that the mechanism is the same projects array.

To see every preset:

npx playwright show-devices

It prints a table — name, viewport, userAgent, deviceScaleFactor, isMobile, hasTouch — for every device Playwright ships.

Browser-conditional skipping

Most tests should pass on every browser. When one genuinely can't — a Chromium-only API like File System Access, a known Safari rendering bug — test.skip lets you opt out cleanly:

import { test, expect } from "@playwright/test";
 
test("uses File System Access API", async ({ page, browserName }) => {
  test.skip(browserName !== "chromium", "FSA API only available in Chromium");
  await page.goto("/file-picker");
  await page.getByRole("button", { name: "Open" }).click();
  // ...
});

test.skip(condition, reason) inside a test body skips at runtime if the condition is true. The reason shows up in the report — future-you and your teammates know why it's skipped without spelunking through git blame.

For tests that should only run on a specific browser, the inverse pattern:

test("Safari-specific scrollbar bug regression", async ({ page, browserName }) => {
  test.skip(browserName !== "webkit", "Bug only manifests in WebKit");
  // ...
});

A whole describe block can be skipped:

test.describe("File System Access flows", () => {
  test.skip(({ browserName }) => browserName !== "chromium", "FSA is Chromium-only");
 
  test("opens a file", async ({ page }) => { /* ... */ });
  test("saves a file", async ({ page }) => { /* ... */ });
});

Use skip sparingly — it hides coverage. If three quarters of your suite is skip on WebKit, the framework is telling you something different about your app's compatibility, not about the tests.

Differences you'll actually hit

Despite Playwright's promise of "same test, three browsers," real apps surface differences:

  • Date input handling. <input type="date"> opens a native picker on Chromium, a custom one on Firefox, and is text-only on Safari. If your test fills '2026-12-25', all three accept the value, but the rendered UI differs. Test the value, not the picker UI.
  • Font rendering and anti-aliasing. Visual snapshot tests (chapter 7) routinely fail on font hinting differences across OSes. Run snapshot tests inside the Playwright Docker image to pin the renderer.
  • Scrollbar widths. Safari's overlay scrollbars are zero pixels wide; Firefox's reserve width. A layout that hugs the viewport edges may look different per browser.
  • CSS support. New features (e.g., container queries, :has()) lag in WebKit by months. A test that depends on a specific layout will fail on WebKit until Safari catches up.
  • Clipboard API. Reading from the clipboard requires explicit permission; granting it is browser-specific. await context.grantPermissions(['clipboard-read']) works on Chromium; WebKit needs a different flag.

None of these are bugs in Playwright — they're real browser differences your users see. Catching them in CI before users do is the entire point of multi-browser testing.

How many browsers should you test?

The answer depends on your audience:

  • Internal-only B2B SaaS with a Chrome-mandated user base → Chromium is enough. Add Firefox or WebKit only if a customer specifically asks.
  • Consumer-facing web app → Chromium + WebKit is the floor. Chromium covers Chrome, Edge, Opera; WebKit covers Safari (a meaningful share on mobile). Firefox is nice-to-have.
  • High-stakes commerce or healthcare → All three desktop browsers, plus mobile Chrome and mobile Safari. Cross-browser regressions in checkout flows cost real money.

For most teams, two desktop browsers + one mobile is the sweet spot. You catch the most common compatibility bugs without 5x'ing your CI cost.

A typical CI run, visualised

Three projects, 60 tests each. The matrix view makes failures at the intersection obvious — one test failing on WebKit only is the kind of bug Chromium-only suites miss for weeks.

CI strategy — keep it fast

Running every project against every browser every PR is expensive. Two strategies that scale:

  • Smoke on PR, full on main. PRs run the suite against Chromium only (fast feedback). Merges to main trigger the full multi-browser run. Most regressions are caught early; cross-browser bugs are caught before deploy.
  • Sharded parallel projects. Each browser runs as a separate CI job, in parallel. A 200-test suite at 3 minutes per browser becomes a 3-minute multi-browser run when shards are parallelised — same wall-clock time as Chromium-only.

GitHub Actions config:

strategy:
  matrix:
    project: [chromium, firefox, webkit]
steps:
  - run: npx playwright test --project=${{ matrix.project }}

Three jobs run in parallel; GitHub aggregates the results. We'll cover this in chapter 8 — for now, know that browser parallelism is solved at the CI level, not the test level.

Coming from Cypress?

Cypress's cross-browser story is genuinely thinner:

  • Chrome, Edge, Firefox supported in production. WebKit is experimental at the time of writing.
  • No native concept of "one project per browser" — you pass --browser per run and re-run the whole suite.
  • No mobile emulation projects — viewport changes only.

If your Cypress suite has skipped iOS-Safari coverage because "Cypress doesn't support it well," that's the easiest single win of a Playwright migration. The same tests, plus WebKit, in one config block.

⚠️ Common mistakes

  • Running all projects locally on every change. Three browsers means three runs; on a 100-test suite that's a noticeable wait. Default to --project=chromium locally for fast feedback; reach for test:all when you specifically want cross-browser verification.
  • test.skip instead of fixing the bug. A genuinely browser-specific issue is rare; most "WebKit failures" are race conditions exposed by WebKit's slightly different timing. Read the failure carefully — often the fix is a await expect(...) instead of a snapshot, not a test.skip.
  • Hardcoding viewport-specific selectors. A test that depends on .desktop-only-button will fail on every mobile project. Use selectors based on accessibility role and label; let the responsive design handle viewport differences. If the test genuinely needs to verify desktop-only behaviour, scope it: { name: 'chromium-desktop', use: { viewport: { width: 1280, height: 720 } } } plus a test.skip(({ viewport }) => viewport && viewport.width < 768) inside the describe.

🎯 Practice task

Add multi-browser coverage to your existing Sauce Demo suite. 25-30 minutes.

  1. Update playwright.config.ts to declare all three desktop projects (if not already):

    import { defineConfig, devices } from "@playwright/test";
     
    export default defineConfig({
      testDir: "./tests",
      use: { baseURL: "https://www.saucedemo.com", trace: "on-first-retry" },
      projects: [
        { name: "chromium", use: { ...devices["Desktop Chrome"] } },
        { name: "firefox", use: { ...devices["Desktop Firefox"] } },
        { name: "webkit", use: { ...devices["Desktop Safari"] } },
        { name: "Mobile Chrome", use: { ...devices["Pixel 5"] } },
        { name: "Mobile Safari", use: { ...devices["iPhone 13"] } }
      ]
    });
  2. Run npx playwright test. The full inventory + checkout suite runs across all five configurations.

  3. Run one project at a time. npx playwright test --project=webkit runs WebKit only. npx playwright test --project="Mobile Safari" runs the iPhone emulation. Compare timings — single-project runs are 3-5x faster than running everything.

  4. Force a browser-specific failure. Add an assertion that's known to behave differently:

    test("scrollbar width", async ({ page, browserName }) => {
      await page.goto("/inventory.html");
      const width = await page.evaluate(() => window.innerWidth - document.documentElement.clientWidth);
      // Chromium/Firefox typically reserve scrollbar space; WebKit overlays
      console.log(`scrollbar width on ${browserName}: ${width}px`);
    });

    Run the test on all three desktops. The console output differs per browser — exactly the kind of cross-browser nuance multi-browser testing surfaces.

  5. Add browser-conditional skip. Add a test that uses navigator.clipboard.readText() (Chromium-only without permissions) and skip on Firefox/WebKit:

    test("clipboard read", async ({ page, browserName, context }) => {
      test.skip(browserName !== "chromium", "Clipboard API requires Chromium permissions");
      await context.grantPermissions(["clipboard-read", "clipboard-write"]);
      await page.goto("/inventory.html");
      await page.evaluate(() => navigator.clipboard.writeText("hello"));
      const result = await page.evaluate(() => navigator.clipboard.readText());
      expect(result).toBe("hello");
    });

    Run all browsers — Firefox and WebKit show the test as skipped with the reason; Chromium runs it.

  6. Stretch: open the HTML report (npm run report) after a multi-browser run. The report shows results grouped by project — click a single test and you see three side-by-side runs. This view is what makes "find the cross-browser regression" a 30-second task instead of a 30-minute investigation.

You now have multi-browser coverage as a one-config-block feature. The next lesson is the close cousin — mobile emulation and viewport-based responsive testing — using the same projects mechanism.

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