Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 096 / 100

Testing PWAs: what every Playwright developer should know

Installable apps and offline mode.

Expona Admin8 min read

Progressive Web Apps are no longer a nice-to-have—they're production-critical for many teams, especially in e-commerce and travel where connectivity varies and installation matters. Yet testing PWAs with Playwright is often overlooked, leaving offline scenarios, service worker updates, and installation flows untested in CI/CD pipelines. Testing PWAs: what every Playwright developer should know covers the gaps between standard web testing and the unique demands of installable, offline-capable applications.

Check the manifest

A PWA begins with its manifest file—a JSON document that tells the browser how to install and display your app. Without a valid manifest, your app won't be installable on Android or desktop, and Playwright tests that skip manifest validation miss a critical failure mode that end users will hit immediately.

The manifest should live at /manifest.json or be linked in your HTML head. Start by fetching and validating its structure before running any offline or installation tests. A malformed or missing manifest is the most common reason PWAs fail in production.

typescript
import { test, expect } from '@playwright/test';

test('PWA manifest exists and is valid', async ({ page, context }) => {
  await page.goto('https://app.example.com');
  
  // Fetch the manifest link from the page
  const manifestHref = await page.evaluate(() => {
    const link = document.querySelector('link[rel="manifest"]');
    return link ? link.getAttribute('href') : null;
  });
  
  expect(manifestHref).toBeTruthy();
  
  // Fetch and parse the manifest
  const response = await page.request.get(
    new URL(manifestHref || '/manifest.json', page.url()).href
  );
  expect(response.ok()).toBeTruthy();
  
  const manifest = await response.json();
  
  // Validate required fields
  expect(manifest.name).toBeTruthy();
  expect(manifest.short_name).toBeTruthy();
  expect(manifest.start_url).toBeTruthy();
  expect(manifest.display).toBe('standalone');
  expect(manifest.icons).toHaveLength(manifest.icons.length > 0 ? manifest.icons.length : 1);
  
  // Validate at least one icon is present and reachable
  for (const icon of manifest.icons) {
    const iconResponse = await page.request.head(
      new URL(icon.src, page.url()).href
    );
    expect(iconResponse.ok()).toBeTruthy();
  }
});

This test confirms that your manifest is discoverable, parseable as JSON, and contains the required fields for installation. In a real scenario, we've caught typos in icon paths, missing start_url, and incorrect display modes that broke the installation experience entirely.

Test offline pages

Offline testing is where Playwright's browser automation shines. Service workers cache resources and serve them when the network is unavailable. Your test must verify that pages load and function without connectivity—not just that they don't crash.

Playwright provides the page.route() API to intercept all network requests. By aborting them, we simulate offline mode. The key is to test real user flows: navigation, form interaction, and data display—not just whether a page loads.

typescript
test('offline mode: cached pages load and are interactive', async ({ page }) => {
  // First, load the page online to populate the cache
  await page.goto('https://app.example.com/dashboard');
  await page.waitForLoadState('networkidle');
  
  // Verify the page loaded some content
  await expect(page.locator('[data-testid="dashboard-title"]')).toBeVisible();
  
  // Now simulate offline by aborting all network requests
  await page.route('**/*', route => route.abort());
  
  // Reload the page while offline
  await page.reload();
  await page.waitForLoadState('networkidle');
  
  // Verify cached content still displays
  await expect(page.locator('[data-testid="dashboard-title"]')).toBeVisible();
  
  // Verify some functionality works (read-only operations)
  const items = await page.locator('[data-testid="dashboard-item"]');
  await expect(items).toHaveCount(await items.count());
  
  // Verify that write operations queue or show a message
  const button = page.locator('[data-testid="save-button"]');
  if (await button.isVisible()) {
    await button.click();
    // Expect a "queued" or "offline" message
    await expect(
      page.locator('text=Syncing when online')
    ).toBeVisible({ timeout: 2000 });
  }
});

This test covers a critical scenario: the user loads your app online, then loses connectivity. Their cached pages still render, and interactive elements either degrade gracefully or queue changes. Many teams skip this and only test the happy path—online, fast network, fresh load.

Verify service worker updates

Service workers are persistent background processes. Once installed, they serve cached content and periodically check for updates. A broken or outdated service worker can leave users on a stale version of your app for days or weeks. Testing updates is essential for shipping fixes in production.

Playwright's context API allows you to control the browser's cache and persistent storage. By simulating a service worker update, we verify that users see new code and data after the update cycle completes.

typescript
test('service worker updates are detected and applied', async ({ browser }) => {
  // First context: user loads the app with version 1
  const context1 = await browser.newContext();
  const page1 = await context1.newPage();
  
  await page1.goto('https://app.example.com');
  await page1.waitForLoadState('networkidle');
  
  // Get the current service worker version from the page or a meta tag
  const version1 = await page1.evaluate(() => {
    return navigator.serviceWorker?.controller?.scriptURL || 'unknown';
  });
  
  console.log('Initial SW version:', version1);
  
  // Simulate a service worker update by checking for updates
  const updatePromise = page1.evaluate(() => {
    return navigator.serviceWorker?.ready.then(reg => {
      return new Promise((resolve) => {
        reg.addEventListener('updatefound', () => {
          resolve('update-found');
        });
        reg.update();
        // Timeout if no update is found
        setTimeout(() => resolve('no-update'), 3000);
      });
    });
  });
  
  const updateResult = await updatePromise;
  
  if (updateResult === 'update-found') {
    console.log('Service worker update detected');
    
    // Wait for the new service worker to activate
    await page1.evaluate(() => {
      return navigator.serviceWorker?.ready.then(reg => {
        return new Promise((resolve) => {
          if (reg.active && reg.active !== reg.installing) {
            resolve('activated');
          } else {
            reg.addEventListener('controllerchange', () => resolve('activated'));
          }
          setTimeout(() => resolve('timeout'), 5000);
        });
      });
    });
    
    // Reload to use the new service worker
    await page1.reload();
    await page1.waitForLoadState('networkidle');
    
    const version2 = await page1.evaluate(() => {
      return navigator.serviceWorker?.controller?.scriptURL || 'unknown';
    });
    
    console.log('Updated SW version:', version2);
    
    // Verify new content or behavior is available
    await expect(
      page1.locator('[data-testid="app-version"]')
    ).toContainText('2');
  }
  
  await context1.close();
});

This test walks through a realistic update flow. Users often keep tabs open for hours or days; we verify they get the updated service worker and new assets without losing data or session state. This catches silent failures where the update mechanism exists but doesn't actually apply new code.

Use Chromium for PWA features

Playwright supports multiple browsers, but PWA features are most reliable on Chromium. Firefox and WebKit have partial or no support for service workers, installability, and offline APIs. If you're testing PWA functionality, run your tests against Chromium (the basis for Chrome and Edge) to ensure production behavior.

Set up your Playwright config to use Chromium for PWA-specific tests, while keeping cross-browser tests for non-PWA features.

typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'chromium-pwa',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'chromium-general',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox-general',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit-general',
      use: { ...devices['Desktop Safari'] },
    },
  ],
});

Then, tag your PWA tests and filter them to run only on Chromium.

typescript
test('PWA manifest check @pwa @chromium-only', async ({ page, browserName }) => {
  test.skip(browserName !== 'chromium', 'PWA features only in Chromium');
  
  await page.goto('https://app.example.com');
  // ... manifest validation
});

Run PWA tests explicitly: npx playwright test --grep @pwa. This prevents false failures on Firefox or WebKit where service workers may not behave identically to Chromium.

Common mistakes

  • Not waiting for service worker registration: Playwright may reload or navigate before the service worker is active. Always use navigator.serviceWorker.ready to ensure it's in place before testing offline behavior.
  • Testing offline without warming the cache: Load the page online first to populate the cache, then simulate offline. Testing a fresh, uncached page offline only tests your fallback UI, not your cache strategy.
  • Ignoring manifest validation: A missing or malformed manifest breaks installation silently. Many teams only notice when users can't install the app from their app store.
  • Running PWA tests on Firefox or WebKit: Service workers and installability vary significantly across browsers. Chromium is the reliable base; cross-browser tests should cover the web surface, not PWA infrastructure.
  • Not testing data sync after offline: It's tempting to verify only that pages load offline. Real testing involves queuing writes, reconnecting, and verifying they sync. Skipping this misses race conditions and data loss bugs.

Key takeaways

  • Validate the manifest file as a first test—it's the contract between your app and the browser's install flow.
  • Simulate offline by routing and aborting network requests; load pages online first to warm the cache, then test read and write behavior without connectivity.
  • Verify service worker updates by simulating the update cycle and confirming new code is active after a reload.
  • Run PWA-specific tests on Chromium only, since Firefox and WebKit don't fully support service workers, installation, and related APIs.
  • Test end-to-end flows: offline reads, queued writes, reconnection, and sync—not just that pages don't crash.
  • Include manifest checks, cache validation, and service worker activation in your CI/CD pipeline to catch PWA regressions early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles