WebRTC Testing with Playwright: Fake Media Streams and Peer Connection Tests
WebRTC is notoriously hard to test. Real cameras and microphones aren't available in CI, peer connections involve NAT traversal, and the media pipeline has timing dependencies that make flaky tests common. This guide shows how to test WebRTC applications reliably using Playwright's built-in fake media capabilities.
Playwright's Fake Media Support
Playwright supports fake media streams out of the box — no real camera required. Configure it at the browser context level:
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
use: {
launchOptions: {
args: [
'--use-fake-ui-for-media-stream', // auto-grant camera/mic permissions
'--use-fake-device-for-media-stream', // inject fake video/audio stream
],
},
permissions: ['camera', 'microphone'],
},
})With --use-fake-device-for-media-stream, Chrome injects a synthetic video test pattern and audio tone instead of using real hardware. Your WebRTC code behaves identically — getUserMedia() returns a real MediaStream.
Testing Camera/Microphone Access
// tests/e2e/media-access.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Media stream access', () => {
test('video stream is active after camera permission is granted', async ({ page }) => {
await page.goto('/video-call')
// The UI should show a local video preview
await page.click('[data-testid=start-camera]')
const videoEl = page.locator('[data-testid=local-video]')
await expect(videoEl).toBeVisible()
// Verify the video element has an active srcObject
const hasActiveStream = await page.evaluate(() => {
const video = document.querySelector('[data-testid=local-video]') as HTMLVideoElement
return video?.srcObject instanceof MediaStream &&
(video.srcObject as MediaStream).active
})
expect(hasActiveStream).toBe(true)
})
test('video track is enabled by default', async ({ page }) => {
await page.goto('/video-call')
await page.click('[data-testid=start-camera]')
const videoTrackEnabled = await page.evaluate(() => {
const video = document.querySelector('[data-testid=local-video]') as HTMLVideoElement
const stream = video?.srcObject as MediaStream
return stream?.getVideoTracks()[0]?.enabled
})
expect(videoTrackEnabled).toBe(true)
})
test('mute button disables the audio track', async ({ page }) => {
await page.goto('/video-call')
await page.click('[data-testid=start-camera]')
await page.click('[data-testid=mute-audio]')
const audioTrackEnabled = await page.evaluate(() => {
const video = document.querySelector('[data-testid=local-video]') as HTMLVideoElement
const stream = video?.srcObject as MediaStream
return stream?.getAudioTracks()[0]?.enabled
})
expect(audioTrackEnabled).toBe(false)
await expect(page.locator('[data-testid=mute-indicator]')).toBeVisible()
})
})Testing Peer Connection Establishment
Test that two peers can establish a connection using two browser contexts:
// tests/e2e/peer-connection.spec.ts
import { test, expect, chromium } from '@playwright/test'
test('two peers can establish a video call connection', async () => {
const browser = await chromium.launch({
args: [
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
],
})
// Peer A creates a room
const ctxA = await browser.newContext({ permissions: ['camera', 'microphone'] })
const pageA = await ctxA.newPage()
await pageA.goto('https://app.example.com/rooms/new')
await pageA.click('[data-testid=create-room]')
await pageA.waitForURL(/\/rooms\/[\w-]+/)
const roomUrl = pageA.url()
// Peer B joins the room
const ctxB = await browser.newContext({ permissions: ['camera', 'microphone'] })
const pageB = await ctxB.newPage()
await pageB.goto(roomUrl)
await pageB.click('[data-testid=join-room]')
// Both peers should show a remote video stream
await expect(pageA.locator('[data-testid=remote-video]')).toBeVisible({ timeout: 15000 })
await expect(pageB.locator('[data-testid=remote-video]')).toBeVisible({ timeout: 15000 })
// Verify remote video has an active stream
const peerARemoteActive = await pageA.evaluate(() => {
const remoteVideo = document.querySelector('[data-testid=remote-video]') as HTMLVideoElement
return (remoteVideo?.srcObject as MediaStream)?.active
})
expect(peerARemoteActive).toBe(true)
await browser.close()
})Unit Testing: ICE Candidate Handling
Test your signaling logic without a real peer connection:
// services/peerConnectionService.ts
export class PeerConnectionService {
private pc: RTCPeerConnection
constructor(config: RTCConfiguration) {
this.pc = new RTCPeerConnection(config)
}
async createOffer(): Promise<RTCSessionDescriptionInit> {
const offer = await this.pc.createOffer()
await this.pc.setLocalDescription(offer)
return offer
}
async handleAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
await this.pc.setRemoteDescription(new RTCSessionDescription(answer))
}
onIceCandidate(callback: (candidate: RTCIceCandidate) => void): void {
this.pc.onicecandidate = (event) => {
if (event.candidate) callback(event.candidate)
}
}
}// services/peerConnectionService.test.ts
import { PeerConnectionService } from './peerConnectionService'
// Mock RTCPeerConnection globally (not available in Node.js)
const mockPc = {
createOffer: jest.fn(),
setLocalDescription: jest.fn(),
setRemoteDescription: jest.fn(),
onicecandidate: null as any,
}
global.RTCPeerConnection = jest.fn().mockImplementation(() => mockPc) as any
global.RTCSessionDescription = jest.fn().mockImplementation((init) => init) as any
describe('PeerConnectionService', () => {
beforeEach(() => jest.clearAllMocks())
it('creates an offer and sets it as local description', async () => {
const offer = { type: 'offer', sdp: 'v=0...' }
mockPc.createOffer.mockResolvedValue(offer)
mockPc.setLocalDescription.mockResolvedValue(undefined)
const service = new PeerConnectionService({ iceServers: [] })
const result = await service.createOffer()
expect(mockPc.setLocalDescription).toHaveBeenCalledWith(offer)
expect(result).toEqual(offer)
})
it('sets remote description when answer is received', async () => {
mockPc.setRemoteDescription.mockResolvedValue(undefined)
const service = new PeerConnectionService({ iceServers: [] })
const answer = { type: 'answer', sdp: 'v=0...' }
await service.handleAnswer(answer)
expect(mockPc.setRemoteDescription).toHaveBeenCalledWith(answer)
})
it('calls callback when ICE candidate is generated', () => {
const service = new PeerConnectionService({ iceServers: [] })
const callback = jest.fn()
service.onIceCandidate(callback)
const candidate = { candidate: 'candidate:123...', sdpMid: '0', sdpMLineIndex: 0 }
mockPc.onicecandidate({ candidate } as RTCPeerConnectionIceEvent)
expect(callback).toHaveBeenCalledWith(candidate)
})
it('does not call callback for null (end-of-candidates) event', () => {
const service = new PeerConnectionService({ iceServers: [] })
const callback = jest.fn()
service.onIceCandidate(callback)
mockPc.onicecandidate({ candidate: null } as RTCPeerConnectionIceEvent)
expect(callback).not.toHaveBeenCalled()
})
})Testing Screen Share
test('screen share replaces video track in the peer connection', async ({ page }) => {
// Note: screen share requires manual permissions in most browsers
// Use mock in Playwright
await page.addInitScript(() => {
navigator.mediaDevices.getDisplayMedia = () =>
navigator.mediaDevices.getUserMedia({ video: true })
})
await page.goto('/video-call')
await page.click('[data-testid=start-camera]')
await page.click('[data-testid=share-screen]')
await expect(page.locator('[data-testid=screen-share-active]')).toBeVisible()
// Verify the outgoing video track was replaced
const isScreenShare = await page.evaluate(() => {
const video = document.querySelector('[data-testid=local-video]') as HTMLVideoElement
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0]
return track?.label?.includes('screen') || track?.getSettings().displaySurface !== undefined
})
// At minimum, the UI should reflect screen share state
await expect(page.locator('[data-testid=stop-screen-share]')).toBeVisible()
})Testing Connection Quality Indicators
test('shows connection quality indicator based on stats', async ({ page }) => {
await page.goto('/video-call')
await page.click('[data-testid=join-room]')
// Connection quality updates are driven by RTCPeerConnection.getStats()
// Inject mock stats for testing
await page.evaluate(() => {
const originalGetStats = RTCPeerConnection.prototype.getStats
RTCPeerConnection.prototype.getStats = async () => {
const stats = new Map()
stats.set('transport', {
type: 'transport',
roundTripTime: 0.05, // 50ms RTT = good quality
availableOutgoingBitrate: 2000000,
})
return stats as any
}
})
// Trigger a stats refresh
await page.waitForTimeout(2000)
await expect(page.locator('[data-testid=connection-quality-good]')).toBeVisible()
})What Automated Tests Miss
Playwright tests with fake media cover the code paths but won't catch:
- Real codec negotiation failures between different browser/OS combinations
- Network path quality — packet loss, jitter, bandwidth constraints
- Hardware encoder issues on specific GPU models
- Mobile Safari WebRTC quirks — behavior differs significantly from Chrome
HelpMeTest runs scheduled WebRTC smoke tests using real browsers on real infrastructure, catching the regressions that synthetic media streams miss. The Pro plan at $100/month gives you parallel execution across multiple browser configurations.
Summary
WebRTC testing with Playwright:
--use-fake-device-for-media-stream— inject synthetic media without real hardware--use-fake-ui-for-media-stream— auto-grant camera/mic permissions in tests- Two browser contexts — simulate real peer connections in a single test
page.evaluate()— inspectMediaStreamstate directly from the test- Mock
RTCPeerConnectionin Node.js unit tests — test signaling logic without a browser