Modern QA2026Тестирование PWA
Join

Course09 Mobile & Cross-Platform Testing

Cutting-edge · Chapter 09

Тестирование PWA

Updated Jul 2026

Вызовы тестирования PWA

Progressive Web Apps преодолевают разрыв между вебом и нативными приложениями, но этот мост создаёт уникальные вызовы тестирования, которые не покрывает ни чисто веб-тестирование, ни чисто мобильное тестирование. PWA должно работать онлайн, офлайн и в условиях нестабильного соединения. Оно должно устанавливаться как нативное приложение, отправлять push-уведомления и синхронизировать данные при восстановлении подключения.

Тестирование Service Worker

Service workers перехватывают сетевые запросы и обеспечивают офлайн-функциональность. Они работают в жизненном цикле, который необходимо тестировать независимо:

// 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-уведомлений

// 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();
    });
});

Тестирование промпта установки

Промпт установки PWA имеет строгие критерии. Тестирование того, что ваше приложение им соответствует, гарантирует пользователям возможность установить его как нативное приложение.

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');
});

Аудит PWA с Lighthouse в 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);
    }
});
"

Тестирование офлайн-синхронизации данных

Один из самых сложных PWA-сценариев: пользователь вносит изменения офлайн, а затем возвращается онлайн. Изменения должны синхронизироваться без потери данных и конфликтов.

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 требует мышления «офлайн в первую очередь». Каждая функция должна тестироваться в трёх состояниях: онлайн, офлайн и в процессе перехода между ними. Именно эта дисциплина тестирования отличает надёжное PWA от сайта, к которому просто прикрутили service worker.