PWA Installability Testing: How to Verify Your App Can Be Installed

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:

  1. HTTPS (or localhost for development)
  2. Web App Manifest with required fields:
    • name or short_name
    • icons with at least 192×192 and 512×512 PNG
    • start_url
    • display (must be standalone, minimal-ui, or fullscreen)
  3. Service worker with a fetch event handler
  4. 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:

  1. Open DevTools → ApplicationManifest — check for errors
  2. Open DevTools → ApplicationService Workers — verify SW is active
  3. Run chrome://flags/#bypass-app-banner-engagement-checks in Chrome to skip engagement requirement
  4. Check the Issues tab in DevTools for manifest warnings
  5. Use lighthouse --only-audits=installable-manifest for a quick check

Common issues:

  • Icon file returns 404
  • start_url not within service worker scope
  • display set to browser instead of standalone
  • 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.

Read more

Start now free