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.
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.
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:
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.
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
beforeEachto 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.






Comments
Loading comments…