Playwright Cross-Browser Testing: Run the Same Test on Chromium, Firefox, and WebKit
Playwright ships with three browser engines out of the box: Chromium (which backs Chrome, Edge, and Opera), Firefox, and WebKit (which backs Safari on iOS and macOS). A single Playwright test file runs unmodified against all three. That's the promise. The reality is slightly more nuanced — browser-specific quirks exist, CI configuration needs care, and parallelizing across browsers without burning through minutes requires deliberate setup.
This guide covers the full picture: browser project configuration, handling per-browser differences in test code, CI matrix strategies, and making sense of multi-browser test reports.
The Browser Project Configuration
Everything starts in playwright.config.ts. The projects array defines which browsers (and browser configurations) your test suite targets:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [
['html'],
['junit', { outputFile: 'results.xml' }]
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile browsers
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 13'] },
},
],
});The devices object from @playwright/test provides pre-configured viewport, user agent, and device scale factor for dozens of real devices. devices['iPhone 13'] sets WebKit as the engine, mobile viewport (390×844), and touch events enabled — all in one line.
Running the full matrix:
npx playwright test # all projects
npx playwright test --project=chromium # single browser
npx playwright test --project=chromium --project=firefox # two browsersParallelism Strategy
By default, Playwright runs test files in parallel but executes tests within a file sequentially. With multiple browser projects, you multiply the total test count: 100 tests × 3 browsers = 300 test runs. Without careful parallelism configuration, this will either be slow or overwhelm your CI runner.
fullyParallel: true enables parallel execution within files, not just across files. This is the single biggest speed lever for multi-browser suites.
workers controls concurrency. In CI, setting this to 4 or even 8 (if your runner has the cores) makes a large difference. Locally, leave it undefined to use Playwright's default (half the logical CPU count).
Sharding lets you split the test suite across multiple CI jobs:
# Job 1: run 1/4 of tests
npx playwright test --shard=1/4
# Job 2: run 2/4 of tests
npx playwright test --shard=2/4This is the most effective way to keep CI times under 10 minutes for large suites. Combine sharding with browser projects and you can run the full matrix in parallel across multiple machines.
Browser-Specific Quirks and How to Handle Them
Writing one test that runs identically on three browser engines sounds clean. In practice, a handful of scenarios require per-browser handling.
Clipboard API
The Clipboard API behavior differs significantly across browsers. Chrome requires explicit permission grants; WebKit in Playwright has different clipboard isolation.
test('copy to clipboard', async ({ page, browserName }) => {
await page.goto('/dashboard');
await page.click('[data-testid="copy-api-key"]');
if (browserName === 'chromium') {
// Chromium: read clipboard directly
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboardText).toBe('test-api-key-123');
} else {
// Firefox/WebKit: verify the visual feedback instead
await expect(page.locator('[data-testid="copy-success-toast"]')).toBeVisible();
}
});The browserName fixture gives you 'chromium', 'firefox', or 'webkit'. Use it sparingly — if you find yourself branching on browserName for core business logic tests, that's a signal the feature itself has a compatibility issue worth surfacing.
File Download Handling
test('export CSV', async ({ page }) => {
await page.goto('/reports');
const downloadPromise = page.waitForEvent('download');
await page.click('[data-testid="export-csv"]');
const download = await downloadPromise;
// This works across all three browsers
expect(download.suggestedFilename()).toBe('report-2024.csv');
// Save and verify contents
const path = await download.path();
const content = await fs.readFile(path, 'utf-8');
expect(content).toContain('user_id,email,created_at');
});Playwright's download API is consistent across browsers. The underlying browser behavior for initiating downloads varies, but Playwright abstracts it.
Input Events and Keyboard
WebKit has historically had differences in how it dispatches keydown, keypress, and keyup events — particularly for special keys like Tab, Enter, and arrow keys in form inputs.
test('keyboard navigation in datepicker', async ({ page, browserName }) => {
await page.goto('/booking');
await page.focus('[data-testid="checkin-date"]');
// Arrow key navigation: works cross-browser via Playwright's keyboard API
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Enter');
// WebKit may need a small pause for event propagation in complex date pickers
if (browserName === 'webkit') {
await page.waitForTimeout(100);
}
await expect(page.locator('[data-testid="checkin-date"]')).toHaveValue('2024-02-15');
});CSS and Layout Assertions
Visual positioning and layout can differ slightly between Chromium and WebKit due to font rendering and box model subtleties. Avoid asserting exact pixel positions in cross-browser tests. Prefer semantic assertions:
// Fragile: pixel-exact position breaks across browsers
const box = await element.boundingBox();
expect(box.x).toBe(24); // Don't do this
// Robust: structural/semantic assertions
await expect(page.locator('.dropdown-menu')).toBeVisible();
await expect(page.locator('.dropdown-menu')).toContainText('Profile');
await expect(page.locator('.dropdown-menu')).toContainText('Logout');Tagging Tests for Selective Browser Runs
Not every test needs to run on every browser. Authentication flows, API integration tests, and backend-heavy scenarios are browser-agnostic. Only UI interaction tests and visual rendering tests genuinely benefit from multi-browser coverage.
Use Playwright's @ tag syntax and the grep flag:
test('payment form - credit card validation @cross-browser', async ({ page }) => {
// This test runs on all browsers
});
test('API authentication flow @chromium-only', async ({ page }) => {
// This test should only run on one browser to avoid 3× the time with 0× the benefit
});Then in your CI config, you can run tagged subsets:
# Run only cross-browser tagged tests
npx playwright test --grep "@cross-browser"
# Skip expensive tests on PR checks, run full matrix nightly
npx playwright test --grep-invert "@slow"CI Matrix Configuration
GitHub Actions — Full Browser Matrix
name: Playwright Cross-Browser Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Tests (${{ matrix.browser }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Run tests
run: npx playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}/4
env:
BASE_URL: ${{ secrets.STAGING_URL }}
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-results-${{ matrix.browser }}-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
merge-reports:
needs: test
runs-on: ubuntu-latest
if: always()
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Download all reports
uses: actions/download-artifact@v4
with:
path: all-reports/
pattern: playwright-results-*
- name: Merge reports
run: npx playwright merge-reports --reporter html ./all-reports
- name: Upload merged report
uses: actions/upload-artifact@v4
with:
name: playwright-merged-report
path: playwright-report/This matrix runs 12 parallel jobs (3 browsers × 4 shards). A test suite that would take 40 minutes sequentially finishes in about 5 minutes.
PR-Level vs Nightly Strategy
Running a 12-job matrix on every PR is expensive and slow. A practical tiered approach:
On every PR:
- Run Chromium only (fastest, catches most issues)
- Full test suite, no sharding needed for most codebases
On merge to main:
- Run all 3 browsers
- Sharded across 4 runners per browser
Nightly on main:
- Full matrix including mobile browsers (Pixel 5, iPhone 13)
- Cloud browser tests against BrowserStack or similar for IE-era browsers if needed
# Conditional browser selection
- name: Set browser matrix
id: matrix
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "browsers=[\"chromium\"]" >> $GITHUB_OUTPUT
else
echo "browsers=[\"chromium\",\"firefox\",\"webkit\"]" >> $GITHUB_OUTPUT
fiReading Multi-Browser Reports
Playwright's HTML reporter groups results by test name across browser projects. When a test fails on Firefox but passes on Chromium and WebKit, the report shows three rows for that test with different status icons — this is immediately readable.
For CI, the JUnit XML output integrates with GitHub's test summary:
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: Playwright Results - ${{ matrix.browser }}
path: results.xml
reporter: java-junitThis gives you per-browser pass/fail counts directly in the GitHub PR view, without opening the full HTML report.
Common Cross-Browser Failures and Their Root Causes
"Element is not visible" on WebKit but not Chromium: WebKit's intersection observer behavior differs slightly. Elements near the viewport edge may be considered not visible. Add explicit scroll steps or use force: true judiciously.
Dialog/alert handling on Firefox: Firefox handles window.confirm timing differently. Always use Playwright's page.on('dialog', ...) handler rather than relying on auto-dismiss.
CSS position: sticky in test assertions: Sticky positioning interacts differently with Playwright's scroll position and visibility detection across browsers. Structure sticky-element tests to scroll to a known position first.
Font metrics causing text truncation: Chromium and WebKit use different font metrics for the same system fonts, causing text to truncate at different lengths. This manifests as "element contains text" assertions failing on one browser. Fix in application code with explicit overflow: hidden and text-overflow: ellipsis where needed.
Integrating with HelpMeTest
Teams using HelpMeTest's Robot Framework + Playwright integration get multi-browser testing as a first-class feature. You define your test scenarios once, and HelpMeTest's automation layer runs them across your configured browser projects. HelpMeTest's usage-based pricing doesn't charge extra per browser — you pay $0.003 per test run regardless of which browser it targets, so your test matrix is your matrix, not a billing line item.
The Robot Framework layer handles the boilerplate of browser project configuration, CI setup, and report aggregation, letting your team focus on writing tests rather than maintaining the infrastructure around them.
Key Takeaways
- Start with
playwright.config.tsprojects — three browsers, zero extra code in your tests - Use
fullyParallel: trueand sharding to keep CI times manageable at scale - Branch on
browserNameonly for genuine browser API differences, not for business logic - Run Chromium on PRs, full matrix on merge, extended mobile matrix nightly
- Semantic assertions are cross-browser by nature; pixel-exact assertions are not
- Multi-browser testing catches real bugs that single-browser testing misses — but the marginal return is highest for UI interaction and rendering tests, not API integration tests