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:
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.
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.
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.
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.
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
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 assertionssimulateOpen()— flips the transport into OPEN and fires onopensimulateClose(code?, reason?)— defaults to 1006, pass 1000 for a clean closesimulateMessage(data)— delivers a server framesimulateError()— fires the error handlerreset()— wipes captured calls; listeners stay attached
Need the last sent payload as JSON? It is just a one-liner against
sentMessages:
const last = transport.sentMessages.at(-1);
const parsed = typeof last === "string" ? JSON.parse(last) : null;Next steps
- API reference — full manager surface you can assert against
- Error handling — patterns to test, including offline send and deserialize errors
- Authentication — testing the first-message auth flow