Skip to content

Test WebRTC calls

Use case: your app has real-time calls — video chat, a support hotline widget, pair-programming voice. You want a test where user A joins, speaks, and user B actually receives the media.

A call needs two browsers, media devices on both ends, and assertions about things that never touch the DOM: is a connection established? is audio flowing? Playwright can orchestrate multiple pages, but gives you nothing for the media itself.

Each browser.newContext() is an isolated user (own storage, own permissions) — perfect for caller/callee:

import { test, expect } from '@playwright/test';
import { mockMicrophone } from 'playwright-audio-mocking';
test('callee hears the caller', async ({ browser }) => {
const caller = await (await browser.newContext()).newPage();
const callee = await (await browser.newContext()).newPage();
// Give the caller a voice before navigation.
const mic = await mockMicrophone(caller);
const room = `test-${Date.now()}`;
await caller.goto(`/call/${room}`);
await callee.goto(`/call/${room}`);
await caller.getByRole('button', { name: 'Join' }).click();
await callee.getByRole('button', { name: 'Join' }).click();
// UI-level signal that the call connected.
await expect(callee.getByTestId('remote-participant')).toBeVisible();
// The caller speaks…
await mic.play('tests/fixtures/hello.wav');
// …and the callee's <audio> element carries actual energy (see below).
await expect
.poll(() => remoteAudioLevel(callee), { timeout: 10_000 })
.toBeGreaterThan(0.01);
});

Don’t trust a “connected” badge — measure the remote stream. Most apps attach it to an <audio>/<video> element, which you can tap without touching app code:

function remoteAudioLevel(page: import('@playwright/test').Page) {
return page.evaluate(async () => {
const el = document.querySelector('audio, video') as HTMLMediaElement;
const stream = (el.srcObject as MediaStream) ?? (el as any).captureStream();
const ctx = new AudioContext();
const analyser = ctx.createAnalyser();
ctx.createMediaStreamSource(stream).connect(analyser);
const data = new Float32Array(analyser.fftSize);
await new Promise((r) => setTimeout(r, 200)); // let some frames arrive
analyser.getFloatTimeDomainData(data);
return Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length); // RMS
});
}

For connection-level assertions, getStats() is the source of truth — packetsReceived must grow, audioLevel must be non-zero. This requires a handle on the RTCPeerConnection; if your app doesn’t expose one, capture it with an init script:

await callee.addInitScript(() => {
const OrigPC = window.RTCPeerConnection;
(window as any).__pcs = [];
window.RTCPeerConnection = class extends OrigPC {
constructor(...args: any[]) {
super(...args);
(window as any).__pcs.push(this);
}
};
});
  • Run both fake devices: mocked mic for the content you control, --use-fake-device-for-media-stream as a fallback camera (see Mock camera input).
  • Two pages in one browser process can be slow in CI; if timings matter, use two workers with a small signaling fixture instead.
  • TURN/STUN behavior differs between localhost and production networks — a passing localhost call doesn’t prove NAT traversal works.