PWA Installability Testing: How to Verify Your App Can Be Installed
PWA installability — the ability to add your web app to the home screen and run it as a standalone app — is one of the most visible PWA features. It's also surprisingly easy to break. A missing icon size, a misconfigured manifest property, or a service worker scope issue can silently prevent the install prompt from appearing. This guide covers how to test that your PWA actually installs.
PWA Installability Requirements
Chrome (and most browsers) require all of these for the install prompt to appear:
- HTTPS (or localhost for development)
- Web App Manifest with required fields:
nameorshort_nameiconswith at least 192×192 and 512×512 PNGstart_urldisplay(must bestandalone,minimal-ui, orfullscreen)
- Service worker with a
fetchevent handler - Not already installed (browser tracks this)
Miss any of these and the browser won't show the install prompt. Worse, it fails silently — no error, no indication of what's wrong.
Validating the Web App Manifest
Automated Manifest Validation with Lighthouse
Lighthouse is the authoritative source for PWA installability checks:
import lighthouse from 'lighthouse';
import chromeLauncher from 'chrome-launcher';
async function testInstallability(url) {
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const result = await lighthouse(url, {
port: chrome.port,
onlyCategories: ['pwa'],
});
await chrome.kill();
return result.lhr;
}
describe('PWA Installability', () => {
it('passes all Lighthouse installability checks', async () => {
const report = await testInstallability('http://localhost:3000');
const installabilityAudit = report.audits['installable-manifest'];
expect(installabilityAudit.score).toBe(1);
const serviceWorkerAudit = report.audits['service-worker'];
expect(serviceWorkerAudit.score).toBe(1);
});
it('has valid icons for installation', async () => {
const report = await testInstallability('http://localhost:3000');
const iconsAudit = report.audits['maskable-icon'];
// Should have maskable icon for Android home screen
expect(iconsAudit.score).toBe(1);
});
});Direct Manifest Validation
Don't rely only on Lighthouse — validate the manifest directly:
import Ajv from 'ajv';
import manifest from '../public/manifest.json';
describe('Web App Manifest', () => {
it('has required name field', () => {
expect(manifest.name || manifest.short_name).toBeTruthy();
});
it('has valid start_url', () => {
expect(manifest.start_url).toBeTruthy();
expect(manifest.start_url).toMatch(/^\/|^https?:\/\//);
});
it('has required display mode', () => {
const validDisplayModes = ['standalone', 'minimal-ui', 'fullscreen'];
expect(validDisplayModes).toContain(manifest.display);
});
it('includes 192x192 icon', () => {
const icons = manifest.icons || [];
const has192 = icons.some(icon =>
icon.sizes.includes('192x192') && icon.type === 'image/png'
);
expect(has192).toBe(true);
});
it('includes 512x512 icon', () => {
const icons = manifest.icons || [];
const has512 = icons.some(icon =>
icon.sizes.includes('512x512') && icon.type === 'image/png'
);
expect(has512).toBe(true);
});
it('includes maskable icon', () => {
const icons = manifest.icons || [];
const hasMaskable = icons.some(icon =>
icon.purpose?.includes('maskable')
);
expect(hasMaskable).toBe(true);
});
it('has theme_color for browser chrome', () => {
expect(manifest.theme_color).toMatch(/^#[0-9a-fA-F]{6}$/);
});
it('has background_color for splash screen', () => {
expect(manifest.background_color).toMatch(/^#[0-9a-fA-F]{6}$/);
});
});Validating Icon Files Exist
Having icon declarations in the manifest isn't enough — the files need to actually exist and be valid:
import path from 'path';
import fs from 'fs';
import { createCanvas } from 'canvas';
import sharp from 'sharp';
import manifest from '../public/manifest.json';
describe('Manifest Icons', () => {
for (const icon of manifest.icons) {
it(`icon ${icon.src} exists and matches declared size`, async () => {
const iconPath = path.join('public', icon.src);
expect(fs.existsSync(iconPath)).toBe(true);
const metadata = await sharp(iconPath).metadata();
const [width, height] = icon.sizes.split('x').map(Number);
expect(metadata.width).toBe(width);
expect(metadata.height).toBe(height);
});
}
});Testing the beforeinstallprompt Event
The beforeinstallprompt event is your hook to show a custom install UI instead of the browser's default prompt. Test that your app handles it:
import { test, expect } from '@playwright/test';
test('captures beforeinstallprompt event', async ({ page }) => {
// Playwright doesn't fire real install prompts, but you can verify
// your app listens for the event correctly
await page.goto('http://localhost:3000');
// Simulate the event
const promptCaptured = await page.evaluate(() => {
return new Promise(resolve => {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // Prevent default browser UI
resolve(true);
});
// Dispatch the event manually for testing
const event = new Event('beforeinstallprompt');
event.prompt = () => Promise.resolve({ outcome: 'accepted' });
event.userChoice = Promise.resolve({ outcome: 'accepted' });
window.dispatchEvent(event);
});
});
expect(promptCaptured).toBe(true);
});
test('shows custom install button when install prompt available', async ({ page }) => {
await page.goto('http://localhost:3000');
// Simulate beforeinstallprompt
await page.evaluate(() => {
const event = new Event('beforeinstallprompt');
event.prompt = () => Promise.resolve({ outcome: 'accepted' });
event.userChoice = Promise.resolve({ outcome: 'accepted' });
window.dispatchEvent(event);
});
await expect(page.locator('[data-testid="install-app-button"]'))
.toBeVisible({ timeout: 2000 });
});
test('install button triggers the prompt', async ({ page }) => {
await page.goto('http://localhost:3000');
let promptCalled = false;
await page.evaluate(() => {
const event = new Event('beforeinstallprompt');
event.prompt = () => {
window.__promptCalled = true;
return Promise.resolve({ outcome: 'accepted' });
};
event.userChoice = Promise.resolve({ outcome: 'accepted' });
window.dispatchEvent(event);
});
await page.click('[data-testid="install-app-button"]');
promptCalled = await page.evaluate(() => window.__promptCalled);
expect(promptCalled).toBe(true);
});
test('hides install button after user installs', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.evaluate(() => {
const event = new Event('beforeinstallprompt');
event.prompt = () => Promise.resolve({ outcome: 'accepted' });
event.userChoice = Promise.resolve({ outcome: 'accepted' });
window.dispatchEvent(event);
});
await page.click('[data-testid="install-app-button"]');
// After accepted, button should disappear
await expect(page.locator('[data-testid="install-app-button"]'))
.toBeHidden({ timeout: 2000 });
});Testing the appinstalled Event
Track when users actually install your PWA:
test('fires analytics event when app is installed', async ({ page }) => {
let analyticsEvents = [];
// Intercept analytics calls
await page.route('**/api/analytics', async route => {
const body = await route.request().postDataJSON();
analyticsEvents.push(body);
await route.fulfill({ status: 200 });
});
await page.goto('http://localhost:3000');
// Simulate install completion
await page.evaluate(() => {
window.dispatchEvent(new Event('appinstalled'));
});
await page.waitForTimeout(500);
expect(analyticsEvents.some(e => e.event === 'pwa_installed')).toBe(true);
});Testing Standalone Display Mode
When installed, your PWA runs in standalone mode (no browser UI). Test that your app adapts:
test('hides browser-specific UI in standalone mode', async ({ browser }) => {
// Simulate standalone display mode
const context = await browser.newContext({
// Chrome doesn't let you truly simulate standalone mode in tests,
// but you can use media query overrides or user agent tricks
});
const page = await context.newPage();
// Override the display-mode media query
await page.addStyleTag({
content: '@media (display-mode: standalone) { .browser-only { display: none; } }'
});
await page.goto('http://localhost:3000');
// Check that standalone-specific UI appears
// (You'd need to check your app's actual standalone detection)
const isStandalone = await page.evaluate(() =>
window.matchMedia('(display-mode: standalone)').matches
);
// In a real standalone launch this would be true
// For testing, verify your detection code works
const detectedMode = await page.evaluate(() => window.__displayMode);
expect(['standalone', 'browser']).toContain(detectedMode);
});
test('shows back navigation in standalone mode', async ({ page }) => {
// If your app hides browser back button in standalone,
// you need your own navigation
await page.evaluate(() => {
// Simulate standalone
Object.defineProperty(window.navigator, 'standalone', { value: true });
});
await page.goto('http://localhost:3000/deep/page');
await expect(page.locator('[data-testid="back-button"]')).toBeVisible();
});Testing Splash Screen Configuration
The splash screen shown while your PWA launches depends on manifest properties. Validate them:
describe('Splash Screen Configuration', () => {
it('has name for splash screen text', () => {
expect(manifest.name.length).toBeGreaterThan(0);
expect(manifest.name.length).toBeLessThan(25); // Long names get truncated
});
it('has background_color matching app theme', () => {
// Background color should match your app's initial background
// to avoid jarring flash during launch
expect(manifest.background_color).toBe('#ffffff'); // or your app's bg color
});
it('has 512x512 icon for splash screen', () => {
const icons = manifest.icons || [];
const splashIcon = icons.find(icon => icon.sizes === '512x512');
expect(splashIcon).toBeTruthy();
});
});Platform-Specific Install Testing
iOS / Safari Testing
iOS Safari has specific quirks for PWA installation:
test('shows iOS-specific install instructions', async ({ browser }) => {
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15'
});
const page = await context.newPage();
await page.goto('http://localhost:3000');
// iOS doesn't support beforeinstallprompt
// Your app should show manual instructions
const hasIOSInstructions = await page.evaluate(() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isStandalone = window.navigator.standalone === true;
return isIOS && !isStandalone;
});
if (hasIOSInstructions) {
await expect(page.locator('[data-testid="ios-install-guide"]'))
.toBeVisible();
await expect(page.locator('[data-testid="ios-install-guide"]'))
.toContainText('Share');
}
});Automated Installability Monitoring
Use HelpMeTest to continuously verify your PWA passes installability checks in production:
*** Test Cases ***
PWA Installability Check
[Documentation] Verify PWA meets installability requirements
${result}= Run Lighthouse Audit https://your-pwa.com pwa
Should Be True ${result.installable_manifest} >= 1
Should Be True ${result.service_worker} >= 1
Manifest JSON Validation
${response}= Make HTTP Request GET https://your-pwa.com/manifest.json
Should Be Equal ${response.status_code} 200
${manifest}= Parse JSON ${response.body}
Should Not Be Empty ${manifest['name']}
Should Not Be Empty ${manifest['start_url']}
Should Not Be Empty ${manifest['icons']}Schedule this to run after every deployment. A manifest property accidentally removed during a build process can break installability without any visible error in the app itself.
Debugging Installability Issues
When the install prompt doesn't appear:
- Open DevTools → Application → Manifest — check for errors
- Open DevTools → Application → Service Workers — verify SW is active
- Run
chrome://flags/#bypass-app-banner-engagement-checksin Chrome to skip engagement requirement - Check the Issues tab in DevTools for manifest warnings
- Use
lighthouse --only-audits=installable-manifestfor a quick check
Common issues:
- Icon file returns 404
start_urlnot within service worker scopedisplayset tobrowserinstead ofstandalone- HTTPS certificate error preventing SW registration
- Mixed content blocking manifest icons
Conclusion
PWA installability is a binary requirement: your app either meets all the criteria or the install prompt doesn't appear. The manifest validation and beforeinstallprompt tests in this guide catch the most common failures before they reach production.
Run these tests in CI on every deployment, and monitor with Lighthouse in production. Installability failures are silent from the user's perspective — they just never see a prompt and never install your app. You won't know unless you test for it explicitly.