Тестирование 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.