WebSocket testing with Playwright splits into two patterns: observing real connections by listening to framesent and framereceived events, and mocking the server entirely using page.routeWebSocket() (Playwright 1.48+) to control what messages the app receives without a live backend. This article covers both approaches, plus testing connection drop and reconnection behavior, with full TypeScript examples for each scenario.

What WebSocket testing looks like in practice

Unlike HTTP requests, WebSockets are persistent bidirectional connections. A single connection can carry thousands of messages over its lifetime. Testing WebSockets means:

1. Verifying that the connection is established

2. Verifying that the app sends the right messages

3. Verifying that the app correctly handles incoming messages

4. Testing what happens when the connection drops

Listening to WebSocket events

Playwright fires events for WebSocket connections:

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

test('chat app establishes WebSocket connection', async ({ page }) => {
  // Listen for WebSocket connections
  const wsConnected = page.waitForEvent('websocket');

  await page.goto('/chat');

  const ws = await wsConnected;
  expect(ws.url()).toContain('/ws/chat');

  // WebSocket is now open
  console.log('Connected to:', ws.url());
});

Capturing WebSocket messages

test('chat sends message via WebSocket', async ({ page }) => {
  const messages: string[] = [];

  page.on('websocket', ws => {
    ws.on('framesent', frame => {
      // Messages sent from browser to server
      if (frame.text) messages.push(frame.text);
    });
  });

  await page.goto('/chat');
  await page.getByPlaceholder('Type a message').fill('Hello, world!');
  await page.keyboard.press('Enter');

  // Verify the WebSocket message was sent
  await expect.poll(() => messages).toContain(
    JSON.stringify({ type: 'message', content: 'Hello, world!' })
  );
});

test('chat receives message and displays it', async ({ page }) => {
  const receivedFrames: string[] = [];

  page.on('websocket', ws => {
    ws.on('framereceived', frame => {
      // Messages received from server by browser
      if (frame.text) receivedFrames.push(frame.text);
    });
  });

  await page.goto('/chat');

  // Wait for at least one message to arrive (e.g., join confirmation)
  await expect.poll(() => receivedFrames.length).toBeGreaterThan(0);

  // Verify message is displayed in UI
  const firstMessage = JSON.parse(receivedFrames[0]);
  await expect(page.getByText(firstMessage.content)).toBeVisible();
});

framesent: messages the browser sent to the server. framereceived: messages the server sent to the browser.

Mocking WebSocket responses

The most powerful pattern: intercept the connection and simulate server messages without running a real server.

Playwright doesn't have a built-in WebSocket mock API, but you can inject a mock in the browser context:

test('app shows notification when server sends alert', async ({ page }) => {
  // Intercept WebSocket and simulate server behavior
  await page.addInitScript(() => {
    const OriginalWebSocket = window.WebSocket;
    
    window.WebSocket = class MockWebSocket extends EventTarget {
      url: string;
      readyState = 1; // OPEN
      
      constructor(url: string) {
        super();
        this.url = url;
        
        // Simulate connection open
        setTimeout(() => {
          this.dispatchEvent(new Event('open'));
          
          // Simulate server sending a notification after 100ms
          setTimeout(() => {
            const messageEvent = new MessageEvent('message', {
              data: JSON.stringify({ type: 'notification', text: 'New order received!' }),
            });
            this.dispatchEvent(messageEvent);
          }, 100);
        }, 0);
      }
      
      send(data: string) {
        // Record sent messages if needed
        console.log('WebSocket send:', data);
      }
      
      close() {
        this.readyState = 3;
        this.dispatchEvent(new CloseEvent('close'));
      }
    } as any;
  });

  await page.goto('/dashboard');

  // Verify the notification appears when the WebSocket message arrives
  await expect(page.getByRole('alert')).toBeVisible({ timeout: 2000 });
  await expect(page.getByRole('alert')).toHaveText('New order received!');
});

Testing disconnection and reconnection

Real-time apps need to handle connection loss gracefully:

test('app shows reconnecting state when WebSocket drops', async ({ page }) => {
  let wsInstance: any;
  
  await page.addInitScript(() => {
    const OriginalWebSocket = window.WebSocket;
    
    window.WebSocket = class extends OriginalWebSocket {
      constructor(url: string) {
        super(url);
        // Expose instance for test control
        (window as any).__wsInstance = this;
      }
    } as any;
  });

  await page.goto('/chat');
  
  // Wait for connection to establish
  await page.waitForEvent('websocket');

  // Force-close the WebSocket connection
  await page.evaluate(() => {
    (window as any).__wsInstance?.close();
  });

  // Verify UI shows reconnecting state
  await expect(page.getByText('Reconnecting...')).toBeVisible();
  
  // After some time, verify it attempts to reconnect
  await expect(page.getByText('Connected')).toBeVisible({ timeout: 10000 });
});

Using route.fulfill for WebSocket in newer Playwright versions

Playwright 1.48+ introduced page.routeWebSocket() for more ergonomic WebSocket mocking:

// Playwright 1.48+
test('order status updates in real time', async ({ page }) => {
  await page.routeWebSocket('/ws/orders', ws => {
    ws.onopen = () => {
      // Send initial state
      ws.send(JSON.stringify({ orderId: '123', status: 'processing' }));
    };

    ws.onmessage = (message) => {
      const data = JSON.parse(message.data);
      if (data.type === 'subscribe' && data.orderId === '123') {
        // Simulate status progression
        setTimeout(() => ws.send(JSON.stringify({ orderId: '123', status: 'shipped' })), 500);
        setTimeout(() => ws.send(JSON.stringify({ orderId: '123', status: 'delivered' })), 1000);
      }
    };
  });

  await page.goto('/orders/123');
  
  await expect(page.getByTestId('status')).toHaveText('Processing');
  await expect(page.getByTestId('status')).toHaveText('Shipped', { timeout: 2000 });
  await expect(page.getByTestId('status')).toHaveText('Delivered', { timeout: 3000 });
});

Check your Playwright version before using routeWebSocket: it's not available in older versions.

When to test WebSockets

Not all real-time features need dedicated WebSocket tests. Prioritize:

  • Connection establishment for critical features (chat, live dashboard)
  • Behavior when the connection drops (shows error? reconnects? loses data?)
  • Message ordering for sequential operations
  • Authentication over WebSocket (is the connection rejected without valid auth?)

Skip WebSocket-level testing when:

  • The feature works reliably and the E2E UI test already covers it
  • The WebSocket is third-party (you don't control the server)
  • Testing the message format is better covered by a backend unit test
→ See also: Network Interception, Mocking, and Stubbing in Playwright | API Testing with Playwright's APIRequestContext (No Postman Required) | GraphQL API Testing with Playwright: Queries, Mutations, and Error Handling