Resource library

QA How-To

Mock WebSocket Server Responses in Playwright

Playwright mock WebSocket server responses tutorial with runnable tests for messages, errors, connection state, passthrough traffic, and reliable assertions.

18 min read | 2,438 words

TL;DR

Call page.routeWebSocket() before navigation, match the target socket URL, and use the provided WebSocketRoute to send controlled server messages. Add onMessage handlers for request-response behavior, or call connectToServer() when only selected traffic should be mocked.

Key Takeaways

  • Register page.routeWebSocket before the application creates its WebSocket connection.
  • Use WebSocketRoute.send() to emit deterministic text or binary messages from a fully mocked server.
  • Drive mocked replies from client messages with WebSocketRoute.onMessage().
  • Use connectToServer() when you want to patch selected frames while forwarding all other traffic.
  • Assert visible application state instead of testing only low-level frame traffic.
  • Model connection lifecycle events, malformed payloads, and delayed replies as explicit scenarios.

A reliable Playwright mock websocket server responses tutorial should let you test realtime UI behavior without starting, seeding, or destabilizing a real socket backend. Playwright can intercept a WebSocket created by the page, observe messages from the browser, send controlled responses, and close the mocked connection.

This tutorial builds that workflow one piece at a time. For the wider strategy, including frame inspection, lifecycle coverage, and test boundaries, read the Playwright WebSocket testing complete guide.

You will create a small notification client directly inside a Playwright test, replace its server with page.routeWebSocket(), and verify the UI after realistic text, delayed, invalid, and binary responses. You will also learn when to use a complete mock and when to connect to the real server and patch only selected messages.

What You Will Build

You will build a runnable Playwright test suite that can:

  • intercept wss://realtime.example.test/notifications;
  • simulate the socket opening without a separate backend process;
  • reply to a client subscription message with controlled JSON;
  • emit delayed updates and malformed data;
  • send a binary frame and verify browser-side decoding;
  • forward traffic to a real server while changing selected frames.

The example application renders a status label, a notification list, and an error message. That small surface is enough to demonstrate the same patterns used by chat clients, trading dashboards, collaborative editors, job status screens, and live test-reporting tools.

Prerequisites

Use Node.js 20 or newer and a current Playwright Test release. Create a clean project and install Chromium with these commands:

mkdir playwright-websocket-mock
cd playwright-websocket-mock
npm init playwright@latest
npx playwright install chromium

Choose TypeScript when the initializer asks. Keep the generated playwright.config.ts. The examples use the stable page.routeWebSocket() and WebSocketRoute APIs and do not require the third-party ws package for the fully mocked scenarios.

Confirm the runner works before adding the tutorial code:

npx playwright test --project=chromium

Verification: the generated example test should pass. If the command cannot find a browser executable, run npx playwright install chromium again from the same project directory.

Step 1: Create a Minimal Realtime Client

Create tests/websocket-mock.spec.ts. Start with a helper that installs an application into the page. Keeping it in the test makes this tutorial runnable without a web server, while the WebSocket usage remains identical to production browser code.

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

const SOCKET_URL = 'wss://realtime.example.test/notifications';

async function installNotificationApp(page: Page) {
  await page.setContent(`
    <main>
      <p data-testid="status">connecting</p>
      <button id="connect">Connect</button>
      <ul data-testid="notifications"></ul>
      <p data-testid="error"></p>
    </main>
    <script>
      const status = document.querySelector('[data-testid=status]');
      const list = document.querySelector('[data-testid=notifications]');
      const error = document.querySelector('[data-testid=error]');

      document.querySelector('#connect').addEventListener('click', () => {
        const socket = new WebSocket('${SOCKET_URL}');
        socket.binaryType = 'arraybuffer';

        socket.addEventListener('open', () => {
          status.textContent = 'connected';
          socket.send(JSON.stringify({ type: 'subscribe', channel: 'jobs' }));
        });

        socket.addEventListener('message', event => {
          try {
            const text = typeof event.data === 'string'
              ? event.data
              : new TextDecoder().decode(event.data);
            const message = JSON.parse(text);
            if (message.type === 'notification') {
              const item = document.createElement('li');
              item.textContent = message.text;
              list.appendChild(item);
            }
          } catch {
            error.textContent = 'Invalid realtime message';
          }
        });

        socket.addEventListener('close', () => {
          status.textContent = 'disconnected';
        });
        socket.addEventListener('error', () => {
          error.textContent = 'Realtime connection failed';
        });
      });
    </script>
  `);
}

The page does not connect until the button is clicked. That gives the test a clear arrange, act, assert sequence and prevents a race between route registration and socket creation. The client also handles string and ArrayBuffer data so later tests can exercise both frame types.

Verification: temporarily add a test that calls installNotificationApp(page) and asserts await expect(page.getByTestId('status')).toHaveText('connecting'). The assertion confirms that the DOM and script were installed.

Step 2: Register a Playwright WebSocket Mock Before Connecting

Register the WebSocket route before clicking Connect. A route affects only sockets created after registration, so ordering is part of the test contract.

test('opens a fully mocked WebSocket', async ({ page }) => {
  await page.routeWebSocket(SOCKET_URL, ws => {
    ws.onMessage(message => {
      console.log('Client sent:', message);
    });
  });

  await installNotificationApp(page);
  await page.getByRole('button', { name: 'Connect' }).click();

  await expect(page.getByTestId('status')).toHaveText('connected');
});

The callback receives a WebSocketRoute, named ws here. If you do not call connectToServer(), Playwright treats it as a fully mocked connection. The browser receives an open connection, but no external DNS lookup or socket service is needed.

The exact URL is ideal for a focused test. In a larger application, a pattern can match dynamic query parameters:

await page.routeWebSocket(/realtime\.example\.test\/notifications(?:\?.*)?$/, ws => {
  ws.onMessage(message => console.log(message));
});

Prefer the narrowest matcher that covers the intended endpoint. A broad pattern such as **/* can accidentally intercept analytics, development tooling, or an unrelated realtime feature.

Verification: run npx playwright test tests/websocket-mock.spec.ts --project=chromium. The status assertion passes even when realtime.example.test does not exist, proving the browser used the mock.

Step 3: Playwright Mock WebSocket Server Responses Tutorial for Client Messages

Most realtime protocols begin with a client command such as subscribe, authenticate, or join. Parse that command inside onMessage() and send the matching server response. This is the central pattern in this Playwright mock websocket server responses tutorial.

test('renders a mocked notification after subscribing', async ({ page }) => {
  await page.routeWebSocket(SOCKET_URL, ws => {
    ws.onMessage(message => {
      if (typeof message !== 'string') return;

      const request = JSON.parse(message);
      if (request.type === 'subscribe' && request.channel === 'jobs') {
        ws.send(JSON.stringify({
          type: 'notification',
          id: 'notice-101',
          text: 'Your resume analysis is ready'
        }));
      }
    });
  });

  await installNotificationApp(page);
  await page.getByRole('button', { name: 'Connect' }).click();

  await expect(page.getByTestId('status')).toHaveText('connected');
  await expect(page.getByTestId('notifications')).toContainText(
    'Your resume analysis is ready'
  );
});

WebSocketRoute.send() represents a message traveling from the mocked server to the page. The onMessage() callback represents a message traveling from the page to the mocked endpoint. Keeping those directions explicit prevents the most common conceptual error in socket mocks.

Assert the user-visible result, not only that a frame was exchanged. The list assertion proves that the client parsed the response and updated the DOM. If you need to diagnose the exact payload before writing a mock, follow the step-by-step guide to inspecting WebSocket frames in Playwright.

Verification: the test passes and the notification list contains exactly the controlled text. Change channel: 'jobs' in the client to another value and the UI assertion should time out, confirming that the mocked protocol condition matters.

Step 4: Emit Unsolicited and Delayed Server Updates

A server does not always wait for a client request. Notifications, price ticks, and progress events can arrive after the subscription is established. Use a short timer inside the route to model that behavior.

test('shows a delayed server push', async ({ page }) => {
  await page.routeWebSocket(SOCKET_URL, ws => {
    ws.onMessage(message => {
      if (typeof message !== 'string') return;
      const request = JSON.parse(message);
      if (request.type !== 'subscribe') return;

      setTimeout(() => {
        ws.send(JSON.stringify({
          type: 'notification',
          id: 'notice-102',
          text: 'Match score updated to 86 percent'
        }));
      }, 150);
    });
  });

  await installNotificationApp(page);
  await page.getByRole('button', { name: 'Connect' }).click();

  await expect(page.getByTestId('notifications')).toContainText(
    'Match score updated to 86 percent'
  );
});

Use Playwright assertions to wait for the outcome. Do not add page.waitForTimeout(500) before the assertion. Web-first assertions poll until the expected UI appears, making the test faster when the update is immediate and more resilient when the machine is busy.

A tiny controlled delay is useful when it represents an important intermediate state. For example, assert that a loading indicator is visible before sending the completion event. Avoid random delay ranges because they create nondeterminism without improving coverage.

Verification: the assertion initially waits, then passes after the mocked push. Run the test several times with --repeat-each=5; all runs should pass without increasing an arbitrary sleep.

Step 5: Test Malformed and Binary Server Responses

Production clients must tolerate unexpected data. A complete mock can send malformed text and binary data precisely, without corrupting a shared test environment.

First, verify invalid JSON handling:

test('shows an error for malformed server data', async ({ page }) => {
  await page.routeWebSocket(SOCKET_URL, ws => {
    ws.onMessage(() => ws.send('{not-valid-json'));
  });

  await installNotificationApp(page);
  await page.getByRole('button', { name: 'Connect' }).click();

  await expect(page.getByTestId('error')).toHaveText(
    'Invalid realtime message'
  );
});

Then verify a binary response. The browser client already sets binaryType to arraybuffer and decodes bytes with TextDecoder.

test('decodes a binary notification', async ({ page }) => {
  await page.routeWebSocket(SOCKET_URL, ws => {
    ws.onMessage(() => {
      const payload = JSON.stringify({
        type: 'notification',
        id: 'notice-103',
        text: 'Binary update received'
      });
      ws.send(Buffer.from(payload, 'utf8'));
    });
  });

  await installNotificationApp(page);
  await page.getByRole('button', { name: 'Connect' }).click();

  await expect(page.getByTestId('notifications')).toContainText(
    'Binary update received'
  );
});

Playwright represents binary route messages with buffers and text messages with strings. Preserve the intended type. Converting every message to a string can hide bugs in protobuf, MessagePack, compressed, or custom binary clients.

Verification: both tests pass independently. The malformed case updates only the error element, while the binary case adds a notification and leaves the error empty.

Step 6: Mock Disconnects and Connection Lifecycle

Close the route to simulate the server ending a session. This lets you verify disconnected banners, disabled controls, or retry behavior without killing a backend process.

test('shows disconnected when the mocked server closes', async ({ page }) => {
  await page.routeWebSocket(SOCKET_URL, ws => {
    ws.onMessage(message => {
      if (typeof message !== 'string') return;
      const request = JSON.parse(message);
      if (request.type === 'subscribe') {
        ws.send(JSON.stringify({
          type: 'notification',
          text: 'Connection will close'
        }));
        ws.close();
      }
    });
  });

  await installNotificationApp(page);
  await page.getByRole('button', { name: 'Connect' }).click();

  await expect(page.getByTestId('notifications')).toContainText(
    'Connection will close'
  );
  await expect(page.getByTestId('status')).toHaveText('disconnected');
});

This test checks an orderly server-side close. A network interruption and a protocol close are not identical from the client's perspective, so name the scenario accurately. If your application retries with exponential backoff, give the reconnect behavior its own suite and use controllable clocks where appropriate. The Playwright WebSocket reconnection testing tutorial covers that lifecycle in depth.

Keep lifecycle assertions at the UI or application-state boundary. An assertion that only counts close events can pass even when the user still sees a stale Connected badge.

Verification: the notification appears before the status becomes disconnected. If ordering is business-critical, expose an event log in test mode and assert the sequence, rather than relying on two unrelated final-state assertions.

Step 7: Patch Selected Messages With a Real Server Connection

A full mock is best for deterministic component-level browser tests. Sometimes you need a hybrid test that preserves the real handshake and most traffic while changing one response. Call connectToServer() to establish the upstream connection, then explicitly forward or modify messages.

test('patches selected upstream messages', async ({ page }) => {
  await page.routeWebSocket(SOCKET_URL, client => {
    const server = client.connectToServer();

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

    server.onMessage(message => {
      if (typeof message !== 'string') {
        client.send(message);
        return;
      }

      const payload = JSON.parse(message);
      if (payload.type === 'notification' && payload.id === 'force-review') {
        client.send(JSON.stringify({
          ...payload,
          text: 'Controlled review notification'
        }));
        return;
      }
      client.send(message);
    });
  });

  // Navigate to an environment where SOCKET_URL is reachable.
  await page.goto('https://app.example.test/dashboard');
  await expect(page.getByText('Controlled review notification')).toBeVisible();
});

This example is intentionally for your reachable test environment, unlike the self-contained full mocks in earlier steps. Once you register onMessage() on either side, make forwarding decisions explicitly. Forward client messages to server, and forward unchanged server messages to client.

Approach Backend needed Determinism Best use
Full routeWebSocket mock No High UI states and edge cases
Hybrid connectToServer route Yes Medium Patch one frame in realistic traffic
Unmodified end-to-end socket Yes Lower Deployment and integration confidence

Do not replace every socket test with a mock. Keep a small integration layer against a real environment to catch authentication, proxy, TLS, deployment, and server-contract failures.

Verification: run the hybrid test only where both the page and socket endpoint are reachable. Confirm the selected ID displays controlled text, while an unrelated real notification passes through unchanged.

Step 8: Make the Mock Reusable and Scenario Driven

Avoid copying route callbacks across a large suite. Create a helper that accepts a scenario and returns captured client messages for protocol assertions.

import type { Page } from '@playwright/test';

type MockScenario = {
  responseText: string;
  delayMs?: number;
  closeAfterResponse?: boolean;
};

async function mockNotifications(page: Page, scenario: MockScenario) {
  const received: string[] = [];

  await page.routeWebSocket(SOCKET_URL, ws => {
    ws.onMessage(message => {
      if (typeof message !== 'string') return;
      received.push(message);

      const sendResponse = () => {
        ws.send(JSON.stringify({
          type: 'notification',
          text: scenario.responseText
        }));
        if (scenario.closeAfterResponse) ws.close();
      };

      if (scenario.delayMs) setTimeout(sendResponse, scenario.delayMs);
      else sendResponse();
    });
  });

  return received;
}

test('uses a scenario-driven notification mock', async ({ page }) => {
  const received = await mockNotifications(page, {
    responseText: 'Scenario response',
    closeAfterResponse: false
  });

  await installNotificationApp(page);
  await page.getByRole('button', { name: 'Connect' }).click();

  await expect(page.getByText('Scenario response')).toBeVisible();
  expect(received).toHaveLength(1);
  expect(JSON.parse(received[0])).toMatchObject({
    type: 'subscribe',
    channel: 'jobs'
  });
});

Keep scenario inputs semantic. Names such as closeAfterResponse explain business-relevant behavior better than exposing a sequence of callback functions in every test. Capture messages only when the outgoing protocol is part of the behavior under test. Otherwise, the visible UI assertion is sufficient.

Use one route per endpoint and one scenario per test. Dense mocks that branch on the test title or global variables become hard to understand and can leak state in parallel execution.

Verification: run the full file with npx playwright test tests/websocket-mock.spec.ts --project=chromium --repeat-each=3. Every test should pass independently and in any order.

Troubleshooting

Problem: the real server receives the connection -> Register page.routeWebSocket() before the action or navigation that creates the socket. Existing WebSocket connections are not retroactively intercepted.

Problem: the route handler never runs -> Check the complete URL, including ws: versus wss:, path, port, and query string. Temporarily use a narrowly scoped regular expression that allows query parameters, then tighten it after observing the actual URL.

Problem: the client sends data but no UI update appears -> Confirm direction. Receive browser messages with ws.onMessage(), then send the mocked server response with ws.send(). Also verify that the response matches the application schema and required event type.

Problem: binary data becomes corrupted -> Pass a Buffer for binary traffic and a string for text traffic. Make sure browser code sets the expected binaryType and decodes the same wire format used in production.

Problem: a hybrid route stops normal traffic -> Once you add message handlers around connectToServer(), forward every message that you do not intentionally modify or drop. Forward client traffic to the server route and upstream traffic to the client route.

Problem: the test is flaky after adding a delay -> Remove fixed sleeps from the test. Await a locator assertion or another observable state. Keep mock delays short, fixed, and limited to scenarios that actually test an intermediate state.

Best Practices for the Playwright Mock WebSocket Server Responses Tutorial

Do keep mock payloads aligned with the production message contract. Share TypeScript types or validated fixtures when the application architecture allows it. A mock with an obsolete payload can make a broken client look healthy.

Do register routes inside each test or a carefully scoped fixture. Playwright creates a fresh page for each test by default, which makes route isolation straightforward. Do not store scenario state in mutable module globals because parallel workers do not provide the ordering such a design assumes.

Do assert what the user observes: a new notification, updated progress, disabled action, error banner, or reconnect status. Low-level message capture is useful for protocol diagnostics, but it should support the behavior assertion rather than replace it. For more UI-focused patterns, see these Playwright realtime notification assertion examples.

Do not mock authentication, network transport, backend rules, and UI behavior in every test and then call the result end to end. Label the test boundary honestly. A balanced suite uses many deterministic mock-driven UI tests, fewer real socket integration tests, and a small number of full deployment checks.

Do not use random payloads unless the property under test requires generated data. Stable IDs and messages make failures reproducible and traces easy to read. If uniqueness is necessary, derive it from test metadata or a fixture without changing business meaning.

Where To Go Next

You can now intercept a WebSocket, drive replies from client messages, push delayed data, send malformed or binary frames, close the connection, and patch selected upstream traffic. Use the complete Playwright WebSocket testing guide to place these techniques in a broader realtime test strategy.

Continue with the task that matches your next risk:

Interview Questions and Answers

Q: Why must routeWebSocket be registered before navigation or the connect action?

The route intercepts new WebSocket connections. It cannot take control of a socket that the page already created, so early registration prevents the application from reaching the real endpoint.

Q: What is the difference between send and onMessage on WebSocketRoute?

onMessage() observes traffic received by that route endpoint. In a fully mocked page route, it receives client messages. send() emits a message from the route toward the opposite endpoint, which makes it act like a server response.

Q: When should you call connectToServer?

Call it for a hybrid test that must preserve a real upstream connection while observing, modifying, or dropping selected messages. Do not call it for a fully isolated mock because the goal there is to avoid the backend dependency.

Q: What should a WebSocket UI test assert?

Assert user-visible application behavior such as rendered content, connection state, error handling, or enabled actions. Add frame assertions when the outbound protocol itself is a requirement, but do not treat frame receipt alone as proof that the UI works.

Q: How do you reduce flakiness in delayed realtime tests?

Use deterministic mock timing and Playwright web-first assertions. Avoid arbitrary waits, random delays, and shared mutable scenario state. Keep each test focused on one transition.

Q: Why keep real WebSocket integration tests if route mocks are deterministic?

Mocks cannot prove that TLS, proxies, authentication, deployment configuration, or the live server contract works. A smaller real integration layer covers those boundaries while the mock-heavy layer provides fast and precise UI coverage.

Conclusion

Use page.routeWebSocket() before the browser connects, handle client commands with onMessage(), and return controlled server data with send(). That combination gives you deterministic coverage of realtime success, delay, malformed data, binary payload, and disconnect behavior without maintaining a dedicated socket backend for every UI test.

Package repeated behavior as explicit scenarios, assert the visible result, and reserve connectToServer() for tests that genuinely need upstream traffic. Then add a focused real-server layer so your suite catches both client behavior defects and integration failures.

Interview Questions and Answers

How do you fully mock a WebSocket server in Playwright?

Register page.routeWebSocket() before the application connects. In the handler, observe browser messages with onMessage() and send controlled server payloads with send(). Do not call connectToServer(), because that would create an upstream connection.

What is the difference between a full WebSocket mock and a hybrid route?

A full mock has no upstream server and provides the highest determinism. A hybrid route calls connectToServer(), forwards most traffic, and modifies selected messages. Hybrid coverage is more realistic but depends on the environment and live server state.

Why should WebSocket routes be installed before navigation?

Applications often create sockets during startup. Playwright routes only connections created after registration, so installing the route later can allow the real connection to escape interception. Registering first removes that race.

How would you verify a mocked realtime notification?

Drive the subscription or action through the UI, send a contract-valid notification from the route, and assert the visible notification content or state. Optionally capture the client subscription frame when its schema is also a requirement.

How do you test malformed WebSocket payload handling?

Send invalid JSON or a schema-invalid but syntactically valid payload from the route. Assert that the application remains usable and shows the intended error or ignores the message safely. Keep separate tests for each failure category.

What causes flakiness in mocked WebSocket tests?

Common causes include late route registration, fixed sleeps, random delays, shared mutable mock state, and assertions against transient implementation details. Use isolated routes, deterministic scenarios, and web-first assertions against observable application behavior.

Why are real-server tests still necessary after adding WebSocket mocks?

A route mock validates client behavior against the contract you encoded, but it cannot validate the deployed server, authentication, network infrastructure, or contract drift. Keep a smaller integration suite against a real endpoint to cover those risks.

Frequently Asked Questions

Can Playwright mock a WebSocket server without starting a backend?

Yes. Register page.routeWebSocket() before the page creates the connection and do not call connectToServer(). The route then acts as the server endpoint, and you can return text or binary messages with send().

Does page.route() intercept WebSocket messages?

No. HTTP routing and WebSocket message routing use different APIs. Use page.routeWebSocket() for WebSocket connections and frames.

How do I send a mocked WebSocket message to the browser in Playwright?

Inside the page.routeWebSocket() callback, call send() on the WebSocketRoute with a string for text data or a Buffer for binary data. Register the route before the client connects.

Can Playwright modify messages from a real WebSocket server?

Yes. Call connectToServer(), listen for upstream messages, modify the selected payload, and send it to the client route. Explicitly forward every message that should remain unchanged.

How should I test a WebSocket disconnect in Playwright?

Call close() on the mocked WebSocketRoute after the desired event, then assert the application's visible disconnected state. Test retry and recovery separately if the client reconnects automatically.

Can a Playwright WebSocket mock send binary frames?

Yes. Pass a Buffer to WebSocketRoute.send() and configure the browser client for the expected binary type. Verify that the application decodes and renders the binary payload correctly.

Why is my Playwright WebSocket route not matching?

The socket may have connected before route registration, or its actual URL may differ in protocol, host, port, path, or query parameters. Register earlier and compare the matcher with the complete runtime URL.

Related Guides