smocket mascot: a cool cat wearing sunglassessmocket

Test socket.io without a server.

smocket reimplements socket.io's rooms, broadcasts, and acknowledgements in memory — and every release is verified against the real library.

Sweet setup, rocket speed.

  • MIT
  • v0.3.0
  • dual-run CI
a s’more rocket blasting off
Packed like a s’more, aimed like a rocket. Each message reaches exactly the sockets it was addressed to, and no others.

See who received what.

Rooms, exclusions, and targeted emits resolve exactly the way socket.io resolves them. Here is the delivery record.

  • A a3f1
  • B b7c2
  • C c9e4
socket_A.to('room-1').emit('stroke', { … })
→ B, C (except A)
io.to(sid_A).emit('word', 'giraffe')
→ A
io.to('room-1').emit('chat', { … })
→ A, B, C

Before, a second player was out of reach.

Hand-written mock
// polycasso/test/mock-socket.ts — before smocket
// A stand-in for socket.io, grown one test at a time.

type Handler = (...args: any[]) => void;

class MockSocket {
  id = 'socket-1';
  private handlers: Record<string, Handler[]> = {};

  on(event: string, fn: Handler) {
    (this.handlers[event] ??= []).push(fn);
  }

  emit(event: string, ...args: any[]) {
    // Only ever one socket, so emit just fans out to whatever
    // this socket registered. Rooms don't enter into it.
    for (const fn of this.handlers[event] ?? []) fn(...args);
  }

  join(_room: string) {} // stored nowhere, read nowhere
  to(_room: string) {
    // The quick fix was to return `this`, so every ".to(room)"
    // broadcast lands back on this same socket — whoever it was for.
    return this;
  }
}

// The harness assumes a single client. A second MockSocket shares no
// room map with the first, so player B never sees player A's strokes.
const socket = new MockSocket();
smocket
import { connect, Server } from 'smocket';

const io = new Server('http://localhost:3000');
const a = connect('http://localhost:3000');
const b = connect('http://localhost:3000');
const c = connect('http://localhost:3000');

190 lines of hand-written mock, and still only one player could connect.

Features

  • Delivery fidelity

    Rooms and socket ids live in the same bidirectional maps socket.io uses. Fan-out is a set operation, not a loop over guesses.

  • Checked against the real thing

    Every test runs twice: once against socket.io, once against smocket. A behavioural difference turns CI red.

  • No server, no ports

    Nothing binds, nothing listens. Tests start and finish in the same process.

  • Honest about its limits

    What a mock cannot have, smocket does not pretend to have. The list is short and written down.

Three players, one page, no server.

One person draws, two watch, and the delivery record on the right shows which socket received each event.

TODO(hyun): demo screenshotTODO(hyun): copy needed — demo entry link

Three steps.

  1. Install

    npm install -D smocket
  2. Change the import

    - import { Server } from 'socket.io';
    + import { Server } from 'smocket';
  3. Run the test

    import { expect, it } from 'vitest';
    import { connect, Server } from 'smocket';
    
    const received = (client, event) =>
      new Promise((resolve) => client.once(event, resolve));
    
    it('a room broadcast reaches the room and excludes the sender', async () => {
      const io = new Server('http://localhost:3000');
    
      const a = connect('http://localhost:3000');
      const b = connect('http://localhost:3000');
      const c = connect('http://localhost:3000');
    
      const socketA = await io.nextConnection();
      const socketB = await io.nextConnection();
      const socketC = await io.nextConnection();
    
      await socketA.join('room-1');
      await socketB.join('room-1');
      await socketC.join('room-1');
    
      let aReceived = false;
      a.on('stroke', () => (aReceived = true));
      const onB = received(b, 'stroke');
      const onC = received(c, 'stroke');
    
      // socket.to(room) delivers to the room and excludes the sender.
      socketA.to('room-1').emit('stroke', { x: 1, y: 2 });
    
      expect(await onB).toEqual({ x: 1, y: 2 });
      expect(await onC).toEqual({ x: 1, y: 2 });
      expect(aReceived).toBe(false);
    });

Scope

What smocket does

  • Rooms and namespaces
  • Broadcast, with and without exclusions
  • Targeted emits by socket id
  • Acknowledgements
  • Disconnect cleanup

What a mock cannot have

  • Reconnection behaviour — there is no "later" to wait for
  • Transport fallback — there is no transport
  • Heartbeat — there is no connection to check
  • Multi-server adapters — there is one process
  • Binary encoding — nothing is serialised
0%