WebRTC Testing Guide: Unit Tests, Integration with Playwright, and Media Quality
WebRTC applications are notoriously hard to test because they involve browser APIs, network negotiation, and real-time media. This guide covers mocking RTCPeerConnection in Jest, intercepting WebRTC in Playwright, measuring media quality with getStats(), and simulating adverse network conditions.
WebRTC powers video calls, screen sharing, and peer-to-peer data channels — but its dependency on browser APIs, STUN/TURN servers, and real network conditions makes it one of the more challenging stacks to test reliably. This guide walks through a layered testing strategy: fast unit tests with mocks, integration tests with Playwright, and media quality measurement using the WebRTC Statistics API.
Mocking RTCPeerConnection in Jest/Vitest
The first layer is pure unit tests that never touch a real browser. The key is replacing the global RTCPeerConnection and getUserMedia with controllable fakes.
// __mocks__/rtc.ts
export class MockRTCPeerConnection {
localDescription: RTCSessionDescriptionInit | null = null;
remoteDescription: RTCSessionDescriptionInit | null = null;
iceConnectionState: RTCIceConnectionState = 'new';
signalingState: RTCSignalingState = 'stable';
private listeners: Record<string, Function[]> = {};
addEventListener(event: string, cb: Function) {
this.listeners[event] = this.listeners[event] || [];
this.listeners[event].push(cb);
}
emit(event: string, data?: unknown) {
(this.listeners[event] || []).forEach(cb => cb(data));
}
async createOffer(): Promise<RTCSessionDescriptionInit> {
return { type: 'offer', sdp: 'mock-sdp-offer' };
}
async createAnswer(): Promise<RTCSessionDescriptionInit> {
return { type: 'answer', sdp: 'mock-sdp-answer' };
}
async setLocalDescription(desc: RTCSessionDescriptionInit) {
this.localDescription = desc;
}
async setRemoteDescription(desc: RTCSessionDescriptionInit) {
this.remoteDescription = desc;
}
addIceCandidate = jest.fn().mockResolvedValue(undefined);
addTrack = jest.fn();
close = jest.fn();
async getStats(): Promise<RTCStatsReport> {
const stats = new Map([
['inbound-rtp-video', {
type: 'inbound-rtp',
kind: 'video',
packetsReceived: 1000,
packetsLost: 5,
jitter: 0.012,
bytesReceived: 500000,
}],
]);
return stats as unknown as RTCStatsReport;
}
}Now wire it into your test setup:
// jest.setup.ts
import { MockRTCPeerConnection } from './__mocks__/rtc';
global.RTCPeerConnection = MockRTCPeerConnection as unknown as typeof RTCPeerConnection;
global.navigator.mediaDevices = {
getUserMedia: jest.fn().mockResolvedValue({
getTracks: () => [{ kind: 'video', stop: jest.fn() }],
getVideoTracks: () => [{ kind: 'video' }],
getAudioTracks: () => [{ kind: 'audio' }],
} as unknown as MediaStream),
enumerateDevices: jest.fn().mockResolvedValue([]),
} as unknown as MediaDevices;With these mocks in place, you can unit-test your signaling logic without any browser:
// signalingService.test.ts
import { SignalingService } from '../src/signalingService';
test('creates offer and sets local description', async () => {
const pc = new MockRTCPeerConnection();
const service = new SignalingService(pc as unknown as RTCPeerConnection);
const offer = await service.createAndSendOffer();
expect(offer.type).toBe('offer');
expect(pc.localDescription?.sdp).toBe('mock-sdp-offer');
});
test('handles ICE candidates', async () => {
const pc = new MockRTCPeerConnection();
const service = new SignalingService(pc as unknown as RTCPeerConnection);
const candidate = { candidate: 'candidate:1 1 UDP 2130706431 192.168.1.1 54321 typ host' };
await service.addRemoteCandidate(candidate as RTCIceCandidateInit);
expect(pc.addIceCandidate).toHaveBeenCalledWith(candidate);
});Testing SDP Exchange and ICE Negotiation
SDP (Session Description Protocol) parsing and ICE candidate handling are common sources of bugs. Test the full negotiation flow with two mock peers:
test('full offer/answer negotiation completes', async () => {
const callerPc = new MockRTCPeerConnection();
const calleePc = new MockRTCPeerConnection();
// Caller creates offer
const offer = await callerPc.createOffer();
await callerPc.setLocalDescription(offer);
// Callee receives offer
await calleePc.setRemoteDescription(offer);
const answer = await calleePc.createAnswer();
await calleePc.setLocalDescription(answer);
// Caller receives answer
await callerPc.setRemoteDescription(answer);
expect(callerPc.remoteDescription?.type).toBe('answer');
expect(calleePc.remoteDescription?.type).toBe('offer');
});For SDP validation — checking that specific codecs, bandwidth limits, or DTLS fingerprints are present — parse the raw SDP string:
function parseSdpCodecs(sdp: string): string[] {
const codecPattern = /a=rtpmap:(\d+) ([^/]+)/g;
const codecs: string[] = [];
let match;
while ((match = codecPattern.exec(sdp)) !== null) {
codecs.push(match[2]);
}
return codecs;
}
test('SDP includes VP8 and opus codecs', () => {
const sdp = getRealOrMockSdp();
const codecs = parseSdpCodecs(sdp);
expect(codecs).toContain('VP8');
expect(codecs).toContain('opus');
});Playwright WebRTC Integration Tests
For end-to-end tests, Playwright can intercept WebRTC via its CDP (Chrome DevTools Protocol) integration. This lets you verify your signaling server actually connects peers.
// webrtc.spec.ts
import { test, expect, chromium } from '@playwright/test';
test('two peers establish a data channel', async () => {
const browser = await chromium.launch({
args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'],
});
const callerCtx = await browser.newContext({ permissions: ['camera', 'microphone'] });
const calleeCtx = await browser.newContext({ permissions: ['camera', 'microphone'] });
const callerPage = await callerCtx.newPage();
const calleePage = await calleeCtx.newPage();
await callerPage.goto('http://localhost:3000/call?room=test-room&role=caller');
await calleePage.goto('http://localhost:3000/call?room=test-room&role=callee');
// Wait for both peers to signal "connected"
await expect(callerPage.locator('[data-testid="connection-status"]')).toHaveText('connected', { timeout: 15000 });
await expect(calleePage.locator('[data-testid="connection-status"]')).toHaveText('connected', { timeout: 15000 });
// Verify data channel message exchange
await callerPage.click('[data-testid="send-test-message"]');
await expect(calleePage.locator('[data-testid="received-message"]')).toHaveText('ping', { timeout: 5000 });
await browser.close();
});To intercept and inspect WebRTC internals via CDP:
test('captures ICE connection events', async ({ page }) => {
const client = await page.context().newCDPSession(page);
await client.send('WebAudio.enable');
const iceEvents: string[] = [];
page.on('console', msg => {
if (msg.text().includes('iceConnectionState')) {
iceEvents.push(msg.text());
}
});
await page.goto('http://localhost:3000');
await page.waitForTimeout(5000);
expect(iceEvents.some(e => e.includes('connected'))).toBe(true);
});Measuring Media Quality with getStats()
RTCPeerConnection.getStats() returns a wealth of metrics. Parse them to compute Mean Opinion Score (MOS) approximations and detect quality degradation:
interface MediaQualityReport {
packetLossPercent: number;
jitterMs: number;
roundTripTimeMs: number;
mosScore: number;
}
async function getMediaQuality(pc: RTCPeerConnection): Promise<MediaQualityReport> {
const stats = await pc.getStats();
let report: Partial<MediaQualityReport> = {};
stats.forEach(stat => {
if (stat.type === 'inbound-rtp' && stat.kind === 'audio') {
const total = stat.packetsReceived + stat.packetsLost;
report.packetLossPercent = total > 0 ? (stat.packetsLost / total) * 100 : 0;
report.jitterMs = stat.jitter * 1000;
}
if (stat.type === 'remote-candidate') {
report.roundTripTimeMs = (stat.currentRoundTripTime || 0) * 1000;
}
});
// Simplified E-model MOS approximation
const R = 93.2
- (report.packetLossPercent || 0) * 2.5
- (report.jitterMs || 0) * 0.1
- (report.roundTripTimeMs || 0) * 0.024;
report.mosScore = 1 + 0.035 * R + R * (R - 60) * (100 - R) * 7e-6;
return report as MediaQualityReport;
}
test('media quality meets MOS threshold', async ({ page }) => {
// ... establish connection ...
const quality = await page.evaluate(async () => {
const pc = window.__testPeerConnection;
return getMediaQuality(pc);
});
expect(quality.packetLossPercent).toBeLessThan(2);
expect(quality.jitterMs).toBeLessThan(30);
expect(quality.mosScore).toBeGreaterThan(3.5);
});Network Condition Simulation
Chrome's network throttling APIs let you simulate bad network conditions to test graceful degradation:
test('handles packet loss gracefully', async ({ page }) => {
const client = await page.context().newCDPSession(page);
// Simulate 10% packet loss, 100ms latency
await client.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: 1_000_000, // 1 Mbps
uploadThroughput: 500_000, // 0.5 Mbps
latency: 100,
});
// For WebRTC-specific throttling, use webrtcTestingOverrides
await client.send('Page.setWebRTCVirtualTimePolicy', { policy: 'advance' });
await page.goto('http://localhost:3000');
// ... conduct call ...
const statusEl = page.locator('[data-testid="quality-indicator"]');
await expect(statusEl).toHaveAttribute('data-quality', /.*(good|fair).*/);
});TURN/STUN Server Testing
Your TURN server is critical for users behind symmetric NATs. Test it separately with a dedicated connectivity check:
async function testTurnConnectivity(turnConfig: RTCIceServer): Promise<boolean> {
return new Promise(resolve => {
const pc = new RTCPeerConnection({ iceServers: [turnConfig] });
let foundRelay = false;
pc.onicecandidate = ({ candidate }) => {
if (candidate?.type === 'relay') {
foundRelay = true;
pc.close();
resolve(true);
}
};
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete' && !foundRelay) {
resolve(false);
}
};
pc.createDataChannel('test');
pc.createOffer().then(offer => pc.setLocalDescription(offer));
setTimeout(() => resolve(false), 10000);
});
}
test('TURN server provides relay candidates', async () => {
const reachable = await testTurnConnectivity({
urls: 'turn:your-turn-server.example.com:3478',
username: 'testuser',
credential: 'testpass',
});
expect(reachable).toBe(true);
}, 15000);Loopback Testing Pattern
For smoke testing media pipelines without a second browser, use loopback: connect a peer to itself through a local signaling server:
async function createLoopbackConnection(): Promise<[RTCPeerConnection, RTCPeerConnection]> {
const pc1 = new RTCPeerConnection({ iceServers: [] });
const pc2 = new RTCPeerConnection({ iceServers: [] });
pc1.onicecandidate = e => e.candidate && pc2.addIceCandidate(e.candidate);
pc2.onicecandidate = e => e.candidate && pc1.addIceCandidate(e.candidate);
const offer = await pc1.createOffer();
await pc1.setLocalDescription(offer);
await pc2.setRemoteDescription(offer);
const answer = await pc2.createAnswer();
await pc2.setLocalDescription(answer);
await pc1.setRemoteDescription(answer);
return [pc1, pc2];
}
test('loopback data channel delivers messages', async () => {
const [pc1, pc2] = await createLoopbackConnection();
const channel1 = pc1.createDataChannel('test');
const received: string[] = [];
pc2.ondatachannel = ({ channel }) => {
channel.onmessage = e => received.push(e.data);
};
await new Promise<void>(resolve => {
channel1.onopen = () => {
channel1.send('hello');
setTimeout(resolve, 500);
};
});
expect(received).toContain('hello');
pc1.close();
pc2.close();
});Putting It All Together
A mature WebRTC test suite combines all of these layers: fast Jest unit tests covering signaling logic (running in milliseconds), Playwright integration tests verifying real peer connections with fake media devices, getStats() assertions validating quality thresholds, and periodic TURN/STUN health checks as part of your monitoring. Run unit tests on every commit, integration tests on every PR, and the network simulation tests as part of a nightly quality gate.
Tools like HelpMeTest can automate these browser-based WebRTC scenarios continuously, alerting your team when media quality degrades or peer connection success rates drop in production environments.