第二十三章:单元测试——用 Jest 测试 WebSocket 服务器

说实话,写信令服务器最怕什么?不是逻辑复杂,而是改了一行代码,不知道哪里会崩。WebSocket 是长连接,状态都在内存里,出了问题很难复现。我早期有个项目,上线后才发现某个路由分支没覆盖到,结果用户多了直接雪崩。

从那以后,我养成了一个习惯:先写测试,再写代码。今天我们就聊聊怎么用 Jest 给 WebSocket 服务器做单元测试。

为什么选 Jest?

Jest 是 Facebook 出的测试框架,用起来很顺手。它自带断言、mock、覆盖率报告,不用额外装一堆插件。对于 WebSocket 测试,我们需要模拟连接、发送消息、验证路由,Jest 的 mock 能力刚好够用。

我个人习惯把测试分成三层:

  • 单元测试:测试单个函数或模块,比如消息解析、路由分发
  • 集成测试:测试 WebSocket 服务器与客户端的交互
  • 端到端测试:模拟真实用户场景

今天重点讲前两层。

搭建测试环境

先装依赖:

npm install --save-dev jest ws

package.json 里加一条脚本:

"scripts": {
  "test": "jest --coverage"
}

嗯,这里要注意:--coverage 会生成覆盖率报告,帮你发现哪些代码没测到。

模拟 WebSocket 连接

测试 WebSocket 服务器,核心是模拟客户端连接。Jest 本身不提供 WebSocket 客户端,但我们可以用 ws 库自带的 WebSocket 类。

来看一个最简单的测试:

const WebSocket = require('ws');
const http = require('http');

// 创建一个简单的 WebSocket 服务器
function createServer() {
  const server = http.createServer();
  const wss = new WebSocket.Server({ server });

  wss.on('connection', (ws) => {
    ws.on('message', (data) => {
      const msg = JSON.parse(data);
      if (msg.type === 'ping') {
        ws.send(JSON.stringify({ type: 'pong' }));
      }
    });
  });

  return server;
}

describe('WebSocket 服务器测试', () => {
  let server;
  let port;

  beforeAll((done) => {
    server = createServer();
    server.listen(0, () => {
      port = server.address().port;
      done();
    });
  });

  afterAll((done) => {
    server.close(done);
  });

  test('应该响应 ping 消息', (done) => {
    const client = new WebSocket(`ws://localhost:${port}`);

    client.on('open', () => {
      client.send(JSON.stringify({ type: 'ping' }));
    });

    client.on('message', (data) => {
      const msg = JSON.parse(data);
      expect(msg.type).toBe('pong');
      client.close();
      done();
    });
  });
});

这段代码做了几件事:

  • beforeAll 启动服务器,端口设为 0(随机端口,避免冲突)
  • afterAll 关闭服务器
  • 测试里创建客户端,发送 ping,验证收到 pong

我在项目中遇到过一个问题:done 回调没调用,测试超时。后来发现是事件没触发,或者触发了但没走到 done()。建议给 done 加个超时参数:

test('应该响应 ping 消息', (done) => {
  // ... 代码 ...
  setTimeout(() => done(new Error('超时')), 3000);
});

测试消息路由

信令服务器的核心是消息路由。不同的消息类型走不同的处理逻辑。我们来看看怎么测试路由。

假设服务器代码是这样的:

// router.js
function handleMessage(ws, data) {
  const msg = JSON.parse(data);
  switch (msg.type) {
    case 'join':
      handleJoin(ws, msg.room);
      break;
    case 'offer':
      handleOffer(ws, msg.target, msg.sdp);
      break;
    case 'ice-candidate':
      handleIceCandidate(ws, msg.target, msg.candidate);
      break;
    default:
      ws.send(JSON.stringify({ type: 'error', message: '未知消息类型' }));
  }
}

测试路由,我们需要 mock 掉具体的处理函数,只验证路由是否正确分发:

const { handleMessage } = require('./router');

jest.mock('./handlers', () => ({
  handleJoin: jest.fn(),
  handleOffer: jest.fn(),
  handleIceCandidate: jest.fn(),
}));

const { handleJoin, handleOffer, handleIceCandidate } = require('./handlers');

describe('消息路由测试', () => {
  let mockWs;

  beforeEach(() => {
    mockWs = { send: jest.fn() };
    jest.clearAllMocks();
  });

  test('join 消息应该调用 handleJoin', () => {
    handleMessage(mockWs, JSON.stringify({ type: 'join', room: 'room1' }));
    expect(handleJoin).toHaveBeenCalledWith(mockWs, 'room1');
  });

  test('offer 消息应该调用 handleOffer', () => {
    handleMessage(mockWs, JSON.stringify({ type: 'offer', target: 'user2', sdp: '...' }));
    expect(handleOffer).toHaveBeenCalledWith(mockWs, 'user2', '...');
  });

  test('未知消息类型应该返回错误', () => {
    handleMessage(mockWs, JSON.stringify({ type: 'unknown' }));
    expect(mockWs.send).toHaveBeenCalledWith(
      JSON.stringify({ type: 'error', message: '未知消息类型' })
    );
  });
});

这里用了 jest.mock 把 handlers 模块整个替换成 mock 函数。这样测试路由时,不会真的执行房间加入逻辑,只验证参数传对了没。

小技巧:mock 的时候记得在 beforeEach 里调用 jest.clearAllMocks(),否则测试之间会互相影响。我吃过这个亏,排查了半天才发现是 mock 状态没清干净。

测试连接生命周期

WebSocket 连接有 open、close、error 等事件。这些也需要测。比如,客户端断开后,服务器应该清理房间里的用户信息。

test('客户端断开后应该从房间移除', (done) => {
  const client = new WebSocket(`ws://localhost:${port}`);

  client.on('open', () => {
    client.send(JSON.stringify({ type: 'join', room: 'room1' }));
  });

  client.on('message', (data) => {
    const msg = JSON.parse(data);
    if (msg.type === 'joined') {
      // 主动断开
      client.close();
    }
  });

  client.on('close', () => {
    // 验证房间里的用户数
    const room = getRoom('room1');
    expect(room.users.length).toBe(0);
    done();
  });
});

这里有个坑:close 事件可能在服务器处理完清理逻辑之前触发。我建议加个 setTimeout 或者用 process.nextTick 延迟一下断言。

注意:测试异步代码时,一定要处理好时序。WebSocket 的事件是异步的,不要假设事件触发的顺序。用 done 回调或者 async/await 来控制流程。

测试并发连接

信令服务器经常要处理多个客户端同时连接。我们可以用 Promise.all 模拟并发:

test('多个客户端可以同时加入房间', async () => {
  const clients = [];
  const promises = [];

  for (let i = 0; i < 5; i++) {
    const client = new WebSocket(`ws://localhost:${port}`);
    clients.push(client);

    promises.push(new Promise((resolve) => {
      client.on('open', () => {
        client.send(JSON.stringify({ type: 'join', room: 'room1' }));
      });
      client.on('message', (data) => {
        const msg = JSON.parse(data);
        if (msg.type === 'joined') {
          resolve();
        }
      });
    }));
  }

  await Promise.all(promises);

  const room = getRoom('room1');
  expect(room.users.length).toBe(5);

  clients.forEach(c => c.close());
});

这种测试能帮你发现竞态条件。我记得有一次,多个客户端同时加入房间,服务器没加锁,导致用户列表数据错乱。后来加了并发测试才复现出来。

覆盖率报告怎么看

Jest 的覆盖率报告会告诉你:

  • 语句覆盖率:多少行代码被执行了
  • 分支覆盖率:if/else、switch 等分支是否都走到了
  • 函数覆盖率:所有函数是否都被调用过
  • 行覆盖率:每行代码是否都被执行

我个人习惯把分支覆盖率作为硬指标,至少 80%。因为路由逻辑里,漏掉一个分支就可能出线上事故。

总结一下

用 Jest 测试 WebSocket 服务器,核心就三点:

  1. 模拟连接:用 ws 库创建客户端,连接测试服务器
  2. Mock 依赖:用 jest.mock 隔离路由和业务逻辑
  3. 控制异步:用 done 回调或 async/await 处理事件

测试不是一次性工作。每加一个新功能,先写测试再写代码。你会发现,代码质量上去了,改代码的胆子也大了。

核心要点

  • beforeAll/afterAll 管理服务器生命周期
  • jest.mock 隔离路由和业务逻辑
  • done 回调处理异步事件
  • 并发测试能发现竞态条件
  • 分支覆盖率至少 80%
Jest WebSocket 测试流程 Jest 测试套件 beforeAll: 启动 WebSocket 服务器 测试用例 模拟客户端连接 发送测试消息 验证响应结果 afterAll: 关闭服务器
公众号:蓝海资料掘金营,微信deep3321