Skip to content

Latest commit

 

History

History
572 lines (457 loc) · 19.1 KB

File metadata and controls

572 lines (457 loc) · 19.1 KB

WebSockets and Real-Time Testing

When to use: When your application uses WebSockets, Server-Sent Events (SSE), or polling for real-time features -- chat, live dashboards, notifications, collaborative editing, stock tickers, live sports scores. Prerequisites: core/assertions-and-waiting.md, core/fixtures-and-hooks.md

Quick Reference

// Listen for WebSocket connections
page.on('websocket', (ws) => {
  console.log('WebSocket opened:', ws.url());

  ws.on('framesent', (frame) => console.log('Sent:', frame.payload));
  ws.on('framereceived', (frame) => console.log('Received:', frame.payload));
  ws.on('close', () => console.log('WebSocket closed'));
});

// Mock a WebSocket via route (Playwright 1.48+)
await page.routeWebSocket('**/ws', (ws) => {
  ws.onMessage((message) => {
    ws.send(JSON.stringify({ echo: message }));
  });
});

Patterns

Observing WebSocket Traffic

Use when: You need to verify that your app sends and receives the correct WebSocket messages without modifying them. Avoid when: You need to intercept or mock the messages. Use routeWebSocket instead.

TypeScript

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

test('chat message is sent over WebSocket', async ({ page }) => {
  const messages: { direction: string; payload: string }[] = [];

  page.on('websocket', (ws) => {
    ws.on('framesent', (frame) => {
      messages.push({ direction: 'sent', payload: String(frame.payload) });
    });
    ws.on('framereceived', (frame) => {
      messages.push({ direction: 'received', payload: String(frame.payload) });
    });
  });

  await page.goto('/chat');
  await page.getByRole('textbox', { name: 'Message' }).fill('Hello!');
  await page.getByRole('button', { name: 'Send' }).click();

  // Wait for the message to appear in UI (confirms round-trip)
  await expect(page.getByText('Hello!')).toBeVisible();

  // Verify WebSocket traffic
  const sentMessage = messages.find(
    (m) => m.direction === 'sent' && m.payload.includes('Hello!')
  );
  expect(sentMessage).toBeDefined();
});

JavaScript

const { test, expect } = require('@playwright/test');

test('chat message is sent over WebSocket', async ({ page }) => {
  const messages = [];

  page.on('websocket', (ws) => {
    ws.on('framesent', (frame) => {
      messages.push({ direction: 'sent', payload: String(frame.payload) });
    });
    ws.on('framereceived', (frame) => {
      messages.push({ direction: 'received', payload: String(frame.payload) });
    });
  });

  await page.goto('/chat');
  await page.getByRole('textbox', { name: 'Message' }).fill('Hello!');
  await page.getByRole('button', { name: 'Send' }).click();

  await expect(page.getByText('Hello!')).toBeVisible();

  const sentMessage = messages.find(
    (m) => m.direction === 'sent' && m.payload.includes('Hello!')
  );
  expect(sentMessage).toBeDefined();
});

Waiting for a Specific WebSocket Message

Use when: Your test depends on a particular server-pushed message before proceeding. Avoid when: The UI already reflects the message. Assert on the UI instead.

TypeScript

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

test('wait for server acknowledgment over WebSocket', async ({ page }) => {
  // Create a promise that resolves when we get the specific message
  const ackPromise = new Promise<void>((resolve) => {
    page.on('websocket', (ws) => {
      ws.on('framereceived', (frame) => {
        const data = JSON.parse(String(frame.payload));
        if (data.type === 'message_ack') {
          resolve();
        }
      });
    });
  });

  await page.goto('/chat');
  await page.getByRole('textbox', { name: 'Message' }).fill('Important update');
  await page.getByRole('button', { name: 'Send' }).click();

  // Wait for server to acknowledge
  await ackPromise;

  // Now verify the message shows a "delivered" checkmark
  await expect(page.getByTestId('message-status').last()).toHaveText('Delivered');
});

JavaScript

const { test, expect } = require('@playwright/test');

test('wait for server acknowledgment over WebSocket', async ({ page }) => {
  const ackPromise = new Promise((resolve) => {
    page.on('websocket', (ws) => {
      ws.on('framereceived', (frame) => {
        const data = JSON.parse(String(frame.payload));
        if (data.type === 'message_ack') {
          resolve();
        }
      });
    });
  });

  await page.goto('/chat');
  await page.getByRole('textbox', { name: 'Message' }).fill('Important update');
  await page.getByRole('button', { name: 'Send' }).click();

  await ackPromise;
  await expect(page.getByTestId('message-status').last()).toHaveText('Delivered');
});

Mocking WebSocket Messages with routeWebSocket

Use when: You want to control what the server sends to test specific UI states -- error messages, edge cases, high-volume data -- without a real backend. Avoid when: You need to test actual server behavior. Use a real backend.

TypeScript

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

test('display notification when server pushes an alert', async ({ page }) => {
  const wsRoute = await page.routeWebSocket('**/ws/notifications', (ws) => {
    // Let the app send its initial handshake
    ws.onMessage((message) => {
      const data = JSON.parse(message);
      if (data.type === 'subscribe') {
        ws.send(JSON.stringify({ type: 'subscribed', channel: data.channel }));
      }
    });

    // Push a notification after a short delay
    setTimeout(() => {
      ws.send(JSON.stringify({
        type: 'notification',
        title: 'Server Alert',
        body: 'Deployment completed successfully',
        severity: 'info',
      }));
    }, 500);
  });

  await page.goto('/dashboard');

  // Verify the notification appears in the UI
  await expect(page.getByRole('alert')).toContainText('Deployment completed successfully');
});

test('handle WebSocket server error gracefully', async ({ page }) => {
  await page.routeWebSocket('**/ws', (ws) => {
    // Immediately close with an error code
    ws.close({ code: 1011, reason: 'Internal server error' });
  });

  await page.goto('/chat');

  // App should show a reconnection message, not crash
  await expect(page.getByText('Connection lost. Reconnecting...')).toBeVisible();
});

JavaScript

const { test, expect } = require('@playwright/test');

test('display notification when server pushes an alert', async ({ page }) => {
  await page.routeWebSocket('**/ws/notifications', (ws) => {
    ws.onMessage((message) => {
      const data = JSON.parse(message);
      if (data.type === 'subscribe') {
        ws.send(JSON.stringify({ type: 'subscribed', channel: data.channel }));
      }
    });

    setTimeout(() => {
      ws.send(JSON.stringify({
        type: 'notification',
        title: 'Server Alert',
        body: 'Deployment completed successfully',
        severity: 'info',
      }));
    }, 500);
  });

  await page.goto('/dashboard');
  await expect(page.getByRole('alert')).toContainText('Deployment completed successfully');
});

test('handle WebSocket server error gracefully', async ({ page }) => {
  await page.routeWebSocket('**/ws', (ws) => {
    ws.close({ code: 1011, reason: 'Internal server error' });
  });

  await page.goto('/chat');
  await expect(page.getByText('Connection lost. Reconnecting...')).toBeVisible();
});

Forwarding with Modification (Man-in-the-Middle)

Use when: You want to connect to the real server but intercept, modify, or inject messages. Avoid when: Full mocking (routeWebSocket without connectToServer) is sufficient.

TypeScript

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

test('inject a fake high-priority message into real stream', async ({ page }) => {
  await page.routeWebSocket('**/ws/feed', (ws) => {
    const server = ws.connectToServer();

    // Forward all messages from server to client, but inject extras
    server.onMessage((message) => {
      ws.send(message); // Forward the real message
    });

    // Forward all client messages to server
    ws.onMessage((message) => {
      server.send(message);
    });

    // Inject a synthetic message after 1 second
    setTimeout(() => {
      ws.send(JSON.stringify({
        type: 'alert',
        priority: 'high',
        text: 'Injected test alert',
      }));
    }, 1000);
  });

  await page.goto('/live-feed');
  await expect(page.getByText('Injected test alert')).toBeVisible();
});

JavaScript

const { test, expect } = require('@playwright/test');

test('inject a fake high-priority message into real stream', async ({ page }) => {
  await page.routeWebSocket('**/ws/feed', (ws) => {
    const server = ws.connectToServer();

    server.onMessage((message) => {
      ws.send(message);
    });

    ws.onMessage((message) => {
      server.send(message);
    });

    setTimeout(() => {
      ws.send(JSON.stringify({
        type: 'alert',
        priority: 'high',
        text: 'Injected test alert',
      }));
    }, 1000);
  });

  await page.goto('/live-feed');
  await expect(page.getByText('Injected test alert')).toBeVisible();
});

Server-Sent Events (SSE) Testing

Use when: Your app uses EventSource for server-to-client streaming (live logs, progress updates, news feeds). Avoid when: The app uses WebSockets. SSE is HTTP-based and intercepted differently.

SSE responses are standard HTTP -- intercept them with page.route() and return a streaming response.

TypeScript

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

test('SSE live log stream displays entries', async ({ page }) => {
  // Intercept the SSE endpoint and return controlled events
  await page.route('**/api/logs/stream', async (route) => {
    const events = [
      'data: {"level":"info","message":"Server started"}\n\n',
      'data: {"level":"warn","message":"High memory usage"}\n\n',
      'data: {"level":"error","message":"Connection timeout"}\n\n',
    ];

    await route.fulfill({
      status: 200,
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
      },
      body: events.join(''),
    });
  });

  await page.goto('/admin/logs');

  await expect(page.getByText('Server started')).toBeVisible();
  await expect(page.getByText('High memory usage')).toBeVisible();
  await expect(page.getByText('Connection timeout')).toBeVisible();
});

test('SSE reconnection on connection drop', async ({ page }) => {
  let requestCount = 0;

  await page.route('**/api/events', async (route) => {
    requestCount++;
    if (requestCount === 1) {
      // First request: send one event then close abruptly
      await route.fulfill({
        status: 200,
        headers: { 'Content-Type': 'text/event-stream' },
        body: 'data: {"msg":"first"}\n\n',
      });
    } else {
      // Reconnection: send the next event
      await route.fulfill({
        status: 200,
        headers: { 'Content-Type': 'text/event-stream' },
        body: 'data: {"msg":"reconnected"}\n\n',
      });
    }
  });

  await page.goto('/live');
  await expect(page.getByText('first')).toBeVisible();
  // EventSource auto-reconnects; verify the app handles it
  await expect(page.getByText('reconnected')).toBeVisible({ timeout: 10000 });
});

JavaScript

const { test, expect } = require('@playwright/test');

test('SSE live log stream displays entries', async ({ page }) => {
  await page.route('**/api/logs/stream', async (route) => {
    const events = [
      'data: {"level":"info","message":"Server started"}\n\n',
      'data: {"level":"warn","message":"High memory usage"}\n\n',
      'data: {"level":"error","message":"Connection timeout"}\n\n',
    ];

    await route.fulfill({
      status: 200,
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
      },
      body: events.join(''),
    });
  });

  await page.goto('/admin/logs');

  await expect(page.getByText('Server started')).toBeVisible();
  await expect(page.getByText('High memory usage')).toBeVisible();
  await expect(page.getByText('Connection timeout')).toBeVisible();
});

Polling-Based Real-Time Testing

Use when: Your app uses HTTP polling (setInterval + fetch) instead of WebSockets or SSE. Avoid when: The app uses WebSockets or SSE -- use the patterns above.

TypeScript

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

test('polling updates dashboard data every interval', async ({ page }) => {
  let callCount = 0;

  await page.route('**/api/dashboard/stats', async (route) => {
    callCount++;
    const data = callCount === 1
      ? { activeUsers: 100, revenue: 5000 }
      : { activeUsers: 142, revenue: 5250 };

    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(data),
    });
  });

  await page.goto('/dashboard');

  // First poll result
  await expect(page.getByTestId('active-users')).toHaveText('100');

  // Wait for the second poll to update the UI
  await expect(page.getByTestId('active-users')).toHaveText('142', { timeout: 15000 });

  // Verify at least 2 requests were made
  expect(callCount).toBeGreaterThanOrEqual(2);
});

JavaScript

const { test, expect } = require('@playwright/test');

test('polling updates dashboard data every interval', async ({ page }) => {
  let callCount = 0;

  await page.route('**/api/dashboard/stats', async (route) => {
    callCount++;
    const data = callCount === 1
      ? { activeUsers: 100, revenue: 5000 }
      : { activeUsers: 142, revenue: 5250 };

    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(data),
    });
  });

  await page.goto('/dashboard');
  await expect(page.getByTestId('active-users')).toHaveText('100');
  await expect(page.getByTestId('active-users')).toHaveText('142', { timeout: 15000 });
  expect(callCount).toBeGreaterThanOrEqual(2);
});

WebSocket Connection Lifecycle

Use when: You need to verify that your app handles connection, disconnection, and reconnection properly. Avoid when: Connection lifecycle is not user-visible.

TypeScript

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

test('app reconnects after WebSocket drops', async ({ page }) => {
  let connectionCount = 0;

  await page.routeWebSocket('**/ws', (ws) => {
    connectionCount++;

    if (connectionCount === 1) {
      // First connection: close after a brief moment
      setTimeout(() => ws.close({ code: 1006, reason: 'Abnormal closure' }), 500);
    } else {
      // Second connection (reconnect): stay open and respond
      ws.onMessage((message) => {
        ws.send(JSON.stringify({ type: 'pong' }));
      });
    }
  });

  await page.goto('/app');

  // App detects disconnect and shows status
  await expect(page.getByText('Reconnecting...')).toBeVisible();

  // App reconnects and status returns to normal
  await expect(page.getByText('Connected')).toBeVisible({ timeout: 10000 });

  expect(connectionCount).toBe(2);
});

JavaScript

const { test, expect } = require('@playwright/test');

test('app reconnects after WebSocket drops', async ({ page }) => {
  let connectionCount = 0;

  await page.routeWebSocket('**/ws', (ws) => {
    connectionCount++;

    if (connectionCount === 1) {
      setTimeout(() => ws.close({ code: 1006, reason: 'Abnormal closure' }), 500);
    } else {
      ws.onMessage((message) => {
        ws.send(JSON.stringify({ type: 'pong' }));
      });
    }
  });

  await page.goto('/app');
  await expect(page.getByText('Reconnecting...')).toBeVisible();
  await expect(page.getByText('Connected')).toBeVisible({ timeout: 10000 });
  expect(connectionCount).toBe(2);
});

Decision Guide

Scenario Approach Why
Verify app sends correct WS message page.on('websocket') + ws.on('framesent') Observe without intercepting
Verify app handles server push page.routeWebSocket() with mock server Full control over what the "server" sends
Test with real server but inject messages routeWebSocket + connectToServer() Man-in-the-middle: forward real traffic plus inject extras
Test SSE endpoint page.route() with text/event-stream content type SSE is HTTP -- standard route interception works
Test HTTP polling page.route() with changing responses per call Increment a counter; return different data each call
Verify reconnection logic routeWebSocket that closes the first connection Simulate server failure, verify the app retries
Test binary WebSocket data ws.on('framereceived'), check frame.payload as Buffer Binary frames arrive as Buffer in Node.js

Anti-Patterns

Don't Do This Problem Do This Instead
page.waitForTimeout(3000) to wait for WS message Arbitrary delay; flaky and slow await expect(page.getByText('msg')).toBeVisible() or wait on a Promise
Directly construct WebSocket in page.evaluate You lose Playwright's observation and routing capabilities Let the app create its own WebSocket; intercept via routeWebSocket
Ignore WebSocket close codes in mocks App may behave differently for 1000 (normal) vs 1006 (abnormal) Use the correct close code: ws.close({ code: 1000 })
Test real-time features against live third-party servers Flaky, slow, and may incur costs Mock the WebSocket or SSE endpoint
Assert on raw WebSocket frame content in every test Couples tests to wire protocol; breaks on payload format changes Assert on the UI -- that is what users see
Forget to handle binary vs text frames frame.payload can be string or Buffer Check frame type or use String(frame.payload) consistently

Troubleshooting

Symptom Cause Fix
page.on('websocket') never fires WebSocket connects before the listener is attached Register the listener before page.goto()
routeWebSocket does not intercept URL pattern does not match the actual WebSocket URL Check the URL in DevTools Network tab; update the glob pattern
SSE mock returns all events at once route.fulfill sends the body synchronously For true streaming, use the real server or chunk the response with pauses via page.evaluate
WebSocket messages arrive but UI does not update App processes messages asynchronously; assertion runs too early Use await expect(...).toBeVisible() which auto-retries
Binary frames show as garbled text String(frame.payload) on binary data produces garbage Treat frame.payload as Buffer and decode appropriately
Reconnection test is flaky App has exponential backoff; timeout too short Increase assertion timeout: toBeVisible({ timeout: 15000 })

Related