극작가 + 크롬: WebRTC 화상 통화 E2E용 수동 테스터 2개를 대체하는 스크립트 1개

작성자

카테고리:

← 피드로
DEV Community · Ken Imoto · 2026-07-24 개발(SW)

If you’ve ever watched a QA engineer press “call” on one laptop, walk across the room, and press “answer” on a phone before the ringing stops, you already know the setup. Two-browser sync is the single hardest part of WebRTC video call testing.

The whole flow looks simple on a whiteboard. Alice presses call, Bob’s screen lights up with an incoming banner, Bob answers, video panes appear on both sides. All of it has to be automated without breaking the timing. One script, two contexts, and enough patience to let ICE finish. That’s the shape of it.

I wrote this after replacing a two-person manual regression pass for a video call app. The team ran that check every merge to main, and every time it involved two people, two devices, and a Slack thread that started with “wait, ready?” Now one Playwright script does it in about 40 seconds on CI, and it fails loudly at the merge when signaling regresses.

Why two browsers and not one page

The instinct on a first pass is to open one page and simulate both sides. That works for signaling unit tests. It fails for anything that involves peer connection state, because the local peer and the remote peer end up sharing the same JS runtime and the same fake camera, and every failure mode that matters in production stays invisible: race between offer and answer, ICE candidate ordering, DTLS handshake stall.

Two Browser Contexts inside a single Chromium process fix that. Each context has its own cookies, localStorage, permission grants, and (crucially) its own RTCPeerConnection instance living in its own renderer. When the caller’s context sends an SDP offer, it goes out through the signaling server the same way it would from a second laptop, and the receiver context reads it back through a real network round trip.

The same set of Playwright fixtures also catches signaling bugs, presence-server bugs, and TURN misconfiguration. The exact bugs manual testers used to catch by accident.

The launch flags

Every Chromium in this test has to lie about its hardware.

import { test, chromium } from '@playwright/test';

test('one-on-one video call connects', 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 receiverCtx = await browser.newContext({
    permissions: ['camera', 'microphone'],
  });

  const callerPage = await callerCtx.newPage();
  const receiverPage = await receiverCtx.newPage();

  await callerPage.goto('http://localhost:3000');
  await receiverPage.goto('http://localhost:3000');

  // ...
  await browser.close();
});

Enter fullscreen mode Exit fullscreen mode

--use-fake-ui-for-media-stream auto-accepts the permission prompt so the test doesn’t hang staring at a browser dialog. --use-fake-device-for-media-stream replaces getUserMedia with a synthetic camera. The default is Chromium’s green ball testcard, which is fine for signaling checks but useless if you want to verify pixels. For pixel checks, pass --use-file-for-fake-video-capture=./sample.y4m and drop in a Y4M file. Chrome only reads Y4M here; MP4 is not supported for fake capture, which surprised me the first time.

A gotcha worth calling out: those flags must be passed at browser launch time. If you set them in playwright.config.ts under use.launchOptions.args but then call chromium.launch({}) directly inside a test, they’re silently ignored on the second launcher. Same rule applies to any custom launcher.

The call sequence, step by step

Two-browser tests read like a stage direction script. Every action has an implicit “wait until the other side sees it” between the lines.

test('caller initiates, receiver answers, both see video', async () => {
  // Precondition: browser, callerPage, receiverPage already set up.

  // Step 1: both join the same room
  await callerPage.fill('#room-input', 'test-room');
  await callerPage.click('#join-button');
  await receiverPage.fill('#room-input', 'test-room');
  await receiverPage.click('#join-button');

  // Step 2: caller starts the call
  await callerPage.click('#call-button');

  // Step 3: wait for the incoming banner on the receiver
  await receiverPage.waitForSelector('#incoming-call-notification', {
    timeout: 10000,
  });

  // Step 4: receiver answers
  await receiverPage.click('#answer-button');

  // Step 5: wait for the remote-video element on both sides
  await callerPage.waitForSelector('video#remote-video', {
    state: 'attached',
    timeout: 15000,
  });
  await receiverPage.waitForSelector('video#remote-video', {
    state: 'attached',
    timeout: 15000,
  });

  // Step 6: verify actual frames are decoded, not just that <video> exists
  const callerHasFrames = await callerPage.evaluate(() => {
    const v = document.querySelector('video#remote-video') as HTMLVideoElement;
    return v.videoWidth > 0 && v.videoHeight > 0 && !v.paused;
  });
  expect(callerHasFrames).toBe(true);
});

Enter fullscreen mode Exit fullscreen mode

About the 10-15 second timeouts

WebRTC connection setup is not fast. Signaling exchange over your signaling server, then ICE candidate gathering, then STUN/TURN round trips, then DTLS handshake, then media flow start. Even on localhost with a fake camera, the whole chain takes 3-8 seconds under normal conditions, and it can spike past 12 under CPU pressure on a shared CI runner.

Playwright’s default 5-second selector timeout is what gives you the classic “works locally, flakes in CI” pattern for WebRTC. Raising it to 30 seconds hides real regressions because ICE stalls take that long to surface as errors. Ten to fifteen seconds is the band where the test fails when signaling actually broke, and passes when signaling is slow-but-alive.

Fixtures do the setup once

Repeating the two-context boilerplate in every test file gets old within about three tests. Playwright’s fixture system turns it into a one-line dependency.

// fixtures/webrtc-context.ts
import { test as base, BrowserContext, Page } from '@playwright/test';

type WebRTCFixtures = {
  callerContext: BrowserContext;
  receiverContext: BrowserContext;
  callerPage: Page;
  receiverPage: Page;
};

export const test = base.extend<WebRTCFixtures>({
  callerContext: async ({ browser }, use) => {
    const ctx = await browser.newContext({
      permissions: ['camera', 'microphone'],
    });
    await use(ctx);
    await ctx.close();
  },
  receiverContext: async ({ browser }, use) => {
    const ctx = await browser.newContext({
      permissions: ['camera', 'microphone'],
    });
    await use(ctx);
    await ctx.close();
  },
  callerPage: async ({ callerContext }, use) => {
    const page = await callerContext.newPage();
    await use(page);
  },
  receiverPage: async ({ receiverContext }, use) => {
    const page = await receiverContext.newPage();
    await use(page);
  },
});

export { expect } from '@playwright/test';

Enter fullscreen mode Exit fullscreen mode

Now the test file just asks for what it needs:

// tests/webrtc/p0-basic-call.spec.ts
import { test, expect } from '../../fixtures/webrtc-context';

test('caller and receiver connect', async ({ callerPage, receiverPage }) => {
  await callerPage.goto('http://localhost:3000');
  await receiverPage.goto('http://localhost:3000');
  // Room join → call → answer → verify video
});

Enter fullscreen mode Exit fullscreen mode

Context teardown runs in the fixture, so you don’t leak a BrowserContext per test.

Verifying that a video actually plays

“A <video> tag exists” is not the same as “video is showing.” A broken test can pass the selector check and miss a black screen. There are two levels of paranoia here, and which one you want depends on what you’re testing.

Level 1: videoWidth / videoHeight

const hasVideo = await page.evaluate(() => {
  const v = document.querySelector('video#remote-video') as HTMLVideoElement;
  return v && v.videoWidth > 0 && v.videoHeight > 0;
});

Enter fullscreen mode Exit fullscreen mode

Both attributes stay at 0 until the video track is active and at least one frame is decoded. Any positive value means media is flowing. Cheap, fast, catches most of the failures I’ve seen in practice.

Level 2: requestVideoFrameCallback

For “is the video actually updating, or is this a frozen frame” you need the frame callback.

const isReceivingFrames = await page.evaluate(() => {
  return new Promise<boolean>((resolve) => {
    const v = document.querySelector('video#remote-video') as HTMLVideoElement;
    if (!v) return resolve(false);
    let count = 0;
    const cb = () => {
      count++;
      if (count >= 3) return resolve(true);
      v.requestVideoFrameCallback(cb);
    };
    v.requestVideoFrameCallback(cb);
    setTimeout(() => resolve(false), 5000);
  });
});
expect(isReceivingFrames).toBe(true);

Enter fullscreen mode Exit fullscreen mode

Three frames within five seconds. A frozen first frame or a static test pattern fails this check. It’s slower and slightly heavier, but the failure mode it catches (the connection made it, and then media stopped) is exactly the one users describe as “my video froze.”

The one thing not to do: Promise.all

Two browsers with two independent await chains suggests parallelizing with Promise.all. Don’t. WebRTC’s signaling is sequential by design, and firing both sides at once creates race windows that only fail 15% of the time, which is the worst possible flake rate because it looks stable until it doesn’t.

// Safe: strictly sequential
await callerPage.click('#call-button');
await receiverPage.waitForSelector('#incoming-call');
await receiverPage.click('#answer-button');

// Dangerous: receiver clicks answer before the banner appears
await Promise.all([
  callerPage.click('#call-button'),
  receiverPage.click('#answer-button'),
]);

Enter fullscreen mode Exit fullscreen mode

The rule I follow: await the caller’s action, then move to the receiver. The only place I’ve found genuine parallelism useful is group calls with three or more participants where multiple joins are semantically simultaneous. Even there I only use Promise.all on the join() calls, never on answer() calls.

Why Playwright and not Cypress / WebdriverIO / Selenium Grid

The multi-context first-class support is the main reason. Cypress runs a test in a single browser tab with an iframe sandbox and cannot open a second real browser context in the same process, which means you end up with Cypress plus a headless driver on the side and manual sync between them. WebdriverIO handles multi-browser, but the API for coordinating two browser objects reads like grid orchestration rather than a test script. Selenium Grid works, but the setup cost is a full network topology, and you pay that cost forever.

Playwright’s browser.newContext() giving you N isolated peers inside one JS process is the shape you want for peer-to-peer testing. The Chrome DevTools Protocol access under the hood is what makes flags like --use-fake-device-for-media-stream actually work end-to-end without a driver in the middle rewriting the launch args.

Docker + GitHub Actions with fake devices

The fake camera flags work on Chromium headless without xvfb. That means the standard mcr.microsoft.com/playwright:v1.49.0-jammy image with default GitHub Actions runners handles WebRTC E2E out of the box: no display server, no OpenGL, no sudo apt install. Runtime for the one-on-one test above is about 40 seconds on a ubuntu-latest runner. For a 6-peer group call it climbs to 90 seconds; still well inside a normal CI budget.

The one thing that surprised me: the fake camera has a memory footprint per peer that scales linearly, so a 10-peer group call test on a 2-core / 7GB runner starts hitting OOM. If you need more than 6 peers, bump to ubuntu-latest-4-cores or split the peers across two runs.

Wrap-up

The two-manual-testers approach doesn’t scale past two testers, and every video call product I’ve seen eventually needs to test presence, screen share, mute state, and 5-person group calls. Once you’re there, scheduling a five-person QA sync every merge is not a plan.

One Playwright script with two Browser Contexts, launched with --use-fake-device-for-media-stream, gives you the smallest useful WebRTC E2E setup. Add frame verification when you care about pixels. Add fixtures the moment you write a second test. Keep the calls sequential and let ICE finish. What you buy back is the ability to ship a video-call feature and let CI tell you when the presence server broke, instead of Bob-from-support at 11pm.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다