QA Engineer Skills 2026QA-2026PWA Testing

PWA Testing

The PWA Testing Challenge

Progressive Web Apps bridge the gap between web and native, but that bridge introduces unique testing challenges that neither pure web testing nor pure mobile testing covers. A PWA must work online, offline, and in degraded network conditions. It must install like a native app, send push notifications, and sync data when connectivity returns.


Service Worker Testing

Service workers intercept network requests and enable offline functionality. They operate in a lifecycle that must be tested independently:

// tests/pwa/service-worker.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Service Worker Lifecycle', () => {
    test('service worker registers on first visit', async ({ page }) => {
        await page.goto('/');

        // Wait for SW registration
        const swRegistered = await page.evaluate(async () => {
            const registration = await navigator.serviceWorker.ready;
            return registration.active !== null;
        });
        expect(swRegistered).toBe(true);
    });

    test('app works offline after caching', async ({ page, context }) => {
        // First visit: let the SW cache resources
        await page.goto('/');
        await page.waitForLoadState('networkidle');

        // Verify critical resources are cached
        const cachedUrls = await page.evaluate(async () => {
            const cache = await caches.open('app-shell-v1');
            const keys = await cache.keys();
            return keys.map(k => new URL(k.url).pathname);
        });
        expect(cachedUrls).toContain('/');
        expect(cachedUrls).toContain('/styles/main.css');
        expect(cachedUrls).toContain('/scripts/app.js');

        // Go offline
        await context.setOffline(true);

        // Navigate -- should work from cache
        await page.goto('/');
        await expect(page.locator('h1')).toBeVisible();

        // Verify offline indicator is shown
        await expect(page.locator('[data-testid="offline-banner"]')).toBeVisible();

        // Restore connectivity
        await context.setOffline(false);
    });

    test('stale content is updated when back online', async ({ page, context }) => {
        await page.goto('/');
        await page.waitForLoadState('networkidle');

        // Go offline, navigate
        await context.setOffline(true);
        await page.goto('/dashboard');
        const offlineContent = await page.locator('[data-testid="data-timestamp"]').textContent();

        // Come back online
        await context.setOffline(false);
        await page.reload();
        await page.waitForLoadState('networkidle');
        const onlineContent = await page.locator('[data-testid="data-timestamp"]').textContent();

        // Content should be fresher after reconnection
        expect(onlineContent).not.toBe(offlineContent);
    });

    test('service worker updates when new version deployed', async ({ page }) => {
        await page.goto('/');
        await page.waitForLoadState('networkidle');

        // Check if the SW detects a new version
        const updateAvailable = await page.evaluate(async () => {
            const reg = await navigator.serviceWorker.getRegistration();
            return new Promise((resolve) => {
                if (reg?.waiting) {
                    resolve(true);
                    return;
                }
                reg?.addEventListener('updatefound', () => {
                    resolve(true);
                });
                setTimeout(() => resolve(false), 5000);
            });
        });

        // If an update is available, verify the update prompt appears
        if (updateAvailable) {
            await expect(page.locator('[data-testid="update-prompt"]')).toBeVisible();
        }
    });
});

Push Notification Testing

// tests/pwa/push-notifications.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Push Notifications', () => {
    test('requests notification permission on first interaction', async ({ page, context }) => {
        // Grant notification permission
        await context.grantPermissions(['notifications']);

        await page.goto('/');
        await page.click('[data-testid="enable-notifications-btn"]');

        // Verify subscription was created
        const subscription = await page.evaluate(async () => {
            const reg = await navigator.serviceWorker.ready;
            const sub = await reg.pushManager.getSubscription();
            return sub !== null;
        });
        expect(subscription).toBe(true);
    });

    test('displays notification when push event received', async ({ page }) => {
        await page.goto('/');

        // Simulate push via service worker
        const notificationShown = await page.evaluate(async () => {
            const reg = await navigator.serviceWorker.ready;
            await reg.showNotification('Test Order Update', {
                body: 'Your order #123 has shipped',
                icon: '/icons/notification-icon.png',
                actions: [
                    { action: 'view', title: 'View Order' },
                    { action: 'dismiss', title: 'Dismiss' }
                ]
            });
            return true;
        });
        expect(notificationShown).toBe(true);
    });

    test('handles notification permission denial gracefully', async ({ page, context }) => {
        // Deny notification permission
        await context.grantPermissions([]);

        await page.goto('/');

        // The enable button should still be present
        const enableBtn = page.locator('[data-testid="enable-notifications-btn"]');
        await expect(enableBtn).toBeVisible();

        // Clicking it should show a fallback message, not crash
        await enableBtn.click();
        await expect(page.locator('[data-testid="notification-denied-message"]')).toBeVisible();
    });
});

Install Prompt Testing

The PWA install prompt has strict criteria. Testing that your app meets them ensures users can install it as a native-like app.

test('install prompt appears for eligible users', async ({ page }) => {
    // PWA install criteria: HTTPS, valid manifest, service worker, engagement heuristic
    await page.goto('/');

    // Verify manifest is linked and valid
    const manifestLink = await page.locator('link[rel="manifest"]');
    await expect(manifestLink).toHaveAttribute('href', '/manifest.json');

    // Fetch and validate the manifest
    const manifest = await page.evaluate(async () => {
        const res = await fetch('/manifest.json');
        return res.json();
    });

    expect(manifest.name).toBeTruthy();
    expect(manifest.short_name).toBeTruthy();
    expect(manifest.start_url).toBeTruthy();
    expect(manifest.display).toMatch(/standalone|fullscreen|minimal-ui/);
    expect(manifest.icons.some((i: any) => i.sizes === '512x512')).toBe(true);
    expect(manifest.icons.some((i: any) => i.purpose?.includes('maskable'))).toBe(true);
});

test('manifest has required fields for app stores', async ({ page }) => {
    await page.goto('/');

    const manifest = await page.evaluate(async () => {
        const res = await fetch('/manifest.json');
        return res.json();
    });

    // Required for Google Play Store PWA listing
    expect(manifest.name.length).toBeGreaterThan(0);
    expect(manifest.name.length).toBeLessThanOrEqual(45);
    expect(manifest.short_name.length).toBeLessThanOrEqual(12);
    expect(manifest.description).toBeTruthy();
    expect(manifest.theme_color).toBeTruthy();
    expect(manifest.background_color).toBeTruthy();

    // Must have icons in multiple sizes
    const sizes = manifest.icons.map((i: any) => i.sizes);
    expect(sizes).toContain('192x192');
    expect(sizes).toContain('512x512');
});

Lighthouse PWA Audit in CI

# Run Lighthouse PWA audit in CI
npx lighthouse https://staging.example.com \
  --only-categories=pwa \
  --output=json \
  --output-path=./lighthouse-pwa.json \
  --chrome-flags="--headless --no-sandbox"

# Parse results and fail if score is below threshold
node -e "
const results = require('./lighthouse-pwa.json');
const score = results.categories.pwa.score * 100;
console.log('PWA Score: ' + score);
if (score < 90) {
    console.error('PWA score below 90. Failing build.');
    process.exit(1);
}
// Check specific audits
const audits = results.audits;
const criticalAudits = [
    'service-worker',
    'installable-manifest',
    'splash-screen',
    'themed-omnibox',
    'viewport'
];
criticalAudits.forEach(audit => {
    if (!audits[audit].score) {
        console.error('FAILED: ' + audit + ' - ' + audits[audit].title);
        process.exit(1);
    }
});
"

Offline Data Sync Testing

One of the hardest PWA scenarios: user makes changes offline, then comes back online. Changes must sync without data loss or conflicts.

test('offline changes sync when connectivity restored', async ({ page, context }) => {
    await page.goto('/todo');
    await page.waitForLoadState('networkidle');

    // Go offline
    await context.setOffline(true);

    // Add items while offline
    await page.fill('[data-testid="new-todo-input"]', 'Buy groceries');
    await page.click('[data-testid="add-todo-btn"]');
    await page.fill('[data-testid="new-todo-input"]', 'Walk the dog');
    await page.click('[data-testid="add-todo-btn"]');

    // Verify items appear in the UI
    const offlineItems = await page.locator('[data-testid="todo-item"]').count();
    expect(offlineItems).toBeGreaterThanOrEqual(2);

    // Verify pending sync indicator
    await expect(page.locator('[data-testid="sync-pending"]')).toBeVisible();

    // Restore connectivity
    await context.setOffline(false);

    // Wait for sync to complete
    await expect(page.locator('[data-testid="sync-complete"]')).toBeVisible({ timeout: 10000 });

    // Reload and verify data persisted on server
    await page.reload();
    await page.waitForLoadState('networkidle');
    const syncedItems = await page.locator('[data-testid="todo-item"]').count();
    expect(syncedItems).toBeGreaterThanOrEqual(2);
});

PWA testing requires offline-first thinking. Every feature must be tested across three states: online, offline, and transitioning between the two. This is the testing discipline that separates a reliable PWA from a website with a service worker bolted on.