Test across Chromium, Firefox, and WebKit with Playwright projects, verify responsive layouts at multiple viewport sizes, and detect browser-specific rendering differences.
Your application runs in Chromium-based browsers, Firefox, and Safari. Layout bugs, CSS compatibility issues, and JavaScript API differences can cause features to work perfectly in Chrome and break completely in Safari. Playwright's project system makes running the same test across all three browser engines a configuration decision, not a code change.
This lesson covers cross-browser testing strategy: which tests to run on which browsers, how to verify responsive layouts at multiple viewports, and how to handle the few cases where browser behaviour genuinely differs.
1// playwright.config.ts — full browser matrix
2import { defineConfig, devices } from '@playwright/test';
3
4export default defineConfig({
5 projects: [
6 // Desktop browsers
7 {
8 name: 'chromium',
9 use: { ...devices['Desktop Chrome'] },
10 },
11 {
12 name: 'firefox',
13 use: { ...devices['Desktop Firefox'] },
14 },
15 {
16 name: 'webkit',
17 use: { ...devices['Desktop Safari'] },
18 },
19
20 // Mobile devices
21 {
22 name: 'mobile-chrome',
23 use: { ...devices['Pixel 7'] },
24 },
25 {
26 name: 'mobile-safari',
27 use: { ...devices['iPhone 14'] },
28 },
29
30 // Specific viewport sizes
31 {
32 name: 'tablet',
33 use: {
34 ...devices['Desktop Chrome'],
35 viewport: { width: 768, height: 1024 },
36 },
37 },
38 ],
39});
40
41// Run only specific projects:
42// npx playwright test --project=chromium
43// npx playwright test --project=chromium --project=firefox
44// npx playwright test --project=mobile-safari
45
46// In CI, run fewer projects to save time:
47projects: process.env['CI']
48 ? [
49 { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
50 { name: 'webkit', use: { ...devices['Desktop Safari'] } },
51 ]
52 : [
53 // Full matrix locally
54 { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
55 { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
56 { name: 'webkit', use: { ...devices['Desktop Safari'] } },
57 { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
58 { name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
59 ],1import { test, expect } from '@playwright/test';
2
3// Test at a specific viewport inside a test
4test('mobile navigation collapses to hamburger menu', async ({ page }) => {
5 // Override the project viewport for this specific test
6 await page.setViewportSize({ width: 375, height: 812 });
7 await page.goto('/');
8
9 // Desktop nav should be hidden
10 await expect(page.getByRole('navigation', { name: 'Main navigation' })
11 .getByRole('link', { name: 'Courses' })).not.toBeVisible();
12
13 // Hamburger should be visible
14 const hamburger = page.getByRole('button', { name: 'Open menu' });
15 await expect(hamburger).toBeVisible();
16
17 // Opening hamburger shows the links
18 await hamburger.click();
19 await expect(page.getByRole('link', { name: 'Courses' })).toBeVisible();
20});
21
22test('course grid shows 1 column on mobile, 3 on desktop', async ({ page }) => {
23 await page.goto('/courses');
24
25 // Mobile: single column
26 await page.setViewportSize({ width: 375, height: 812 });
27 const grid = page.getByRole('list', { name: 'Courses grid' });
28 const boundingBox = await grid.boundingBox();
29 const firstCard = grid.getByRole('listitem').first();
30 const firstCardBox = await firstCard.boundingBox();
31 // On mobile, card width should be close to viewport width
32 expect(firstCardBox!.width).toBeGreaterThan(300);
33
34 // Desktop: 3 columns
35 await page.setViewportSize({ width: 1440, height: 900 });
36 const firstCardBoxDesktop = await grid.getByRole('listitem').first().boundingBox();
37 // On desktop, card width should be roughly 1/3 of viewport
38 expect(firstCardBoxDesktop!.width).toBeLessThan(500);
39});
40
41// Test multiple breakpoints using test.each:
42const viewports = [
43 { name: 'mobile', width: 375, height: 812 },
44 { name: 'tablet', width: 768, height: 1024 },
45 { name: 'desktop', width: 1440, height: 900 },
46];
47
48for (const vp of viewports) {
49 test(`navigation is usable at ${vp.name} (${vp.width}px)`, async ({ page }) => {
50 await page.setViewportSize({ width: vp.width, height: vp.height });
51 await page.goto('/');
52 // At every breakpoint, users should be able to navigate to courses
53 if (vp.width < 768) {
54 await page.getByRole('button', { name: 'Open menu' }).click();
55 }
56 await page.getByRole('link', { name: 'Courses' }).click();
57 await expect(page).toHaveURL('/courses');
58 });
59}1import { test, expect } from '@playwright/test';
2
3// Skip on specific browsers
4test('CSS grid subgrid layout', async ({ page, browserName }) => {
5 // Skip on WebKit — subgrid has limited Safari support in older versions
6 test.skip(browserName === 'webkit', 'Subgrid not fully supported in this Safari version');
7
8 await page.goto('/courses');
9 // Test grid subgrid layout
10 const grid = page.getByTestId('course-grid');
11 await expect(grid).toBeVisible();
12});
13
14// Run only on specific browser
15test('Firefox-specific scrollbar styling', async ({ page, browserName }) => {
16 test.skip(browserName !== 'firefox', 'Firefox-only scrollbar CSS');
17 await page.goto('/courses');
18 // Test Firefox-specific CSS
19});
20
21// Use annotations for documentation
22test('payment form auto-fills on iOS Safari', async ({ page }) => {
23 test.info().annotations.push({
24 type: 'issue',
25 description: 'https://github.com/org/repo/issues/123',
26 });
27
28 // Test autofill behaviour
29});
30
31// Test-level browser detection:
32test('feature detection', async ({ page, browserName }) => {
33 if (browserName === 'webkit') {
34 // Safari-specific assertion
35 await expect(page.getByTestId('webkit-fallback')).toBeVisible();
36 } else {
37 // Chrome/Firefox assertion
38 await expect(page.getByTestId('modern-feature')).toBeVisible();
39 }
40});
41
42// Project-level tag filtering:
43// playwright.config.ts:
44// { name: 'chromium', use: { ...devices['Desktop Chrome'] },
45// grep: /^(?!.*@skip-chromium).*$/ }, // skip tests tagged @skip-chromium
46// In test file: test('@skip-chromium custom scroll behaviour', ...)1test('home page renders correctly on desktop', async ({ page }) => {
2 await page.goto('/');
3 // Wait for all images to load
4 await page.waitForLoadState('networkidle');
5
6 // Full page screenshot
7 await expect(page).toHaveScreenshot('home-desktop.png', {
8 fullPage: true,
9 maxDiffPixelRatio: 0.02, // allow 2% pixel difference
10 });
11});
12
13test('course card screenshot matches baseline', async ({ page }) => {
14 await page.goto('/courses');
15 const firstCard = page.getByRole('article').first();
16 await expect(firstCard).toHaveScreenshot('course-card.png');
17});
18
19// Update baselines:
20// npx playwright test --update-snapshots
21
22// In playwright.config.ts:
23use: {
24 // Set a threshold for pixel differences
25 expect: {
26 toHaveScreenshot: {
27 maxDiffPixelRatio: 0.02,
28 threshold: 0.3, // colour difference threshold (0-1)
29 },
30 },
31},
32
33// Masking dynamic content (timestamps, avatars) before comparison:
34test('dashboard matches baseline (masked)', async ({ page }) => {
35 await page.goto('/dashboard');
36 await expect(page).toHaveScreenshot('dashboard.png', {
37 mask: [
38 page.getByTestId('user-avatar'), // masks avatar image
39 page.getByTestId('last-login-time'), // masks timestamp
40 page.getByTestId('enrollment-date'), // masks dynamic date
41 ],
42 fullPage: true,
43 });
44});1test('swipe gesture dismisses mobile notification', async ({ page }) => {
2 await page.setViewportSize({ width: 375, height: 812 });
3 await page.goto('/dashboard');
4
5 // Trigger a notification
6 await page.evaluate(() => {
7 (window as any).showNotification({ message: 'Test notification' });
8 });
9
10 const notification = page.getByRole('status');
11 await expect(notification).toBeVisible();
12
13 // Simulate swipe to dismiss (touch events)
14 const box = await notification.boundingBox();
15 if (box) {
16 await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
17 // Swipe right
18 await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
19 await page.mouse.down();
20 await page.mouse.move(box.x + box.width / 2 + 200, box.y + box.height / 2);
21 await page.mouse.up();
22 }
23
24 await expect(notification).not.toBeVisible();
25});
26
27test('pinch-to-zoom on mobile image viewer', async ({ page }) => {
28 await page.setViewportSize({ width: 375, height: 812 });
29 await page.goto('/courses/1/media');
30
31 const image = page.getByRole('img', { name: 'Course diagram' });
32 const box = await image.boundingBox();
33
34 // Playwright touchscreen API for basic touch
35 await page.touchscreen.tap(box!.x + box!.width / 2, box!.y + box!.height / 2);
36
37 // Check zoom controls appeared
38 await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
39});
40
41// Test touch target sizes meet WCAG 2.5.5 (44x44px minimum):
42test('all touch targets are at least 44x44px on mobile', async ({ page }) => {
43 await page.setViewportSize({ width: 375, height: 812 });
44 await page.goto('/');
45
46 const buttons = page.getByRole('button');
47 const count = await buttons.count();
48 for (let i = 0; i < count; i++) {
49 const box = await buttons.nth(i).boundingBox();
50 if (box) {
51 expect(box.width).toBeGreaterThanOrEqual(44);
52 expect(box.height).toBeGreaterThanOrEqual(44);
53 }
54 }
55});A comprehensive cross-browser and responsive test suite for navigation, course grid layout, and mobile-specific interactions across three viewports and two browser engines.
1// e2e/responsive.spec.ts
2import { test, expect } from '@playwright/test';
3
4const BREAKPOINTS = [
5 { name: 'mobile', width: 375, height: 812, isMobile: true },
6 { name: 'tablet', width: 768, height: 1024, isMobile: false },
7 { name: 'desktop', width: 1440, height: 900, isMobile: false },
8] as const;
9
10test.describe('Responsive Navigation', () => {
11 for (const bp of BREAKPOINTS) {
12 test(`navigation works at ${bp.name} (${bp.width}px)`, async ({ page }) => {
13 await page.setViewportSize({ width: bp.width, height: bp.height });
14 await page.goto('/');
15
16 if (bp.isMobile) {
17 // Mobile: hamburger menu
18 const hamburger = page.getByRole('button', { name: 'Open menu' });
19 await expect(hamburger).toBeVisible();
20
21 // Desktop nav links hidden
22 const desktopNav = page.getByRole('navigation', { name: 'Main' });
23 await expect(desktopNav.getByRole('link', { name: 'Courses' })).not.toBeVisible();
24
25 // Open hamburger
26 await hamburger.click();
27 await expect(page.getByRole('link', { name: 'Courses' })).toBeVisible();
28 await page.getByRole('link', { name: 'Courses' }).click();
29 } else {
30 // Tablet/Desktop: inline navigation
31 await expect(page.getByRole('navigation', { name: 'Main' })
32 .getByRole('link', { name: 'Courses' })).toBeVisible();
33 await page.getByRole('navigation', { name: 'Main' })
34 .getByRole('link', { name: 'Courses' }).click();
35 }
36
37 await expect(page).toHaveURL('/courses');
38 });
39 }
40});
41
42test.describe('Course Grid Layout', () => {
43 test.beforeEach(async ({ page }) => {
44 await page.goto('/courses');
45 });
46
47 test('grid renders single column on mobile (375px)', async ({ page }) => {
48 await page.setViewportSize({ width: 375, height: 812 });
49 await page.reload();
50 const cards = page.getByRole('article');
51 await expect(cards.first()).toBeVisible();
52 const firstBox = await cards.first().boundingBox();
53 // Single column: card almost fills viewport width
54 expect(firstBox!.width).toBeGreaterThan(300);
55 });
56
57 test('grid renders 2 columns on tablet (768px)', async ({ page }) => {
58 await page.setViewportSize({ width: 768, height: 1024 });
59 await page.reload();
60 const cards = page.getByRole('article');
61 await expect(cards.nth(1)).toBeVisible();
62 const firstBox = await cards.nth(0).boundingBox();
63 const secondBox = await cards.nth(1).boundingBox();
64 // Two columns: cards are side by side (different x positions)
65 expect(Math.abs(firstBox!.y - secondBox!.y)).toBeLessThan(10); // same row
66 expect(firstBox!.x).not.toBeCloseTo(secondBox!.x, 0); // different columns
67 });
68
69 test('grid renders 3 columns on desktop (1440px)', async ({ page }) => {
70 await page.setViewportSize({ width: 1440, height: 900 });
71 await page.reload();
72 const firstBox = await page.getByRole('article').nth(0).boundingBox();
73 // Three columns: each card is less than 480px wide (1440/3)
74 expect(firstBox!.width).toBeLessThan(480);
75 });
76});
77
78test.describe('Mobile-Specific Interactions', () => {
79 test.beforeEach(async ({ page }) => {
80 await page.setViewportSize({ width: 375, height: 812 });
81 });
82
83 test('course filter drawer opens from bottom on mobile', async ({ page }) => {
84 await page.goto('/courses');
85 await page.getByRole('button', { name: 'Filter courses' }).click();
86
87 const drawer = page.getByRole('dialog', { name: 'Filter courses' });
88 await expect(drawer).toBeVisible();
89
90 // Drawer should slide from bottom on mobile
91 const drawerBox = await drawer.boundingBox();
92 expect(drawerBox!.y).toBeGreaterThan(200); // not at top of screen
93 });
94
95 test('all interactive elements meet 44x44px touch target requirement', async ({ page }) => {
96 await page.goto('/');
97 const buttons = page.getByRole('button');
98 const count = await buttons.count();
99
100 for (let i = 0; i < Math.min(count, 10); i++) {
101 const button = buttons.nth(i);
102 if (await button.isVisible()) {
103 const box = await button.boundingBox();
104 if (box) {
105 expect(
106 box.width >= 44 && box.height >= 44,
107 `Button at index ${i} is ${box.width}x${box.height} — below 44x44px minimum`
108 ).toBe(true);
109 }
110 }
111 }
112 });
113});The loop over BREAKPOINTS generates three tests automatically from one test body, keeping the responsive test matrix DRY. The grid column test uses bounding box comparison (cards at the same y = same row, different x = different columns) rather than CSS assertions, which is more resilient to implementation changes. The touch target test is a lightweight accessibility check that runs in the mobile viewport and verifies WCAG 2.5.5 compliance for the most important interactive elements.
The next lesson covers visual regression testing in depth: setting up Playwright's screenshot comparison, managing baseline updates in Git, integrating with Percy or Chromatic for cloud-based visual diffs.
Your progress is saved in this browser, on this device only.