WebRTC Testing with Playwright: Fake Media Streams and Peer Connection Tests

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() — inspect MediaStream state directly from the test
  • Mock RTCPeerConnection in Node.js unit tests — test signaling logic without a browser

Read more