实时通信是最难测试的一类系统。它没有稳定的输入输出:媒体是概率性的、网络是时变的、结果依赖真实设备与浏览器实现。单元测试能覆盖信令消息的解析与状态机,却完全无法回答「两个人能不能通上话」这个最基本的问题。
更麻烦的是回归。一次看似无关的改动(升级 SDK、调整 SDP 顺序、修改 ICE 候选策略)可能只在特定浏览器与网络条件下才暴露问题,而这些问题在开发机上永远复现不出来,没有自动化测试就只能靠用户投诉发现。
本文给出可落地的方法:用无头浏览器加假媒体流做确定性的端到端用例,用 getStats 把「能不能通」变成可断言的数字,用压测工具模拟并发房间,用网络损伤模拟覆盖弱网。最后讨论基线管理、CI 分层与容量规划。文中涉及的指标定义与采集方式,与 实时音视频的质量监控
一脉相承。
目录
- 为什么实时通信的测试特别难
- 无头浏览器与假媒体流
- 确定性媒体:从假设备到固定文件
- 自动化端到端用例的设计
- 用 getStats 做断言
- 压测:并发房间与并发用户
- 网络损伤模拟
- 弱网回归与基线管理
- CI 中的实时测试流水线
- 从压测到容量规划
1. 为什么实时通信的测试特别难
难点可以归纳成四条:
- 非确定性:编解码、拥塞控制、抖动缓冲都带随机性,同一条用例跑十次可能得到十组不同的数字。
- 环境依赖:需要真实浏览器、真实编解码器、真实网络栈,纯 Node 环境无法替代。
- 时序敏感:很多 bug 只在竞态条件下出现(候选与 SDP 的到达顺序、重连与再协商的交叠)。
- 观测困难:问题发生在别人的浏览器里,日志拿不到,只能靠
getStats与埋点反推。
对策分别对应:用假媒体流消灭非确定性;用无头浏览器提供真实环境;用显式的状态等待替代 sleep 消除时序脆弱;用统一的指标采集把观测内建到测试里。
2. 无头浏览器与假媒体流
Chrome 提供了一组命令行开关,让无头浏览器在没有物理摄像头与麦克风的环境下产出确定性的媒体:
| 开关 | 作用 |
|---|---|
--use-fake-device-for-media-stream | 用内置的彩条与音调替代真实设备 |
--use-fake-ui-for-media-stream | 自动允许权限,跳过授权弹窗 |
--use-file-for-fake-video-capture=/path.y4m | 用 Y4M 文件作为视频源 |
--use-file-for-fake-audio-capture=/path.wav | 用 WAV 文件作为音频源 |
--allow-file-access-from-files | 允许读取本地媒体文件 |
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
headless: "new",
args: [
"--use-fake-device-for-media-stream",
"--use-fake-ui-for-media-stream",
"--use-file-for-fake-video-capture=/tmp/fixtures/color-bars.y4m",
"--use-file-for-fake-audio-capture=/tmp/fixtures/tone-1khz.wav",
"--autoplay-policy=no-user-gesture-required", "--no-sandbox",
],
});
const page = await browser.newPage();
await page.goto("https://localhost:8443/test-room.html", { waitUntil: "domcontentloaded" });
--use-fake-ui-for-media-stream 尤其关键:没有它,权限弹窗会永远挂在那里,用例超时。它只应在测试环境使用,绝不要出现在生产打包的启动参数里。
3. 确定性媒体:从假设备到固定文件
默认的假设备(彩条加音调)已经足够确定性,但有两个不足:画面变化太少,无法测出编码器在运动场景下的行为;音频是持续音调,无法测试静音检测与 DTX。
用固定文件可以精确控制内容:
ffmpeg -f lavfi -i testsrc2=size=1280x720:rate=30 -t 30 -pix_fmt yuv420p /tmp/fixtures/motion.y4m
ffmpeg -f lavfi -i "sine=frequency=1000:sample_rate=48000:duration=30" \
-af "volume='if(lt(mod(t,4),2),0.5,0)':eval=frame" \
-c:a pcm_s16le /tmp/fixtures/tone-gated.wav
Y4M 是未压缩的 YUV 序列,文件很大(720p30 每秒约 40 MB),30 秒素材就有 1.2 GB,实践中应把时长压到 10 到 20 秒或降低分辨率。需要长时间压测时,改用 --use-fake-device-for-media-stream 更合适。
3.1 用 Canvas 生成动态媒体
另一种更灵活的方式是在页面内用 canvas.captureStream() 生成媒体,完全不依赖命令行开关:用 setInterval 每帧在 canvas 上绘制一个位置由帧号决定的方块,再调用 captureStream(30) 得到视频轨道,需要音频时用 AudioContext 合成。这种方式的优势是内容完全由代码决定,因此可以在页面内断言「收到的画面确实是这一帧」,为端到端画质验证提供了可能。
4. 自动化端到端用例的设计
4.1 显式等待,不要 sleep
实时通信用例最常见的脆弱点是固定 sleep。正确做法是等待具体的状态变化:
async function waitForIceConnected(page, timeout = 15000) {
await page.waitForFunction(() => window.__pc?.iceConnectionState === "connected",
{ timeout, polling: 100 });
}
async function waitForFirstFrame(page, timeout = 10000) {
await page.waitForFunction(() => {
const v = document.querySelector("#remote");
return v && v.readyState >= 3 && v.videoWidth > 0;
}, { timeout, polling: 100 });
}
页面侧需要把 RTCPeerConnection 暴露到 window 上供测试读取,这是测试专用代码,应通过环境变量控制只在测试构建中启用。
4.2 双端编排
一次完整的通话测试需要两个页面实例,由测试代码统一编排:并行打开 caller 与 callee 两个页面并导航到同一房间,然后 Promise.all 等待两端 ICE 连通与首帧渲染,最后采集两端的 getStats 做对比。关键是所有等待都是「等待具体条件」而不是等待固定时长,这样 CI 机器变慢时用例只会变慢,不会随机失败。
4.3 必测的用例清单
| 用例 | 验证点 |
|---|---|
| 双人通话建立 | ICE 连通、首帧时间 |
| 一方挂断 | 另一方收到 peer-left 并清理连接 |
| 中途断网重连 | 3 秒内恢复,媒体继续 |
| 摄像头切换 | replaceTrack 后无黑屏、无再协商 |
| 屏幕共享启停 | 轨道增删后协商正确 |
| 三人房间 | 每人两路下行,无串流 |
| 长时间通话 | 30 分钟无内存泄漏、无累积延迟 |
5. 用 getStats 做断言
把「能不能通」变成可断言的数字,是自动化测试的关键一步。核心断言项:
async function assertHealthy(page, thresholds) {
const stats = await page.evaluate(async () => {
const report = await window.__pc.getStats();
const out = { inbound: null, outbound: null, pair: null };
report.forEach((r) => {
if (r.type === "inbound-rtp" && r.kind === "video") out.inbound = r;
if (r.type === "outbound-rtp" && r.kind === "video") out.outbound = r;
if (r.type === "candidate-pair" && r.state === "succeeded") out.pair = r;
});
return {
packetsReceived: out.inbound?.packetsReceived ?? 0,
packetsLost: out.inbound?.packetsLost ?? 0,
framesDecoded: out.inbound?.framesDecoded ?? 0,
freezeCount: out.inbound?.freezeCount ?? 0,
rtt: out.pair?.currentRoundTripTime ?? -1,
qualityLimitation: out.outbound?.qualityLimitationReason ?? "unknown",
};
});
const lossRate = stats.packetsReceived > 0
? stats.packetsLost / (stats.packetsReceived + stats.packetsLost) : 0;
expect(stats.framesDecoded).toBeGreaterThan(thresholds.minFrames);
expect(lossRate).toBeLessThan(thresholds.maxLossRate);
expect(stats.rtt).toBeGreaterThan(0);
expect(stats.rtt).toBeLessThan(thresholds.maxRtt);
return stats;
}
几个必须注意的陷阱:
- 统计量是累计值,必须比较两次采样之间的差值,而不是看绝对值。
packetsLost可能为负(乱序到达时先算丢包后又收到),计算丢包率时要钳到 0。framesDecoded在暂停时不变,断言前要先确认播放器处于播放状态。qualityLimitationReason是排查瓶颈的关键,cpu说明编码器过载,bandwidth说明网络受限。
6. 压测:并发房间与并发用户
端到端测试验证「功能对」,压测验证「规模下是否还对」。压测要模拟的是真实的房间拓扑,而不是单纯的连接数。
6.1 用无头浏览器做小规模压测
几十到几百个并发连接可以直接用无头浏览器集群,优点是复用真实协议栈,结果是可信的:
async function loadTest({ rooms, peersPerRoom, browser }) {
const handles = [];
for (let r = 0; r < rooms; r++) {
const roomId = `load-${r}`;
for (let p = 0; p < peersPerRoom; p++) {
const page = await browser.newPage();
await page.goto(`${BASE}/room.html?room=${roomId}&role=peer${p}`);
handles.push({ page, roomId, peer: p });
}
}
await new Promise((res) => setTimeout(res, 60000)); // 等全部进入稳定态
const samples = await Promise.all(handles.map((h) => collectStats(h.page)));
return { handles, samples };
}
无头浏览器的瓶颈在内存与 CPU:单机通常只能跑 50 到 200 个页面实例,因此这种方式适合「小规模但高保真」的验证,例如验证 20 人房间的转发正确性。
6.2 用协议库做大规模压测
要压到上千并发,必须换成轻量的协议实现,例如 Go 的 Pion 或 Node 的 werift。它们能在一个进程里跑数千个 RTCPeerConnection:
// 用 Pion 模拟大量只订阅的观众(省略信令细节)
func spawnViewer(sfuURL, roomID string, wg *sync.WaitGroup) {
defer wg.Done()
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.example.com:3478"}}},
})
if err != nil {
log.Println("create pc failed:", err)
return
}
defer pc.Close()
// 只接收,不发送,模拟直播观众
_, err = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo,
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly})
if err != nil {
log.Println("add transceiver failed:", err)
return
}
offer, _ := pc.CreateOffer(nil)
_ = pc.SetLocalDescription(offer)
}
压测要覆盖的维度:
| 维度 | 取值示例 | 观察指标 |
|---|---|---|
| 房间数 | 10 / 100 / 500 | 信令 QPS、房间创建耗时 |
| 每房间人数 | 2 / 6 / 20 | 转发延迟、下行带宽 |
| 观众数 | 1k / 5k / 10k | SFU 出口带宽、丢包 |
| 持续时长 | 5 / 30 / 120 分钟 | 内存增长、句柄泄漏 |
7. 网络损伤模拟
功能正常不代表弱网正常。网络损伤模拟必须在测试中成为常规项,而不是上线前的临时检查。
7.1 用 tc netem 模拟
Linux 上的 tc 加 netem 是最贴近真实的方案:
sudo tc qdisc add dev eth0 root netem loss 5% delay 20ms 10ms distribution normal rate 1mbit
tc qdisc show dev eth0 # 查看当前规则
sudo tc qdisc del dev eth0 root # 清理
需要注意 netem 作用于整块网卡,会影响机器上的所有流量。生产化的做法是在容器网络命名空间里注入,或用 iptables 加 tc 组合按目标 IP 过滤。
7.2 用代理与 CDP 模拟
在浏览器侧,可以用支持限速的代理(如 toxiproxy、Charles)拦截流量,或用 Chromium 的 Network.emulateNetworkConditions CDP 接口注入延迟与限速。要注意的是这个接口只影响 HTTP 与 WebSocket,对 WebRTC 的 UDP 媒体流无效。要模拟媒体侧的弱网,仍必须用 tc 或在 SFU 侧注入丢包。
7.3 必须覆盖的弱网档位
| 档位 | 丢包 | RTT | 带宽 | 场景 |
|---|---|---|---|---|
| 良好 | 0% | 20 ms | 10 Mbps | 基线 |
| 一般 | 1% | 80 ms | 2 Mbps | 家庭宽带 |
| 较差 | 5% | 150 ms | 800 kbps | 地铁 4G |
| 极差 | 10% | 300 ms | 300 kbps | 弱信号 |
每个档位都要验证:连接能否建立、能否维持、指标是否在阈值内、恢复后能否回到基线。弱网下的具体自适应机制见 弱网对抗与自适应码率 。
8. 弱网回归与基线管理
压测与弱网测试的价值在于「对比」,而不是「看绝对值」。因此必须建立基线并做趋势管理。
baseline:
version: "2.14.0"
two_party_call: { ttff_ms: 780, mos: 4.2, loss_rate: 0.004, freeze_rate: 0.002 }
room_6_peers: { ttff_ms: 1200, loss_rate: 0.011, cpu_percent: 42 }
weak_network_5pct_loss: { mos: 3.6, freeze_rate: 0.031 }
回归判定的规则应该是「超出基线的容差范围才失败」,而不是「低于某个绝对阈值」:
function checkRegression(current, baseline, tolerance) {
const failures = [];
for (const [key, base] of Object.entries(baseline)) {
const cur = current[key];
if (cur === undefined) continue;
const delta = (cur - base) / base;
if (delta > tolerance[key]) failures.push({ metric: key, base, cur, delta: `${(delta * 100).toFixed(1)}%` });
}
return failures;
}
容差要按指标的性质设置:首帧时间允许 15% 的波动,丢包率允许 50%(因为它本身抖动大),MOS 只允许 5%(它是用户感知的直接映射)。
9. CI 中的实时测试流水线
实时测试跑得慢、依赖重,不适合放在每次提交的快速流水线里。推荐分层:
| 层级 | 触发时机 | 内容 | 时长目标 |
|---|---|---|---|
| L1 单元测试 | 每次提交 | 信令消息解析、状态机 | < 1 分钟 |
| L2 端到端冒烟 | 每次合并 | 双人通话、断线重连 | < 5 分钟 |
| L3 弱网回归 | 每日 | 四档弱网 × 核心用例 | < 30 分钟 |
| L4 压测 | 每周 / 发版前 | 并发房间与容量 | < 2 小时 |
CI 环境的两个必备配置:一是把 --no-sandbox 与合适的 shm_size 加进容器(默认 64 MB 的 /dev/shm 会让 Chrome 崩溃);二是把媒体素材挂载为只读卷,避免每次重新生成。
services:
chrome-test:
image: browserless/chrome:latest
shm_size: 1gb
environment:
CHROME_FLAGS: "--use-fake-device-for-media-stream --use-fake-ui-for-media-stream --no-sandbox"
volumes: ["./fixtures:/tmp/fixtures:ro"]
用例失败时必须自动产出诊断材料:截图、getStats 快照、浏览器控制台日志、ICE 候选列表。缺少这些,失败用例的排查成本会高到没人愿意看。
10. 从压测到容量规划
压测的最终产出应该是一张容量表,而不是一堆曲线图:
单台 SFU(8 核 / 1 Gbps 出口):
2 人房间 :约 400 间(800 路下行)
6 人房间 :约 90 间(约 540 路下行,下行 810 Mbps)
20 人房间 :约 12 间(约 240 路下行,下行 720 Mbps)
直播观众 :约 330 路(1.5 Mbps 单路,50% 余量)
三条经验:下行带宽通常先于 CPU 成为瓶颈;房间越大,人均资源消耗越高(下行是 N-1 路);必须按「房间规模分布」而不是平均人数来规划。SFU 侧的容量估算公式见 SFU 与 MCU 架构选型 。压测还应回答「故障时的降级行为」:单个 SFU 挂掉后重连流量涌向剩余节点,能否维持服务?这类验证的组织方式与 混沌工程与故障注入 一致——先定义稳态,再注入故障,观察是否跌破稳态。
权衡取舍
| 取舍点 | 无头浏览器 | 协议库(Pion / werift) |
|---|---|---|
| 保真度 | 最高(真实浏览器) | 中(协议正确但非浏览器实现) |
| 单机并发 | 50~200 | 1000~5000 |
| 资源开销 | 高(每实例数百 MB) | 低(每连接数十 KB) |
| 适合 | 功能验证、小规模 | 容量压测、长时间稳定性 |
务实的组合是:功能与兼容性用无头浏览器,容量与稳定性用协议库,两者共用同一套指标采集口径以保证数据可比。
常见坑清单
- 用固定
sleep等待连接建立,CI 机器一慢就大面积失败。 - 断言
getStats的绝对值而非两次采样的差值,把累计量当瞬时量。 - 忘记
packetsLost可能为负,算出的丢包率小于 0。 - 只在良好网络下跑用例,且用 CDP 限速代替媒体弱网,实际对 UDP 无效。
- 测试环境外启用
--use-fake-ui-for-media-stream,权限校验被彻底绕过。 - 压测只加连接数不建房间,测出的容量远高于真实拓扑下的容量。
- 容器
/dev/shm保持默认 64 MB,且用例失败不产出截图与 getStats 快照,排查成本高到无人跟进。
小结
实时通信的测试要解决的核心矛盾是「环境真实」与「结果确定」之间的冲突。无头浏览器加假媒体流是当前最实用的折中:它保留了真实的协议栈与编解码器,同时把媒体内容变成可复现的常量。在此之上,用 getStats 把主观的「通不通」翻译成可断言的数字,测试才真正具备回归能力。
压测的重点不在并发数字本身,而在房间拓扑的真实性。只有按「房间数 × 每房间人数 × 持续时长」三个维度同时施压,得到的容量结论才可用于生产规划。弱网必须成为常规档位而不是临时检查,四档丢包与延迟的组合基本能覆盖移动端的真实分布。最后一条经验是把每次发版的指标快照存下来:实时通信的劣化往往是渐进的,单次对比看不出问题,把三个版本的数据排在一起趋势就会非常明显。只有把测试与线上监控共用同一套指标口径,回归结论才能与真实体验对得上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。