esc

Type to search...

Testing

react-socket ships a tiny in-memory transport so you can unit-test components and hooks without a real WebSocket server. The transport implements the same IWebSocketTransport interface the manager uses internally, so behavior matches production.

Import it from the dedicated subpath so the testing helpers do not leak into your production bundle:

setup.ts
import { MockTransport } from "@luciodale/react-socket/testing";

Basic flow

Pass transport in the manager config, then drive the lifecycle with simulateOpen, simulateMessage, and friends. The transport records every connect, send, and disconnect call so you can assert against them.

greeting.test.ts
import { describe, expect, it } from "vitest";
import { WebSocketManager } from "@luciodale/react-socket";
import { MockTransport } from "@luciodale/react-socket/testing";

type TClientMsg = { type: "hello"; name: string };
type TServerMsg = { type: "hi"; name: string };

describe("greeting flow", () => {
  it("sends hello and reacts to hi", () => {
    const transport = new MockTransport();
    const received: TServerMsg[] = [];

    const manager = new WebSocketManager<TClientMsg, TServerMsg>({
      url: "ws://test",
      transport,
      serialize: (msg) => JSON.stringify(msg),
      deserialize: (raw) => JSON.parse(raw),
    });
    manager.addMessageListener((msg) => received.push(msg));

    manager.connect();
    transport.simulateOpen();

    manager.send({ data: { type: "hello", name: "world" } });
    const last = transport.sentMessages.at(-1);
    expect(typeof last === "string" ? JSON.parse(last) : last).toEqual({
      type: "hello",
      name: "world",
    });

    transport.simulateMessage(JSON.stringify({ type: "hi", name: "server" }));
    expect(received).toEqual([{ type: "hi", name: "server" }]);
  });
});

Simulating reconnects

simulateClose() defaults to code 1006, which the manager treats as an abnormal drop and schedules a reconnect. Lower reconnectBaseDelayMs and reconnectMaxAttempts in tests so backoff does not slow the suite.

reconnect.test.ts
it("schedules reconnect on abnormal close", async () => {
  const transport = new MockTransport();
  const states: string[] = [];

  const manager = new WebSocketManager<TClientMsg, TServerMsg>({
    url: "ws://test",
    transport,
    serialize: (m) => JSON.stringify(m),
    deserialize: (r) => JSON.parse(r),
    reconnectBaseDelayMs: 10,
    reconnectMaxAttempts: 3,
  });
  manager.addConnectionStateListener(() => {
    states.push(manager.getConnectionState());
  });

  manager.connect();
  transport.simulateOpen();
  expect(states).toContain("connected");

  // Drop the connection. 1006 is the default for simulateClose.
  transport.simulateClose();

  // States should now include "reconnecting"
  expect(states).toContain("reconnecting");

  // Each reconnect attempt records a fresh connectCall
  await vi.waitFor(() => {
    expect(transport.connectCalls.length).toBeGreaterThan(1);
  });
});

Simulating ack flow

Wire getAckId in the manager config and then deliver a delivered frame to confirm the in-flight registry clears.

ack.test.ts
it("clears in-flight on ack from server", () => {
  const transport = new MockTransport();
  const manager = new WebSocketManager<TClientMsg, TServerMsg>({
    url: "ws://test",
    transport,
    serialize: (m) => JSON.stringify(m),
    deserialize: (r) => JSON.parse(r),
    getAckId: (msg) => (msg.type === "delivered" ? msg.ackId : undefined),
  });

  manager.connect();
  transport.simulateOpen();

  manager.send({ data: { type: "send", text: "hi" }, ackId: "ack-1" });
  expect(manager.getSnapshot().inFlightMessages.has("ack-1")).toBe(true);

  transport.simulateMessage(JSON.stringify({ type: "delivered", ackId: "ack-1" }));
  expect(manager.getSnapshot().inFlightMessages.has("ack-1")).toBe(false);
});

Testing hooks

Use React Testing Library's renderHook with act to drive the manager. The hook reads from the manager via useSyncExternalStore, so each synchronous transition is observable on the next render.

useSocketConnectionState.test.tsx
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
  useSocketConnectionState,
  WebSocketManager,
} from "@luciodale/react-socket";
import { MockTransport } from "@luciodale/react-socket/testing";

describe("useSocketConnectionState", () => {
  it("tracks state transitions", () => {
    const transport = new MockTransport();
    const manager = new WebSocketManager<{ type: "x" }, { type: "x" }>({
      url: "ws://test",
      transport,
      serialize: JSON.stringify,
      deserialize: JSON.parse,
    });

    const { result } = renderHook(() => useSocketConnectionState(manager));
    expect(result.current).toBe("idle"); // connect() not called yet

    act(() => {
      manager.connect();
    });
    expect(result.current).toBe("connecting");

    act(() => {
      transport.simulateOpen();
    });
    expect(result.current).toBe("connected");
  });
});

Vitest setup

A jsdom-based environment is enough for hook tests. No browser needed.

vitest.config.ts
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "jsdom",
    globals: true,
  },
});

What MockTransport exposes

  • connectCalls, sentMessages, disconnectCalls — captured in order, ready for direct assertions
  • simulateOpen() — flips the transport into OPEN and fires onopen
  • simulateClose(code?, reason?) — defaults to 1006, pass 1000 for a clean close
  • simulateMessage(data) — delivers a server frame
  • simulateError() — fires the error handler
  • reset() — wipes captured calls; listeners stay attached

Need the last sent payload as JSON? It is just a one-liner against sentMessages:

parse-last.ts
const last = transport.sentMessages.at(-1);
const parsed = typeof last === "string" ? JSON.parse(last) : null;

Next steps