第二十三章:单元测试——用 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 函数。这样测试路由时,不会真的执行房间加入逻辑,只验证参数传对了没。
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 延迟一下断言。
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 服务器,核心就三点:
- 模拟连接:用
ws库创建客户端,连接测试服务器 - Mock 依赖:用
jest.mock隔离路由和业务逻辑 - 控制异步:用
done回调或 async/await 处理事件
测试不是一次性工作。每加一个新功能,先写测试再写代码。你会发现,代码质量上去了,改代码的胆子也大了。
核心要点:
- 用
beforeAll/afterAll管理服务器生命周期 - 用
jest.mock隔离路由和业务逻辑 - 用
done回调处理异步事件 - 并发测试能发现竞态条件
- 分支覆盖率至少 80%