Testing Real-Time Apps

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Real-time code fails in ways that ordinary request and response code does not: a message arrives before the listener exists, two tests share a room and one swallows the other’s id as a duplicate, a call connects on your laptop and never in CI, a heartbeat that takes twenty seconds to prove makes the suite take twenty seconds. This page is the testing strategy of the section. It shows how to keep most of the logic testable without a socket, how to run integration tests against a real server on a random port for each stack, how to drive a browser with Playwright and look inside its WebSocket connections, how to run a two-person video call headlessly, and how to test an XMPP client against a server in a container.

Every example on this page was run on 2026-10-24 against Node.js 24, socket.io and socket.io-client 4.8.4, ws 8.22.0, Python 3.13 with python-socketio 5.17.0, aiohttp 3.14.5, uvicorn 0.54.0, pytest 9.1.1 and pytest-asyncio 1.4.0, @playwright/test 1.64.1 (Chrome Headless Shell 156), @xmpp/client 0.14.0, ejabberd 26.09, .NET 10 with Microsoft.AspNetCore.Mvc.Testing and Microsoft.AspNetCore.SignalR.Client 10.0.13, and Swoole 6.2.3 on PHP 8.4. The examples test a small subset of the Chatterbox v1 protocol (join, message, acknowledgements, duplicates and error codes) defined on Message Design and Serialization; the full servers are on the stack pages linked in each section.

The test pyramid for a real-time application

Layer What it proves Speed Where on this page

Unit test of the message handler

Protocol rules: validation, seq, duplicate ids, error codes, permissions. No socket and no clock.

Milliseconds

Unit tests

Integration test with a real client

The wiring: the handshake, authentication, rooms, acknowledgements, broadcast, close codes. A real server on a random port, real client library.

Tens of milliseconds

Node.js, Python, other stacks

Browser end-to-end test

That the page, the bundle, the CORS and Origin rules and the browser WebSocket API work together; media and ICE for calls.

Seconds

Playwright

Test against a third-party server

That a client speaks XMPP correctly to a real server (SASL, MUC, ping).

Seconds, plus the container start

XMPP

Load and soak test

Capacity, fan-out latency, reconnect storms. Not a correctness test.

Minutes

Load Testing and Capacity Planning

Put most tests at the top two rows. The handler tests are what you run on every save; the integration tests are what proves that the transport, the framework and the protocol agree; the browser tests are few, because each one is slow and touches everything.

Why real-time tests are hard

  • The event can arrive before you wait for it. Register the listener, then send. A test that sends and then calls once("message") loses the message whenever the server answers in the same tick.

  • Order across connections is not defined. A frame written on connection A and a frame written on connection B can be processed in either order. When the second action depends on the first having been processed, make the first one round-trip (an acknowledgement, or an XMPP ping) before you start the second.

  • State outlives the test. A server shared by several tests keeps its rooms, its sequence numbers and its idempotency table. Ids and rooms must be unique per test, or the server must be created per test.

  • Absence is harder than presence. "No second broadcast arrived" needs a short bounded wait or a deterministic hook; never a bare sleep.

  • Ports and handles. Listen on port 0 and read the real port back, and close every socket and server in an after hook. A forgotten open handle keeps the test runner alive and the CI job hanging until its timeout.

  • Time. Heartbeats, grace periods and reconnection back-off are measured in seconds. Inject the clock or the intervals into the code under test, or fake the timers in the browser (see the fake clock).

  • Authentication. Tests should not log in through a real identity provider. Mint the one-time ticket or token with the same code the application uses, or give the test environment a fixed test key. The flows are on Authentication and Authorization and, for the token endpoints, OAuth.

Unit tests for the message handler

Keep the protocol rules in a function that takes plain objects and returns plain objects: the state, the user and the incoming envelope go in; the replies and the broadcasts to send come out. The transport adapters (Socket.IO, ws, Channels, a hub) then contain no decisions, and the decisions can be tested without a socket, a port or a timer. The handler below implements join, message with acknowledgement and duplicate detection, and the error envelopes of the Chatterbox protocol.

src/handler.js
// src/handler.js - transport-free core of a Chatterbox subset: join, message, duplicate ids, errors.
export function createState() {
  return { rooms: new Map(), acks: new Map() }; // room -> { members:Set, seq:number }; "user:id" -> ack
}

const reply = (type, ref, room, payload) => ({ v: 1, type, id: `s_${ref}`, room, ts: Date.now(), payload });
const error = (ref, room, code, message, retryable = false) =>
  reply("error", ref, room, { ref, code, message, retryable });

/** Returns { replies: [envelope], broadcast: [{ room, envelope }] } and mutates state. */
export function handle(state, userId, env) {
  if (env?.v !== 1) return { replies: [error(env?.id ?? null, null, "unsupported_version", "v must be 1")], broadcast: [] };
  const room = state.rooms.get(env.room) ?? { members: new Set(), seq: 0 };
  switch (env.type) {
    case "join":
      room.members.add(userId);
      state.rooms.set(env.room, room);
      return {
        replies: [reply("ack", env.id, env.room, { ref: env.id, ok: true, seq: null, dup: false, result: { members: [...room.members], lastSeq: room.seq } })],
        broadcast: [],
      };
    case "message": {
      if (!room.members.has(userId)) return { replies: [error(env.id, env.room, "forbidden", "join the room first", false)], broadcast: [] };
      const text = env.payload?.text;
      if (typeof text !== "string" || text.length < 1 || text.length > 4000)
        return { replies: [error(env.id, env.room, "bad_request", "text must be 1..4000 characters")], broadcast: [] };
      const key = `${userId}:${env.id}`;
      const known = state.acks.get(key);
      if (known) return { replies: [{ ...known, payload: { ...known.payload, dup: true } }], broadcast: [] };
      room.seq += 1;
      const ack = reply("ack", env.id, env.room, { ref: env.id, ok: true, seq: room.seq, dup: false, result: {} });
      state.acks.set(key, ack);
      const out = reply("message", env.id, env.room, { from: { userId }, text, replyTo: null, attachments: [], seq: room.seq, edited: false });
      return { replies: [ack], broadcast: [{ room: env.room, envelope: out }] };
    }
    default:
      return { replies: [error(env.id, env.room ?? null, "bad_request", `unknown type ${env.type}`)], broadcast: [] };
  }
}

The tests use the Node.js built-in runner (node:test and node:assert), which needs no dependency. They check the properties that are easiest to get wrong: a retry returns the original seq with dup set and produces no second broadcast, and every failure is an error envelope with a Chatterbox code.

test/handler.test.js
import test from "node:test";
import assert from "node:assert/strict";
import { createState, handle } from "../src/handler.js";
import { frame, id } from "../support/helpers.js";

test("a retry with the same id is a duplicate with the original seq", () => {
  const state = createState();
  handle(state, "alice", frame("join", "general", { since: null }, id()));
  const msg = frame("message", "general", { text: "Hello, Chatterbox" }, id());

  const first = handle(state, "alice", msg);
  const retry = handle(state, "alice", msg);

  assert.equal(first.replies[0].payload.seq, 1);
  assert.equal(retry.replies[0].payload.dup, true);
  assert.equal(retry.replies[0].payload.seq, 1);
  assert.equal(retry.broadcast.length, 0);
});

test("errors are envelopes with Chatterbox codes", () => {
  const state = createState();
  const forbidden = handle(state, "alice", frame("message", "secret", { text: "hi" }, id()));
  assert.equal(forbidden.replies[0].payload.code, "forbidden");

  handle(state, "alice", frame("join", "general", { since: null }, id()));
  const tooLong = handle(state, "alice", frame("message", "general", { text: "x".repeat(4001) }, id()));
  assert.equal(tooLong.replies[0].payload.code, "bad_request");

  const wrongVersion = handle(state, "alice", { ...frame("message", "general", { text: "hi" }, id()), v: 2 });
  assert.equal(wrongVersion.replies[0].payload.code, "unsupported_version");
});
support/helpers.js
export const frame = (type, room, payload, id) => ({ v: 1, type, id, room, ts: 0, payload });
let n = 0;
export const id = () => `01JAB3W7Q8Z4N5R6T7V8X9${String(++n).padStart(4, "0")}`;

Run them with node --test, which finds the *.test.js files by itself. Passing a directory (node --test test/) fails on Node.js 24, where the argument is treated as a module path; and a helper kept inside test/ is executed as a test file, which is why the helpers above live in support/.

The same shape works in every language: a pure function in Python, a service class in Java or C# that does not depend on WebSocketSession or HubCallerContext, a plain class behind a Laravel controller. The cost is an adapter layer one screen long; the gain is a test suite that does not break when you change transport.

Integration tests: Node.js

An integration test starts the real server on port 0, connects the real client library and exercises the whole path. The server below puts the handler of the previous section behind Socket.IO (the event chatterbox, the user in socket.handshake.auth) and behind a plain ws endpoint at /ws, so both test styles run against one process. It returns its port and a close() that disconnects every client first, because io.close() alone waits for them.

src/server.js
// src/server.js - the same handler behind Socket.IO (event "chatterbox") and behind a plain ws endpoint (/ws).
import { createServer } from "node:http";
import { Server } from "socket.io";
import { WebSocketServer } from "ws";
import { createState, handle } from "./handler.js";

export async function startServer(port = 0) {
  const http = createServer();
  const state = createState();
  const io = new Server(http, { serveClient: false, cors: { origin: "https://chat.example.test" } });
  const wss = new WebSocketServer({ noServer: true });
  const sockets = new Map(); // ws connection -> user id
  const rooms = new Map();   // room -> Set of ws connections

  io.use((socket, next) => {
    const user = socket.handshake.auth?.user;
    if (!user) return next(new Error("unauthorized"));
    socket.data.user = user;
    next();
  });
  io.on("connection", (socket) => {
    socket.on("chatterbox", (env, cb) => {
      const { replies, broadcast } = handle(state, socket.data.user, env);
      if (env?.type === "join") socket.join(env.room);
      for (const r of replies) cb ? cb(r) : socket.emit("chatterbox", r);
      for (const b of broadcast) io.to(b.room).emit("chatterbox", b.envelope);
    });
  });

  http.on("upgrade", (req, socket, head) => {
    const url = new URL(req.url, "http://x");
    if (url.pathname !== "/ws") return; // Socket.IO handles /socket.io/
    const user = url.searchParams.get("user");
    if (!user) { socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); return socket.destroy(); }
    wss.handleUpgrade(req, socket, head, (ws) => { sockets.set(ws, user); wss.emit("connection", ws); });
  });
  wss.on("connection", (ws) => {
    ws.on("message", (data) => {
      let env;
      try { env = JSON.parse(data.toString()); } catch { return ws.close(4400, "protocol error"); }
      const { replies, broadcast } = handle(state, sockets.get(ws), env);
      if (env?.type === "join") (rooms.get(env.room) ?? rooms.set(env.room, new Set()).get(env.room)).add(ws);
      replies.forEach((r) => ws.send(JSON.stringify(r)));
      for (const b of broadcast) rooms.get(b.room)?.forEach((c) => c.send(JSON.stringify(b.envelope)));
    });
    ws.on("close", () => { sockets.delete(ws); rooms.forEach((s) => s.delete(ws)); });
  });

  await new Promise((resolve) => http.listen(port, "127.0.0.1", resolve));
  return {
    port: http.address().port,
    async close() {
      io.disconnectSockets(true);
      wss.clients.forEach((c) => c.terminate());
      await io.close();
    },
  };
}

A Socket.IO test

emitWithAck() of the 4.8 client returns a promise, which turns the request and acknowledgement into a plain await. Broadcasts are not acknowledgements, so the test registers a one-shot listener before it sends. The last assertion shows how to check an idempotent retry: the same envelope id answered with dup: true and the original seq.

test/socketio.test.js
import test, { before, after } from "node:test";
import assert from "node:assert/strict";
import { io } from "socket.io-client";
import { startServer } from "../src/server.js";
import { frame, id } from "../support/helpers.js";

let server;
const clients = [];
before(async () => { server = await startServer(); });
after(async () => { clients.forEach((c) => c.disconnect()); await server.close(); });

async function connect(user) {
  const socket = io(`http://127.0.0.1:${server.port}`, { transports: ["websocket"], auth: { user }, reconnection: false, forceNew: true });
  clients.push(socket);
  await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("connect_error", reject); });
  return socket;
}
const once = (socket, event) => new Promise((resolve) => socket.once(event, resolve));

test("the middleware rejects a connection without credentials", async () => {
  const socket = io(`http://127.0.0.1:${server.port}`, { transports: ["websocket"], reconnection: false, forceNew: true });
  clients.push(socket);
  const err = await new Promise((resolve) => socket.once("connect_error", resolve));
  assert.equal(err.message, "unauthorized");
});

test("ack, broadcast to the room and duplicate detection", async () => {
  const [alice, bob] = await Promise.all([connect("alice"), connect("bob")]);
  await alice.emitWithAck("chatterbox", frame("join", "general", { since: null }, id()));
  await bob.emitWithAck("chatterbox", frame("join", "general", { since: null }, id()));

  const received = once(bob, "chatterbox");   // register BEFORE sending
  const msg = frame("message", "general", { text: "Hello, Chatterbox" }, id());
  const ack = await alice.emitWithAck("chatterbox", msg);
  assert.deepEqual([ack.type, ack.payload.seq, ack.payload.dup], ["ack", 1, false]);
  assert.equal((await received).payload.text, "Hello, Chatterbox");

  const dup = await alice.emitWithAck("chatterbox", msg);
  assert.deepEqual([dup.payload.seq, dup.payload.dup], [1, true]);
});

A ws test with a frame queue

A raw WebSocket client has no acknowledgement helper, so give the test one: a queue that stores every frame and a next(type) that resolves with the first frame of that type, or fails after a deadline. It makes tests independent of interleaved frames (a presence frame between an ack and a broadcast does not break the test) and gives a readable failure. The same file shows how to assert on handshake rejections (unexpected-response carries the HTTP status) and on a close code (4400 for a malformed frame).

test/ws.test.js
import test, { before, after } from "node:test";
import assert from "node:assert/strict";
import WebSocket from "ws";
import { startServer } from "../src/server.js";
import { frame, id } from "../support/helpers.js";

let server;
before(async () => { server = await startServer(); });
after(() => server.close());

/** Opens a socket and returns { ws, next(type) } where next() resolves with the next frame of that type. */
async function open(user) {
  const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?user=${user}`);
  const queue = [];
  const waiters = [];
  ws.on("message", (data) => {
    queue.push(JSON.parse(data.toString()));
    waiters.splice(0).forEach((w) => w());
  });
  await new Promise((resolve, reject) => { ws.once("open", resolve); ws.once("error", reject); });
  const next = async (type, ms = 2000) => {
    const deadline = Date.now() + ms;
    for (;;) {
      const i = queue.findIndex((e) => e.type === type);
      if (i >= 0) return queue.splice(i, 1)[0];
      const left = deadline - Date.now();
      if (left <= 0) throw new Error(`timed out waiting for a ${type} frame`);
      await new Promise((resolve) => { waiters.push(resolve); setTimeout(resolve, left); });
    }
  };
  return { ws, next, send: (env) => ws.send(JSON.stringify(env)) };
}

test("a message is acknowledged to the sender and broadcast to the room", async () => {
  const alice = await open("alice");
  const bob = await open("bob");
  for (const c of [alice, bob]) { c.send(frame("join", "general", { since: null }, id())); await c.next("ack"); }

  alice.send(frame("message", "general", { text: "Hello, Chatterbox" }, id()));
  assert.equal((await alice.next("ack")).payload.seq, 1);
  assert.equal((await bob.next("message")).payload.text, "Hello, Chatterbox");
  alice.ws.close(); bob.ws.close();
});

test("a malformed frame closes the socket with 4400", async () => {
  const c = await open("carol");
  const closed = new Promise((resolve) => c.ws.once("close", (code) => resolve(code)));
  c.ws.send("not json");
  assert.equal(await closed, 4400);
});

test("the upgrade without credentials is refused with 401", async () => {
  const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws`);
  const status = await new Promise((resolve) => ws.once("unexpected-response", (_req, res) => resolve(res.statusCode)));
  assert.equal(status, 401);
});

The three files ran with node --test in about two seconds: 7 tests, 0 failures. The package.json that pins the versions:

package.json
{
  "name": "chatterbox-tests",
  "private": true,
  "type": "module",
  "scripts": { "test": "node --test" },
  "dependencies": { "socket.io": "4.8.4", "ws": "8.22.0" },
  "devDependencies": { "socket.io-client": "4.8.4" }
}

The same approach tests a uWebSockets.js server (see the test client on uWebSockets.js) and a Socket.IO server inside NestJS (NestJS WebSockets). For the runner itself, mocking and coverage see the Node.js test runner.

Integration tests: Python

python-socketio has no in-process test client, so the test starts the ASGI app with Uvicorn on a free port inside the test and connects an AsyncClient. Wrap the server in a fixture so each test gets a fresh port and clean state. First the server under test (the same protocol subset, with the user taken from the auth argument of connect):

app.py
# app.py - a Chatterbox subset on python-socketio: join, message with ack, duplicate ids.
import socketio

sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins=["https://chat.example.test"])
app = socketio.ASGIApp(sio)

ROOMS: dict[str, int] = {}        # room -> last seq
ACKS: dict[tuple[str, str], dict] = {}   # (user, envelope id) -> ack


def reply(kind: str, ref: str, room: str | None, payload: dict) -> dict:
    return {"v": 1, "type": kind, "id": f"s_{ref}", "room": room, "ts": 0, "payload": payload}


@sio.event
async def connect(sid, environ, auth):
    if not auth or not auth.get("user"):
        raise socketio.exceptions.ConnectionRefusedError("unauthorized")
    await sio.save_session(sid, {"user": auth["user"]})


@sio.on("chatterbox")
async def chatterbox(sid, env):
    user = (await sio.get_session(sid))["user"]
    room = env.get("room")
    if env.get("type") == "join":
        await sio.enter_room(sid, room)
        ROOMS.setdefault(room, 0)
        return reply("ack", env["id"], room, {"ref": env["id"], "ok": True, "seq": None, "dup": False, "result": {}})
    if env.get("type") == "message":
        if room not in sio.rooms(sid):
            return reply("error", env["id"], room, {"ref": env["id"], "code": "forbidden", "message": "join first", "retryable": False})
        key = (user, env["id"])
        if key in ACKS:
            return {**ACKS[key], "payload": {**ACKS[key]["payload"], "dup": True}}
        ROOMS[room] += 1
        ack = reply("ack", env["id"], room, {"ref": env["id"], "ok": True, "seq": ROOMS[room], "dup": False, "result": {}})
        ACKS[key] = ack
        out = reply("message", env["id"], room, {"from": {"userId": user}, "text": env["payload"]["text"], "seq": ROOMS[room]})
        await sio.emit("chatterbox", out, room=room)
        return ack
    return reply("error", env.get("id", ""), room, {"ref": env.get("id"), "code": "bad_request", "message": "unknown type", "retryable": False})

The fixture asks the OS for a free port, starts uvicorn.Server as a task of the test’s event loop, waits for server.started and shuts it down after the test:

conftest.py
import asyncio
import socket

import pytest_asyncio
import uvicorn

from app import ACKS, ROOMS, app


@pytest_asyncio.fixture
async def base_url():
    ROOMS.clear()
    ACKS.clear()
    with socket.socket() as s:          # ask the OS for a free port, then hand it to uvicorn
        s.bind(("127.0.0.1", 0))
        port = s.getsockname()[1]
    server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
    task = asyncio.create_task(server.serve())
    while not server.started:           # wait until the listener is up
        await asyncio.sleep(0.01)
    yield f"http://127.0.0.1:{port}"
    server.should_exit = True
    await task
test_chat.py
import asyncio

import pytest
import socketio


def frame(kind, room, payload, id_):
    return {"v": 1, "type": kind, "id": id_, "room": room, "ts": 0, "payload": payload}


async def client(base_url, user):
    c = socketio.AsyncClient()
    await c.connect(base_url, auth={"user": user}, transports=["websocket"], wait_timeout=5)
    return c


async def test_ack_broadcast_and_duplicate(base_url):
    alice, bob = await client(base_url, "alice"), await client(base_url, "bob")
    inbox = asyncio.Queue()
    bob.on("chatterbox", lambda env: inbox.put_nowait(env))      # register before sending
    for c, n in ((alice, "J1"), (bob, "J2")):
        await c.call("chatterbox", frame("join", "general", {"since": None}, n), timeout=5)

    msg = frame("message", "general", {"text": "Hello, Chatterbox"}, "M1")
    ack = await alice.call("chatterbox", msg, timeout=5)
    assert (ack["type"], ack["payload"]["seq"], ack["payload"]["dup"]) == ("ack", 1, False)
    assert (await asyncio.wait_for(inbox.get(), 5))["payload"]["text"] == "Hello, Chatterbox"

    dup = await alice.call("chatterbox", msg, timeout=5)
    assert (dup["payload"]["seq"], dup["payload"]["dup"]) == (1, True)
    with pytest.raises(asyncio.TimeoutError):                    # absence: no second broadcast
        await asyncio.wait_for(inbox.get(), 0.3)
    await alice.disconnect()
    await bob.disconnect()


async def test_non_member_gets_forbidden(base_url):
    alice = await client(base_url, "alice")
    reply = await alice.call("chatterbox", frame("message", "secret", {"text": "hi"}, "M2"), timeout=5)
    assert reply["payload"]["code"] == "forbidden"
    await alice.disconnect()


async def test_connection_without_credentials_is_refused(base_url):
    c = socketio.AsyncClient()
    with pytest.raises(socketio.exceptions.ConnectionError):
        await c.connect(base_url, transports=["websocket"], wait_timeout=5)
pytest.ini
[pytest]
asyncio_mode = auto

The three tests passed in 0.95 s in a python:3.13-slim container (pip install python-socketio aiohttp uvicorn pytest pytest-asyncio, then python -m pytest). Points to remember:

  • AsyncClient needs aiohttp for its transport; without it connect() fails with a missing-module error.

  • call() is the awaitable form of emit-with-acknowledgement and raises socketio.exceptions.TimeoutError when no reply arrives; pass an explicit timeout.

  • A handler registered with client.on() runs on the loop. Put a received event on an asyncio.Queue and wait for it with asyncio.wait_for: the same pattern checks absence (a short wait_for that must time out).

  • asyncio_mode = auto lets the test functions be plain async def without a marker.

  • For Django Channels use WebsocketCommunicator instead (see the next section).

Other stacks

Each stack ships its own way to test without a browser. The complete, executed tests are on the stack pages; this table tells you where to find them and what to look for.

Stack Tool Notes

Django Channels

channels.testing.WebsocketCommunicator, ChannelsLiveServerTestCase for a real server

In-process, no socket; route to the consumer and set scope["user"] yourself. Use TransactionTestCase when the consumer touches the database. See Django Channels.

ASP.NET Core SignalR

WebApplicationFactory and a .NET HubConnection on the test server

Full pipeline in memory; example below. See SignalR and Testing ASP.NET Core.

Spring WebFlux

@SpringBootTest(webEnvironment = RANDOM_PORT), ReactorNettyWebSocketClient, StepVerifier

WebTestClient has no WebSocket support, so start the real server. See Spring WebFlux WebSockets and Spring Boot testing.

Laravel Reverb

Event::fake() and the broadcasting auth endpoint in feature tests

No WebSocket needed for the application logic; set the broadcast driver to reverb with throw-away credentials in phpunit.xml. See Laravel Reverb.

Swoole

Swoole\Coroutine\Http\Client with upgrade()

A coroutine client inside Swoole\Coroutine\run(); example below. See Swoole.

Atmosphere

A script client against a running container

See the test client on Atmosphere.

Socket.IO, ws, uWebSockets.js

The real client library on a random port

Sections above.

SignalR: WebApplicationFactory

The hub is in the web project, with public partial class Program; at the end so the test project can name it. The test project references it and builds a HubConnection whose transport is the in-memory test server: the HttpMessageHandlerFactory and the WebSocketFactory both come from factory.Server. A failing hub method throws HubException; the client receives it with the message the server chose.

App/Program.cs
using Microsoft.AspNetCore.SignalR;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
builder.Services.AddSingleton<MessageLog>();
var app = builder.Build();
app.MapHub<ChatHub>("/chat");
app.Run();

public sealed class MessageLog
{
    private readonly Dictionary<string, int> _seq = new();
    public int Next(string room) { lock (_seq) { _seq[room] = _seq.GetValueOrDefault(room) + 1; return _seq[room]; } }
}

public sealed class ChatHub(MessageLog log) : Hub
{
    public Task Join(string room) => Groups.AddToGroupAsync(Context.ConnectionId, room);

    public async Task<int> Send(string room, string text)
    {
        if (text.Length is 0 or > 4000) throw new HubException("bad_request");
        var seq = log.Next(room);
        await Clients.Group(room).SendAsync("message", new { room, text, seq });
        return seq;
    }
}

public partial class Program;   // makes Program visible to WebApplicationFactory<Program>
App.Tests/ChatHubTests.cs
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.SignalR.Client;
using Xunit;

public class ChatHubTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    // A HubConnection that talks to the in-memory TestServer through a real WebSocket.
    private async Task<HubConnection> ConnectAsync()
    {
        var server = factory.Server;
        var connection = new HubConnectionBuilder()
            .WithUrl(new Uri(server.BaseAddress, "/chat"), o =>
            {
                o.Transports = HttpTransportType.WebSockets;
                o.SkipNegotiation = true;                                   // WebSockets only: no negotiate request needed
                o.HttpMessageHandlerFactory = _ => server.CreateHandler();
                o.WebSocketFactory = async (context, ct) =>
                    await server.CreateWebSocketClient().ConnectAsync(context.Uri, ct);
            })
            .Build();
        await connection.StartAsync();
        return connection;
    }

    [Fact]
    public async Task A_message_reaches_the_room_with_a_sequence_number()
    {
        await using var alice = await ConnectAsync();
        await using var bob = await ConnectAsync();
        var received = new TaskCompletionSource<(string Room, string Text, int Seq)>();
        bob.On<System.Text.Json.JsonElement>("message", m =>
            received.TrySetResult((m.GetProperty("room").GetString()!, m.GetProperty("text").GetString()!, m.GetProperty("seq").GetInt32())));
        await alice.InvokeAsync("Join", "general");
        await bob.InvokeAsync("Join", "general");

        var seq = await alice.InvokeAsync<int>("Send", "general", "Hello, Chatterbox");

        var message = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
        Assert.Equal(1, seq);
        Assert.Equal(("general", "Hello, Chatterbox", 1), message);
    }

    [Fact]
    public async Task An_invalid_message_surfaces_as_a_HubException_on_the_client()
    {
        await using var alice = await ConnectAsync();
        var error = await Assert.ThrowsAsync<HubException>(() => alice.InvokeAsync<int>("Send", "general", ""));
        Assert.Contains("bad_request", error.Message);
    }
}
App.Tests/App.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />
    <PackageReference Include="xunit" Version="*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="*" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.*" />
    <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.*" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="../App/App.csproj" />
  </ItemGroup>
</Project>

Both tests passed with dotnet test in the mcr.microsoft.com/dotnet/sdk:10.0 image (2 passed, 0 failed). Pin the package versions in a real project instead of the floating * shown for the test SDK and xunit.

Swoole: the coroutine client

A Swoole test is a script that runs inside Swoole\Coroutine\run(), upgrades a Swoole\Coroutine\Http\Client and calls recv() with a timeout. The helper next_of() below does what the queue does in the Node.js tests: read until the wanted type arrives, or fail at the deadline. It tests any server that speaks the Chatterbox subset on port 9501, for example the Swoole server of the Swoole page or a container of it (CHAT_HOST selects the host).

client_test.php
<?php
// client_test.php - tests with the Swoole coroutine client; run with `php client_test.php`.
use Swoole\Coroutine;
use Swoole\Coroutine\Http\Client;

use function Swoole\Coroutine\run;

function frame(string $type, string $room, array $payload, string $id): string
{
    return json_encode(['v' => 1, 'type' => $type, 'id' => $id, 'room' => $room, 'ts' => 0, 'payload' => $payload]);
}

function connect(string $host, int $port): Client
{
    $client = new Client($host, $port);
    if (!$client->upgrade('/')) {
        throw new RuntimeException("upgrade failed: {$client->errCode}");
    }
    return $client;
}

/** Reads frames until one has the wanted type; a read that takes longer than $timeout seconds fails. */
function next_of(Client $c, string $type, float $timeout = 2.0): array
{
    $deadline = microtime(true) + $timeout;
    while (($left = $deadline - microtime(true)) > 0) {
        $f = $c->recv($left);
        if ($f === false || $f === '') {
            break;
        }
        $env = json_decode($f->data, true);
        if ($env['type'] === $type) {
            return $env;
        }
    }
    throw new RuntimeException("no $type frame within {$timeout}s");
}

function check(bool $ok, string $label): void
{
    echo ($ok ? 'PASS ' : 'FAIL ') . $label . PHP_EOL;
    if (!$ok) {
        exit(1);
    }
}

run(function () {
    $host = getenv('CHAT_HOST') ?: '127.0.0.1';
    $alice = connect($host, 9501);
    $bob = connect($host, 9501);
    foreach ([$alice, $bob] as $i => $c) {
        $c->push(frame('join', 'general', ['since' => null], "join$i"));
        next_of($c, 'ack');
    }

    $msg = frame('message', 'general', ['text' => 'Hello, Chatterbox'], 'm1');
    $alice->push($msg);
    $ack = next_of($alice, 'ack');
    check($ack['payload']['seq'] === 1 && $ack['payload']['dup'] === false, 'message is acknowledged with seq 1');
    check(next_of($bob, 'message')['payload']['text'] === 'Hello, Chatterbox', 'bob receives the broadcast');

    $alice->push($msg);
    $dup = next_of($alice, 'ack');
    check($dup['payload']['dup'] === true && $dup['payload']['seq'] === 1, 'the same id is a duplicate');

    $bob->push(frame('message', 'secret', ['text' => 'hi'], 'm2'));
    check(next_of($bob, 'error')['payload']['code'] === 'forbidden', 'a non-member gets forbidden');
    $alice->close();
    $bob->close();
});

The script ran in the phpswoole/swoole:6.2.3-php8.4 image against a one-worker Swoole WebSocket server of the same protocol subset and printed four PASS lines. In a PHPUnit suite, wrap the same body in a test method and start the server as a separate process in the setup.

Browser end-to-end tests with Playwright

A browser test is the only place where the page’s JavaScript, the browser’s WebSocket and WebRTC implementations, and the server’s Origin, CORS and TLS rules are exercised together. E2E Testing in the Browser covers Playwright, Selenium and Cypress in general; this section adds what is specific to real-time pages: observing the WebSocket, avoiding races with waiting assertions, simulating several users, fake media devices and fake time.

The setup

@playwright/test starts the application itself with the webServer option, waits for the URL, and runs the browsers with the options in use. The fake-device flags are Chromium launch arguments, so they belong in launchOptions.

playwright.config.js
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",
  timeout: 30_000,
  webServer: { command: "node server.js", url: "http://127.0.0.1:3100/", reuseExistingServer: false },
  use: {
    baseURL: "http://127.0.0.1:3100",
    launchOptions: {
      args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
    },
    trace: "retain-on-failure",
  },
});
package.json
{
  "name": "chatterbox-e2e",
  "private": true,
  "type": "module",
  "scripts": { "test": "playwright test" },
  "dependencies": { "ws": "8.22.0" },
  "devDependencies": { "@playwright/test": "1.64.1" }
}

Install the browser once with npx playwright install chromium (about 100 MB; in CI use npx playwright install --with-deps chromium). Playwright’s Chromium runs headless as Chrome Headless Shell, which is why the examples need no display.

The test application is the Chatterbox subset again, with two additions: a static page server and a signalling relay at /signal for the call test.

server.js
// server.js - static files, the Chatterbox subset on /ws and a raw signalling relay on /signal.
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { WebSocketServer } from "ws";
import { createState, handle } from "./handler.js";

const PORT = Number(process.env.PORT ?? 3100);
const state = createState();
const chat = new WebSocketServer({ noServer: true });
const signal = new WebSocketServer({ noServer: true });
const users = new Map();      // chat socket -> user
const roomSockets = new Map(); // room -> Set of chat sockets
const peers = new Map();       // call room -> Set of signalling sockets

const http = createServer(async (req, res) => {
  const { pathname } = new URL(req.url, "http://x");
  const name = pathname === "/" ? "chat.html" : pathname.slice(1);
  try {
    const body = await readFile(new URL(`./public/${name}`, import.meta.url));
    res.writeHead(200, { "content-type": name.endsWith(".html") ? "text/html" : "text/plain" }).end(body);
  } catch { res.writeHead(404).end("not found"); }
});

http.on("upgrade", (req, socket, head) => {
  const url = new URL(req.url, "http://x");
  const wss = url.pathname === "/ws" ? chat : url.pathname === "/signal" ? signal : null;
  if (!wss) return socket.destroy();
  wss.handleUpgrade(req, socket, head, (ws) => {
    ws.query = url.searchParams;
    wss.emit("connection", ws);
  });
});

chat.on("connection", (ws) => {
  users.set(ws, ws.query.get("user"));
  ws.on("message", (data) => {
    const env = JSON.parse(data.toString());
    const { replies, broadcast } = handle(state, users.get(ws), env);
    if (env.type === "join") (roomSockets.get(env.room) ?? roomSockets.set(env.room, new Set()).get(env.room)).add(ws);
    replies.forEach((r) => ws.send(JSON.stringify(r)));
    broadcast.forEach((b) => roomSockets.get(b.room)?.forEach((c) => c.send(JSON.stringify(b.envelope))));
  });
  ws.on("close", () => users.delete(ws));
});

signal.on("connection", (ws) => {
  const room = ws.query.get("room");
  const set = peers.get(room) ?? peers.set(room, new Set()).get(room);
  ws.send(JSON.stringify({ type: "peers", count: set.size }));  // the second arrival creates the offer
  set.add(ws);
  ws.on("message", (data) => set.forEach((c) => c !== ws && c.send(data.toString())));
  ws.on("close", () => set.delete(ws));
});

http.listen(PORT, "127.0.0.1", () => console.log(`listening on ${PORT}`));

The chat page sends and receives envelopes. It gives every envelope a random id (a constant id would make the second test that sends a message look like a duplicate of the first) and marks body with data-joined when the server acknowledged the join, so tests wait on a DOM condition instead of a sleep. It also has an application heartbeat, used later.

public/chat.html
<!doctype html>
<meta charset="utf-8">
<title>Chatterbox test page</title>
<ul id="log" aria-label="messages"></ul>
<form id="form"><input id="text" aria-label="Message"><button>Send</button></form>
<script>
  const user = new URLSearchParams(location.search).get("user");
  const ws = new WebSocket(`ws://${location.host}/ws?user=${user}`);
  const newId = () => crypto.randomUUID().replaceAll("-", "").slice(0, 26);
  let joinId;
  const send = (type, payload) => {
    const id = newId();
    ws.send(JSON.stringify({ v: 1, type, id, room: "general", ts: 0, payload }));
    return id;
  };
  ws.onopen = () => { joinId = send("join", { since: null }); };
  ws.onmessage = (e) => {
    const env = JSON.parse(e.data);
    if (env.type === "ack" && env.payload.ref === joinId) document.body.dataset.joined = "true";
    if (env.type !== "message") return;
    const li = document.createElement("li");
    li.textContent = `${env.payload.from.userId}: ${env.payload.text}`;
    log.append(li);
  };
  setInterval(() => ws.readyState === WebSocket.OPEN && send("presence", { status: "online" }), 20000);  // application heartbeat
  form.onsubmit = (e) => { e.preventDefault(); send("message", { text: text.value }); text.value = ""; };
</script>

Looking inside the WebSocket

Playwright reports every WebSocket the page opens as a websocket event on the Page, and the WebSocket object emits framesent, framereceived, socketerror and close. Register the listener before page.goto(): a socket opened during load is gone from the event stream otherwise. Parsing the frames gives tests access to the protocol itself, not only to what the UI shows: the first test asserts that the UI message produced a well-formed message envelope and that the server’s ack carries ok, a seq and dup: false.

tests/chat.spec.js
import { test, expect } from "@playwright/test";

test("the page sends and receives Chatterbox envelopes", async ({ page }) => {
  const sent = [];
  const received = [];
  page.on("websocket", (ws) => {
    ws.on("framesent", (f) => sent.push(JSON.parse(f.payload)));
    ws.on("framereceived", (f) => received.push(JSON.parse(f.payload)));
  });

  await page.goto("/?user=alice");
  await expect(page.locator("body")).toHaveAttribute("data-joined", "true");
  await page.getByRole("textbox", { name: "Message" }).fill("Hello, Chatterbox");
  await page.getByRole("button", { name: "Send" }).click();

  await expect(page.getByRole("list", { name: "messages" })).toContainText("alice: Hello, Chatterbox");
  const message = sent.find((e) => e.type === "message");
  expect(message).toMatchObject({ v: 1, room: "general", payload: { text: "Hello, Chatterbox" } });
  const ack = received.find((e) => e.type === "ack" && e.payload.ref === message.id);
  expect(ack.payload).toMatchObject({ ok: true, seq: expect.any(Number), dup: false });
});

test("two browser contexts are two users", async ({ browser }) => {
  const alice = await (await browser.newContext()).newPage();
  const bob = await (await browser.newContext()).newPage();
  await Promise.all([alice.goto("/?user=alice"), bob.goto("/?user=bob")]);
  for (const page of [alice, bob]) await expect(page.locator("body")).toHaveAttribute("data-joined", "true");

  await alice.getByRole("textbox", { name: "Message" }).fill("Anyone there?");
  await alice.getByRole("button", { name: "Send" }).click();
  await expect(bob.getByRole("list", { name: "messages" })).toContainText("alice: Anyone there?");
});

Things worth knowing about this test:

  • Assert on the DOM with a web-first assertion (expect(locator).toContainText()), which retries until the timeout. Use expect.poll() for values that are not in the DOM, such as a captured frame list.

  • The framesent and framereceived payload is a string for text frames and a Buffer for binary frames (a protocol with binary snapshots, as in Pixel Arena, needs Buffer handling).

  • getByLabel("Message") is a substring match by default and also matched the label messages of the list, which fails the strict-mode check. getByRole("textbox", { name: "Message" }) is the precise form.

  • The config keeps a trace of failed tests (trace set to retain-on-failure), which is how a CI failure is investigated afterwards.

  • page.routeWebSocket() can intercept and mock a WebSocket, which is the tool for testing how the page behaves when the server sends something unusual, or for running a page without a back end (see the network documentation).

Two users: two browser contexts

A BrowserContext is an isolated browser profile: its own cookies, storage and permissions. Two contexts of one browser are two users, cheaper than two browsers. The second test of the file above has Alice and Bob in separate contexts, waits for both joins, and checks that a message typed by Alice appears on Bob’s page.

The relay and the database of the server persist across tests unless you reset them. Playwright runs test files in parallel workers against the one webServer, so every test uses its own user and its own message ids (as above), or its own room. Use a unique room per test (a counter or the test title) whenever a test asserts on seq or on who is a member.

Fake camera and microphone

A headless browser has no camera. Chromium provides flags for tests:

Flag Effect

--use-fake-device-for-media-stream

getUserMedia() returns a synthetic video (a moving test pattern) and audio (a beep) instead of failing with NotFoundError.

--use-fake-ui-for-media-stream

Accepts the permission prompt automatically. The call test passed with this flag alone and failed when only Playwright’s permissions: ["camera", "microphone"] was set, so keep the flag.

--use-file-for-fake-video-capture=clip.y4m and --use-file-for-fake-audio-capture=clip.wav

Plays a recorded clip instead of the test pattern; useful for tests that check the received picture or recognise a tone.

Firefox has equivalent preferences (media.navigator.streams.fake, media.navigator.permission.disabled, passed in firefoxUserPrefs), WebKit has no such switch, so media tests run on Chromium. Because the flags are launch arguments, they cannot be changed per context: a project that needs a real device test and a fake one defines two Playwright projects with different launchOptions. Without the flags the call test below failed: the page never reached connected.

Two contexts, one call

The call page captures the fake camera and microphone, adds the tracks to an RTCPeerConnection, and signals through the relay: the page that arrives second creates the offer, the first answers (a minimal flow, not the Perfect Negotiation pattern of Signaling and Perfect Negotiation). There is no STUN or TURN server: both pages are on one machine and connect with host candidates. The page exposes a function that sums bytesReceived of the inbound video reports, so the test can assert that media really flows, not only that the connection state says connected.

public/huddle.html
<!doctype html>
<meta charset="utf-8">
<title>Huddle test page</title>
<video id="local" autoplay muted playsinline></video><video id="remote" autoplay playsinline></video>
<p id="state">idle</p>
<script>
  const room = new URLSearchParams(location.search).get("room");
  const pc = new RTCPeerConnection();                      // host candidates only: both pages are on one machine
  const sig = new WebSocket(`ws://${location.host}/signal?room=${room}`);
  const send = (m) => sig.send(JSON.stringify(m));
  pc.onicecandidate = (e) => e.candidate && send({ candidate: e.candidate });
  sig.onopen = () => { state.textContent = "waiting"; };
  pc.ontrack = (e) => { remote.srcObject = e.streams[0]; };
  pc.onconnectionstatechange = () => { state.textContent = pc.connectionState; };
  const media = navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then((stream) => {
    local.srcObject = stream;
    stream.getTracks().forEach((t) => pc.addTrack(t, stream));
  });
  sig.onmessage = async (e) => {
    const m = JSON.parse(e.data);
    await media;
    if (m.type === "peers" && m.count > 0) {                // I arrived second: I make the offer
      await pc.setLocalDescription(await pc.createOffer());
      send({ description: pc.localDescription });
    } else if (m.description) {
      await pc.setRemoteDescription(m.description);
      if (m.description.type === "offer") {
        await pc.setLocalDescription(await pc.createAnswer());
        send({ description: pc.localDescription });
      }
    } else if (m.candidate) {
      await pc.addIceCandidate(m.candidate);
    }
  };
  window.inboundVideoBytes = async () => {
    const report = await pc.getStats();
    let bytes = 0;
    report.forEach((s) => { if (s.type === "inbound-rtp" && s.kind === "video") bytes += s.bytesReceived; });
    return bytes;
  };
</script>
tests/huddle.spec.js
import { test, expect } from "@playwright/test";

test("two contexts get a call with media flowing both ways", async ({ browser }) => {
  const first = await (await browser.newContext()).newPage();
  await first.goto("/huddle.html?room=daily");
  await expect(first.locator("#state")).toHaveText("waiting");   // signalling is open before the second page arrives

  const second = await (await browser.newContext()).newPage();
  await second.goto("/huddle.html?room=daily");

  for (const page of [first, second]) {
    await expect(page.locator("#state")).toHaveText("connected");
    await expect.poll(() => page.evaluate(() => window.inboundVideoBytes()), { timeout: 15_000 }).toBeGreaterThan(0);
  }
});

The test waits for the first page to report waiting before it opens the second, so the order of arrival (and therefore who offers) is deterministic. It then asserts the connection state on both sides and polls the statistics until bytes arrive. More on statistics is on Stats, Quality and Bandwidth, and the ICE behaviour you would add TURN for is on ICE, STUN and TURN.

A fake clock for heartbeats

page.clock replaces the page’s Date, setTimeout, setInterval and requestAnimationFrame. install() must run before the page loads so that the page’s timers are the fake ones; fastForward() then jumps time and fires due timers at most once per timer, which is why the assertion below is toBeGreaterThanOrEqual(1). Use runFor() when every intermediate firing matters. The WebSocket itself keeps real time: only the page’s timers are faked, so the server must not depend on the page’s clock.

tests/heartbeat.spec.js
import { test, expect } from "@playwright/test";

test("the client sends a presence heartbeat every 20 seconds, without waiting 20 seconds", async ({ page }) => {
  const heartbeats = [];
  page.on("websocket", (ws) =>
    ws.on("framesent", (f) => {
      const env = JSON.parse(f.payload);
      if (env.type === "presence") heartbeats.push(env);
    }));

  await page.clock.install();                 // before goto: the page's timers are now fake
  await page.goto("/?user=carol");
  await expect(page.locator("body")).toHaveAttribute("data-joined", "true");
  expect(heartbeats).toHaveLength(0);

  await page.clock.fastForward(61_000);       // three heartbeat intervals in no time
  await expect.poll(() => heartbeats.length).toBeGreaterThanOrEqual(1);
  expect(heartbeats[0]).toMatchObject({ type: "presence", payload: { status: "online" } });
});

All four tests (the two chat tests, the call and the heartbeat) passed in about one second on three workers, repeatedly. The same technique tests a reconnection back-off: close the server side with server.close, fast-forward past the delay and assert a new websocket event. The back-off design is on Connection Lifecycle, Heartbeats and Reconnection.

Latency and packet loss are not modelled by any of these tests; the techniques for them are in the "Testing under bad networks" section of Building a Multiplayer Game.

XMPP against a containerised server

An XMPP client or bot is best tested against a real server: SASL, resource binding, MUC and stream management behave differently enough between mocks and servers. a throw-away container per suite is cheap. The Compose file publishes only the HTTP/WebSocket listener, on a high loopback port, and mounts a minimal configuration with WebSocket (RFC 7395), pings and multi-user chat. The domain chat.example.test is reserved for documentation, and the passwords are placeholders.

compose.yaml
name: g7-testing-real-time-apps
services:
  ejabberd:
    image: ghcr.io/processone/ejabberd:26.09
    container_name: g7-testing-real-time-apps-ejabberd
    hostname: chat.example.test
    ports:
      - "127.0.0.1:18780:5280"   # HTTP: the WebSocket endpoint /ws
    volumes:
      - ./ejabberd.yml:/opt/ejabberd/conf/ejabberd.yml:ro
    healthcheck:
      test: ["CMD", "ejabberdctl", "status"]
      interval: 3s
      timeout: 5s
      retries: 20
ejabberd.yml
hosts:
  - chat.example.test
loglevel: warning
listen:
  - port: 5222
    ip: "::"
    module: ejabberd_c2s
    starttls_required: false
  - port: 5280
    ip: "::"
    module: ejabberd_http
    request_handlers:
      /ws: ejabberd_http_ws
acl:
  local:
    user_regexp: ""
access_rules:
  local:
    allow: local
  muc_create:
    allow: local
  muc:
    allow: all
modules:
  mod_disco: {}
  mod_ping: {}
  mod_roster: {}
  mod_muc:
    access: muc
    access_create: muc_create

Start it, wait for the health check, create the two test accounts with ejabberdctl, run the tests and remove everything:

docker compose up -d --wait
docker compose exec ejabberd ejabberdctl register alice chat.example.test change-me-alice
docker compose exec ejabberd ejabberdctl register bob chat.example.test change-me-bob
npm test
docker compose down -v

The tests use @xmpp/client over WebSocket. The helper logs a user in, sends presence and then performs a ping round trip: the server has processed the presence once the ping is answered, so a message sent by the other account right after login() cannot arrive while the recipient still looks offline. Without it, the first test failed intermittently because the message raced the presence and never arrived.

test/xmpp.test.js
import test, { before, after } from "node:test";
import assert from "node:assert/strict";
import { client, xml } from "@xmpp/client";

const URL = process.env.XMPP_WS ?? "ws://127.0.0.1:18780/ws";
const DOMAIN = "chat.example.test";
const online = [];

/** Connects, authenticates and returns the client with a stanza inbox. */
async function login(user, password) {
  const xmpp = client({ service: URL, domain: DOMAIN, username: user, password });
  const inbox = [];
  const waiters = [];
  xmpp.on("stanza", (s) => { inbox.push(s); waiters.splice(0).forEach((w) => w()); });
  xmpp.on("error", (e) => console.error(`[${user}]`, e.message));
  await xmpp.start();
  await xmpp.send(xml("presence"));
  // a stanza that was written is not a stanza that was processed: a ping round trip on the same stream
  // guarantees the server has handled the presence before another account sends a message
  await xmpp.iqCaller.request(xml("iq", { type: "get", to: DOMAIN }, xml("ping", { xmlns: "urn:xmpp:ping" })));
  online.push(xmpp);
  xmpp.next = async (match, ms = 5000) => {
    const deadline = Date.now() + ms;
    for (;;) {
      const i = inbox.findIndex(match);
      if (i >= 0) return inbox.splice(i, 1)[0];
      const left = deadline - Date.now();
      if (left <= 0) throw new Error("timed out; inbox=" + inbox.map((s) => s.toString()).join(" "));
      await new Promise((resolve) => { waiters.push(resolve); setTimeout(resolve, left); });
    }
  };
  return xmpp;
}

after(async () => { await Promise.all(online.map((c) => c.stop().catch(() => {}))); });

test("a chat message reaches the other account", async () => {
  const alice = await login("alice", "change-me-alice");
  const bob = await login("bob", "change-me-bob");

  await alice.send(xml("message", { type: "chat", to: `bob@${DOMAIN}`, id: "m1" }, xml("body", {}, "Hello, Chatterbox")));
  const got = await bob.next((s) => s.is("message") && s.getChild("body"));
  assert.equal(got.getChildText("body"), "Hello, Chatterbox");
  assert.equal(got.attrs.from.startsWith(`alice@${DOMAIN}`), true);
});

test("the server answers a ping (XEP-0199)", async () => {
  const alice = await login("alice", "change-me-alice");
  const result = await alice.iqCaller.request(xml("iq", { type: "get", to: DOMAIN }, xml("ping", { xmlns: "urn:xmpp:ping" })));
  assert.equal(result.attrs.type, "result");
});

test("a wrong password fails SASL with not-authorized", async () => {
  const intruder = client({ service: URL, domain: DOMAIN, username: "alice", password: "wrong" });
  intruder.on("error", () => {});
  await assert.rejects(intruder.start(), (e) => e.name === "SASLError" && e.element.getChild("not-authorized") !== undefined);
  await intruder.stop().catch(() => {});
});

test("a multi-user chat room echoes what a member says", async () => {
  const alice = await login("alice", "change-me-alice");
  const room = `lobby@conference.${DOMAIN}`;
  await alice.send(xml("presence", { to: `${room}/alice` }, xml("x", { xmlns: "http://jabber.org/protocol/muc" })));
  await alice.next((s) => s.is("presence") && s.attrs.from === `${room}/alice` && !s.attrs.type);
  await alice.send(xml("message", { type: "groupchat", to: room, id: "g1" }, xml("body", {}, "Hello, room")));
  const echo = await alice.next((s) => s.is("message") && s.attrs.type === "groupchat" && s.getChild("body"));
  assert.equal(echo.getChildText("body"), "Hello, room");
});
package.json
{
  "name": "chatterbox-xmpp-tests",
  "private": true,
  "type": "module",
  "scripts": { "test": "node --test" },
  "dependencies": { "@xmpp/client": "0.14.0" }
}

The four tests passed repeatedly, in well under a second once the container was healthy. What each shows:

  • Chat message. Two accounts, a message of type chat, the body and the full JID of the sender.

  • Ping. iqCaller.request() sends an IQ and resolves with the result; it is the XMPP equivalent of an acknowledgement.

  • Bad password. The failure surfaces as a SASLError. This version of the library reads the first child of the failure element as the condition and reports text, so the test inspects the element for not-authorized instead of comparing error.condition. The client also reconnects by default; stop it in the test (as intruder.stop() does) or the suite never ends.

  • Multi-user chat. Joining lobby@conference.chat.example.test and receiving the echo of the own groupchat message.

Other points: use a fresh container (and down -v) per CI run so accounts and archives never leak between runs; use the Testcontainers modules when the suite is JVM or .NET based (see Testcontainers Fundamentals); and keep the server’s configuration in the repository next to the tests, since the tests encode its behaviour (archive policy, registration, upload limits). The server options and the Prosody equivalent are on XMPP Servers, and the client libraries on XMPP in the Browser. For a browser E2E test of an XMPP web client, start the same container and point the page at ws://127.0.0.1:18780/ws with Playwright.

Running the suites in CI

  • Start infrastructure with Docker Compose (up -d --wait uses the health checks) and publish ports on 127.0.0.1 only; see Compose for Local Development.

  • Prefer random or high ports, read from the environment in the tests, so two jobs on one runner do not collide.

  • Install browsers with npx playwright install --with-deps chromium and cache ~/.cache/ms-playwright.

  • Keep Playwright traces and the container logs as artifacts of failed runs.

  • Give each test a deadline shorter than the job’s (a timed-out next() that names the frame type is more useful than a killed job).

  • Measure capacity and latency in a separate job, not in the functional suite (Load Testing and Capacity Planning; JMeter is covered in JMeter).

Common errors and pitfalls

  • Sending before listening. Register socket.on, ws.on("message"), bob.on("chatterbox", handler) first.

  • node --test test/ fails on Node.js 24. Use node --test or a glob; keep non-test helpers outside the test folder.

  • Shared state between tests. Reused ids become duplicates (dup: true, no broadcast), reused rooms leak members. Generate ids, or start a server per test.

  • Open handles. io.close() waits for clients; disconnect them first. XMPP clients reconnect unless stopped.

  • getByLabel matches substrings. Use getByRole with a name, or { exact: true }.

  • page.on("websocket") registered after goto. The first socket is missed.

  • Missing fake-device flags. getUserMedia() fails and the call never connects; the failure looks like an ICE problem.

  • Asserting connected only. Check inbound bytes or frames decoded; a connected peer connection can carry silence.

  • Sleeping. Replace every sleep with a waiting assertion, a frame queue, or an acknowledgement round trip.

  • Testing the transport with the logic. A handler that needs a socket to be tested will be tested slowly and rarely.

  • Real identity providers in tests. Use test tickets or a fixed test key and keep the real login flow for one smoke test.

Version and specification notes

  • Playwright 1.64 bundles Chromium 156 as Chrome Headless Shell for headless runs; flags are passed through launchOptions.args. page.clock (since 1.45) and page.routeWebSocket() (since 1.48) are part of the stable API.

  • The Node.js test runner (node:test) is stable; its file discovery changed across versions, so check the argument form your Node.js version accepts.

  • Socket.IO 4.8 clients return promises from emitWithAck(); python-socketio 5 uses call() for the same pattern.

  • WebApplicationFactory and a HubConnection with a custom WebSocketFactory work on .NET 10; the in-memory test server has no TCP port, so tools that need a URL (curl, a browser) cannot reach it.

  • pytest-asyncio 1.x removed the event_loop fixture; use asyncio_mode = auto or the loop_scope marker argument.

  • @xmpp/client 0.14 reconnects by default and exposes iqCaller for request and response IQs.