Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 095 / 100

Testing browser storage and caching: a step-by-step guide

Service workers and offline caches.

Expona Admin8 min read

Introduction

When your application relies on service workers to cache assets or localStorage to persist user preferences, those mechanisms become critical to test. Yet many teams skip testing browser storage and caching, discovering problems only after deployment when users report stale content or missing data. This guide walks you through disabling service workers strategically, verifying cache behavior, clearing storage between test runs, and validating update prompts—the core techniques for a step-by-step guide to testing browser storage and caching.

Why Service Workers and Offline Caches Matter in Tests

Service workers intercept network requests and serve cached responses, which is excellent for performance but problematic for test reliability. A service worker might serve stale assets from cache even when you've deployed new code, making your tests pass with outdated resources. Similarly, localStorage and sessionStorage can leak state between tests, causing flaky failures that only occur in specific test sequences. Offline caches add another layer: you need to verify that your app works when connectivity is interrupted, but also that it refreshes when the connection returns.

The goal is to test these behaviors intentionally rather than be blindsided by them in production.

Disabling Service Workers with serviceWorkers: 'block'

The simplest way to eliminate service worker interference is to disable them during test runs. Playwright offers the serviceWorkers context option set to 'block', which prevents service workers from registering.

typescript
import { test, expect } from '@playwright/test';

test.describe('Service worker blocking', () => {
  test('should not register service worker', async ({ browser }) => {
    const context = await browser.createContext({
      serviceWorkers: 'block',
    });
    const page = await context.newPage();

    await page.goto('http://localhost:3000');

    // Check that no service worker is active
    const hasServiceWorker = await page.evaluate(() => {
      return navigator.serviceWorker.controller !== null;
    });

    expect(hasServiceWorker).toBe(false);
    await context.close();
  });
});

Use serviceWorkers: 'block' as your default for most tests. This isolates your application logic from caching behavior. When you need to test the service worker itself, use the default setting or 'allow' to let it register. This separation keeps your test suite predictable and fast.

Test Cache Behavior Explicitly

Once you've blocked service workers for general tests, dedicate a specific test suite to verify caching logic. You'll want to confirm that assets are cached, that cache busting works, and that stale content is refreshed when needed.

typescript
test.describe('Cache behavior', () => {
  test('should cache assets on first load', async ({ page, context }) => {
    // Allow service workers for this test
    const cacheContext = await browser.createContext({
      serviceWorkers: 'allow',
    });
    const cachePage = await cacheContext.newPage();

    // First load
    await cachePage.goto('http://localhost:3000');
    const firstLoadTime = Date.now();

    // Wait for service worker to activate
    await cachePage.waitForFunction(() => {
      return navigator.serviceWorker.controller !== null;
    });

    // Simulate offline
    await cachePage.context().setOffline(true);

    // Navigate to a new route
    await cachePage.goto('http://localhost:3000/about');

    // Should still load from cache
    const isStillVisible = await cachePage
      .locator('h1')
      .isVisible();
    expect(isStillVisible).toBe(true);

    await cacheContext.close();
  });

  test('should refresh cache on version update', async ({ browser }) => {
    const cacheContext = await browser.createContext();
    const cachePage = await cacheContext.newPage();

    await cachePage.goto('http://localhost:3000');

    // Verify initial version
    const initialVersion = await cachePage.evaluate(() => {
      return sessionStorage.getItem('app_version');
    });

    // Simulate app update (e.g., new build deployed)
    // In real scenarios, you'd trigger a cache update mechanism
    await cachePage.evaluate(() => {
      if (navigator.serviceWorker.controller) {
        navigator.serviceWorker.controller.postMessage({
          type: 'UPDATE_CACHE',
        });
      }
    });

    // Wait for update
    await cachePage.waitForTimeout(1000);

    const updatedVersion = await cachePage.evaluate(() => {
      return sessionStorage.getItem('app_version');
    });

    expect(updatedVersion).not.toBe(initialVersion);
    await cacheContext.close();
  });
});

This approach verifies that your service worker correctly caches assets and responds to update signals. Catch cache-related bugs before users do.

Clear Storage Between Tests

localStorage, sessionStorage, and IndexedDB persist across page navigations and even browser restarts. If one test populates localStorage with user preferences, the next test inherits that state. This causes flaky tests where order matters—a red flag for any test suite.

Always clear storage before each test. Playwright provides context.clearCookies() for cookies and page.evaluate() for direct storage access:

typescript
test.beforeEach(async ({ page, context }) => {
  // Clear cookies
  await context.clearCookies();

  // Clear localStorage and sessionStorage
  await page.evaluate(() => {
    localStorage.clear();
    sessionStorage.clear();
  });

  // Clear IndexedDB if used
  await page.evaluate(() => {
    if (window.indexedDB) {
      const databases = ['myAppDB'];
      for (const dbName of databases) {
        indexedDB.deleteDatabase(dbName);
      }
    }
  });
});

test('user preferences are saved in localStorage', async ({ page }) => {
  await page.goto('http://localhost:3000/settings');

  await page.locator('[data-testid="dark-mode-toggle"]').click();
  await page.locator('button:has-text("Save")').click();

  const isDarkMode = await page.evaluate(() => {
    return localStorage.getItem('theme') === 'dark';
  });

  expect(isDarkMode).toBe(true);
});

test('preferences survive reload', async ({ page }) => {
  await page.goto('http://localhost:3000/settings');

  // Set preference
  await page.evaluate(() => {
    localStorage.setItem('theme', 'dark');
  });

  // Reload
  await page.reload();

  // Verify preference is still there
  const theme = await page.evaluate(() => {
    return localStorage.getItem('theme');
  });

  expect(theme).toBe('dark');
});

Clearing storage in beforeEach ensures each test starts clean. Tests become independent, repeatable, and less flaky.

Verify Update Prompts

Many PWAs show an update prompt when a new service worker version is available. Users expect a way to refresh and get the latest features. Test that this mechanism works end-to-end.

typescript
test.describe('Update prompts', () => {
  test('should show update prompt when new service worker is ready', async ({
    browser,
  }) => {
    const context = await browser.createContext();
    const page = await context.newPage();

    // Listen for service worker update events
    const updatePromptVisible = page.waitForSelector(
      '[data-testid="update-prompt"]',
      { timeout: 10000 },
    );

    await page.goto('http://localhost:3000');

    // Simulate new service worker ready
    await page.evaluate(() => {
      // Trigger custom event that your app listens to
      const event = new CustomEvent('swUpdateReady', {
        detail: { version: '2.0.0' },
      });
      window.dispatchEvent(event);
    });

    // Verify prompt appears
    await expect(updatePromptVisible).resolves.toBeTruthy();

    // Click "Update Now"
    await page.locator('button:has-text("Update Now")').click();

    // Verify page reloads with new version
    const newVersion = await page.locator('[data-testid="version"]').textContent();
    expect(newVersion).toContain('2.0.0');

    await context.close();
  });

  test('user can dismiss update prompt', async ({ page }) => {
    await page.goto('http://localhost:3000');

    // Trigger update ready
    await page.evaluate(() => {
      const event = new CustomEvent('swUpdateReady');
      window.dispatchEvent(event);
    });

    // Dismiss prompt
    await page.locator('button:has-text("Dismiss")').click();

    // Prompt should be gone
    const prompt = page.locator('[data-testid="update-prompt"]');
    await expect(prompt).not.toBeVisible();

    // App continues to work with old version
    await page.goto('http://localhost:3000/about');
    await expect(page.locator('h1')).toBeVisible();
  });
});

Testing update prompts surfaces UX issues: does the prompt appear reliably? Does the update complete without errors? Does the app gracefully handle dismissals?

Common Mistakes

  • Not blocking service workers by default: Leaving service workers enabled in most tests causes cache hits that mask bugs. Block them in the base configuration and opt in when needed.
  • Forgetting to clear storage between tests: State leakage is insidious; it fails only when tests run in a certain order. Always clear in beforeEach.
  • Testing offline behavior without actually going offline: Use context.setOffline(true) to truly verify offline functionality, not just mocked scenarios.
  • Assuming cache updates happen instantly: Service worker updates and cache invalidation are asynchronous. Wait for them explicitly rather than relying on fixed delays.
  • Mixing cache tests with functional tests: Keep cache-specific tests separate. They require different setup and can slow down your main test suite.

Key Takeaways

  • Set serviceWorkers: 'block' in most tests to eliminate cache-related flakiness and ensure predictable behavior.
  • Dedicate a separate test suite to service worker and cache behavior using the default or 'allow' setting.
  • Clear localStorage, sessionStorage, cookies, and IndexedDB in beforeEach to prevent state leakage between tests.
  • Test offline functionality explicitly with context.setOffline(true) and verify that the app recovers when connectivity is restored.
  • Verify update prompts and version transitions to ensure users can refresh to new app versions without confusion.
  • Use page.evaluate() to inspect and manipulate storage from within your tests, bridging the gap between what your app sees and what you assert.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles