PK配信は、二人の配信者が接続できた瞬間だけを見ると完成したように感じます。しかし、再テストのたびに担当者を集め、操作順を口頭で合わせていると、競合操作や切断時の不整合を見落とします。
これは実装者の理解不足というより、配信者A・配信者B・視聴者が見た状態を、同じ時系列で観測する場所がないことが原因です。
本稿では、Tencent RTCのInteractive LiveでPKを実装する際に使える、ローカル専用のCLIテストハーネスを作ります。CLIが一時ポートで収集サーバーを開き、Playwrightで3役のブラウザを起動し、アプリが確定した状態遷移を集約します。
ここで作るイベント名はテスト用のアプリケーション契約です。Tencent RTC SDKのAPI名やコールバック名ではありません。利用中のSDK・UI構成から変換するアダプターを自分のアプリ側に置きます。
結論
PK配信の回帰テストでは、SDKの生ログを大量に保存するより、次の状態だけをアプリ側で正規化して記録する方が扱いやすくなります。
- 両配信者がライブ状態になった
- PK要求が作られた
- 相手が承諾した
- PK表示が接続済みになった
- 視聴者にもPKレイアウトが表示された
- 終了または離脱後に通常表示へ戻った
CLIには本番の管理権限を持たせません。テストビルドだけが利用できる一時的な観測口として扱い、127.0.0.1、ランダムポート、役割別チケットで閉じます。
前提:先にUI方式を決める
Tencent RTCの公式ドキュメントでは、ライブ配信の導入方法として、構築済みUIを使う方法と、ヘッドレスなAtomicXCoreを使う方法が案内されています。
- 標準的な配信画面を早く組み立てたい:構築済みUI
- 独自のPK画面、状態表示、操作制御を作りたい:AtomicXCore
実装方法の選択は、公式のセットアップガイドを基準にしてください。
TUILiveKitの製品紹介では、共同配信、PK、チャット、ギフト、視聴者管理など、Interactive Liveで利用する機能領域が説明されています。本稿では、そのうちPKの状態遷移をテスト対象にします。
UI方式によるテスト位置の違い
| 構成 | テストイベントを出す位置 | 向いている確認 |
|---|---|---|
| 構築済みUI | 画面の表示状態をアプリ側で観測した直後 | 標準フローの回帰確認 |
| AtomicXCore | アプリの状態ストアを更新した直後 | 独自UI、競合操作、復旧処理 |
SDKから通知を受けた瞬間ではなく、アプリの状態ストアと画面が更新された後にテストイベントを出すのがポイントです。通知を受けても画面更新に失敗していれば、ユーザーにとってPKは成功していません。
完成するワークフロー
CLIを1回実行すると、次の順でテストします。
-
127.0.0.1の空きポートにイベント収集サーバーを起動 - 配信者A、配信者B、視聴者用のランダムなチケットを発行
- Playwrightの独立したBrowser Contextを3つ起動
- 各ページへ役割とイベント送信関数を注入
-
data-testidを使って配信開始、PK要求、承諾を操作 - 3画面から届いたイベントの順序と
attemptIdを検証 - ブラウザとローカルサーバーを明示的に終了
Browser Contextを分けるのは、Cookieやローカルストレージを共有させないためです。同じページを3タブ開くだけでは、ログイン状態が混ざるアプリがあります。
手順1:検証環境を作る
Node.js 20以降を前提にします。
mkdir pk-live-harness
cd pk-live-harness
npm init -y
npm install --save-dev playwright
npx playwright install chromium
テスト対象アプリには、開発環境専用で次のセレクターを付けます。
<button data-testid='start-live'>配信開始</button>
<button data-testid='request-pk'>PKを申し込む</button>
<button data-testid='accept-pk'>PKを承諾する</button>
<div data-testid='pk-layout'>...</div>
data-testidは見た目や文言から独立させます。ボタンの位置や表示文言が変わっても、操作契約まで壊れないようにするためです。
手順2:テスト用データモデルを固定する
SDK固有のイベントをそのまま保存せず、テストで判断したい意味へ変換します。
type Role = 'hostA' | 'hostB' | 'audience';
type PkEventType =
| 'LIVE_STARTED'
| 'PK_REQUESTED'
| 'PK_ACCEPTED'
| 'PK_CONNECTED'
| 'PK_LAYOUT_VISIBLE'
| 'PK_ENDED'
| 'LIVE_ENDED';
type PkTestEvent = {
runId: string;
role: Role;
type: PkEventType;
attemptId?: string;
occurredAt: number;
};
特に重要なのがattemptIdです。
PK要求をキャンセルして再要求した場合、最初の要求に対する遅い通知が後から届く可能性があります。画面が現在どの要求を表示しているのかを区別できなければ、古い結果で新しいPK画面を上書きしてしまいます。
runIdはCLI実行単位、attemptIdはPK試行単位として分けます。
コード1:ループバック収集サーバーと3役ブラウザ
次をpk-harness.mjsとして保存します。
import http from 'node:http';
import { randomBytes, randomUUID } from 'node:crypto';
import { chromium } from 'playwright';
const appUrl = process.env.APP_URL;
if (!appUrl) throw new Error('APP_URLを指定してください');
const runId = randomUUID();
const roles = ['hostA', 'hostB', 'audience'];
const allowedTypes = new Set([
'LIVE_STARTED',
'PK_REQUESTED',
'PK_ACCEPTED',
'PK_CONNECTED',
'PK_LAYOUT_VISIBLE',
'PK_ENDED',
'LIVE_ENDED'
]);
const tickets = new Map(
roles.map(role => [role, randomBytes(24).toString('base64url')])
);
const events = [];
function readJson(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 16 * 1024) {
reject(new Error('payload too large'));
req.destroy();
}
});
req.on('end', () => {
try {
resolve(JSON.parse(body));
} catch {
reject(new Error('invalid json'));
}
});
});
}
const server = http.createServer(async (req, res) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'content-type,x-test-ticket',
'Access-Control-Allow-Methods': 'POST,OPTIONS'
});
return res.end();
}
if (req.method !== 'POST' || req.url !== '/event') {
res.writeHead(404);
return res.end('not found');
}
try {
const event = await readJson(req);
const expectedTicket = tickets.get(event.role);
const actualTicket = req.headers['x-test-ticket'];
if (!expectedTicket || actualTicket !== expectedTicket) {
res.writeHead(401);
return res.end('unauthorized');
}
if (event.runId !== runId || !allowedTypes.has(event.type)) {
res.writeHead(400);
return res.end('invalid event');
}
const stored = { ...event, receivedAt: Date.now() };
events.push(stored);
console.log(
`${stored.role.padEnd(8)} ${stored.type.padEnd(20)} ${stored.attemptId ?? '-'}`
);
res.writeHead(204, { 'Access-Control-Allow-Origin': '*' });
res.end();
} catch (error) {
res.writeHead(400);
res.end(String(error));
}
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const endpoint = `http://127.0.0.1:${address.port}/event`;
const browser = await chromium.launch({ headless: false });
const pages = {};
for (const role of roles) {
const context = await browser.newContext();
const ticket = tickets.get(role);
await context.addInitScript(
({ endpoint, ticket, runId, role }) => {
window.__PK_TEST__ = {
emit(type, attemptId) {
return fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-test-ticket': ticket
},
body: JSON.stringify({
runId,
role,
type,
attemptId,
occurredAt: Date.now()
})
});
}
};
},
{ endpoint, ticket, runId, role }
);
const page = await context.newPage();
const url = new URL(appUrl);
url.searchParams.set('testRole', role);
url.searchParams.set('testRun', runId);
await page.goto(url.toString());
pages[role] = page;
}
async function waitForEvent(predicate, timeoutMs = 10000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const found = events.find(predicate);
if (found) return found;
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error('期待したイベントが届きませんでした');
}
try {
await Promise.all([
pages.hostA.getByTestId('start-live').click(),
pages.hostB.getByTestId('start-live').click()
]);
await waitForEvent(e => e.role === 'hostA' && e.type === 'LIVE_STARTED');
await waitForEvent(e => e.role === 'hostB' && e.type === 'LIVE_STARTED');
await pages.hostA.getByTestId('request-pk').click();
const request = await waitForEvent(
e => e.role === 'hostA' && e.type === 'PK_REQUESTED'
);
await pages.hostB.getByTestId('accept-pk').click();
await waitForEvent(
e => e.type === 'PK_ACCEPTED' && e.attemptId === request.attemptId
);
await waitForEvent(
e => e.type === 'PK_CONNECTED' && e.attemptId === request.attemptId
);
await waitForEvent(
e =>
e.role === 'audience' &&
e.type === 'PK_LAYOUT_VISIBLE' &&
e.attemptId === request.attemptId
);
console.log('PASS: 3役で同じPK試行を確認しました');
} finally {
await browser.close();
await new Promise(resolve => server.close(resolve));
}
実行します。
APP_URL=http://localhost:3000/live npm exec -- node pk-harness.mjs
testRoleはローカルのテストフィクスチャを選ぶための値であり、認証として使ってはいけません。本番ビルドでは、この分岐とwindow.__PK_TEST__への送信処理を除外してください。
コード2:アプリの確定状態をCLIへ通知する
アプリ側には小さな通知関数を用意します。
type TestBridge = {
emit(type: string, attemptId?: string): Promise<void>;
};
declare global {
interface Window {
__PK_TEST__?: TestBridge;
}
}
export function reportPkState(type: string, attemptId?: string) {
if (import.meta.env.PROD) return;
void window.__PK_TEST__?.emit(type, attemptId);
}
呼び出す位置は、SDK通知の受信直後ではなく、アプリの状態更新が完了した箇所です。
function commitPkConnected(attemptId: string) {
if (attemptId !== pkStore.currentAttemptId) return;
pkStore.status = 'connected';
pkStore.visibleAttemptId = attemptId;
renderPkLayout();
reportPkState('PK_CONNECTED', attemptId);
}
視聴者画面では、PK用レイアウトが実際に表示された後に通知します。
function showAudiencePkLayout(attemptId: string) {
audienceStore.layout = 'pk';
audienceStore.attemptId = attemptId;
renderAudienceLayout();
const visible = document.querySelector('[data-testid=pk-layout]');
if (visible) reportPkState('PK_LAYOUT_VISIBLE', attemptId);
}
この分離により、メディア接続の通知は届いたが、視聴者UIが通常レイアウトのままという失敗を検出できます。
確認方法
正常系を1回通しただけでは不十分です。最低限、次のケースを別シナリオとして実行します。
1. 通常のPK開始
合格条件は次の通りです。
- 配信者AとBが
LIVE_STARTEDを送る - Aの
PK_REQUESTEDにattemptIdがある - Bの
PK_ACCEPTEDが同じattemptIdを持つ -
PK_CONNECTEDの後に視聴者のPK_LAYOUT_VISIBLEが届く
2. 承諾前のキャンセル
Aが要求を取り消した後、Bが遅れて承諾操作をしてもPK画面へ遷移しないことを確認します。
古いattemptIdに対するPK_ACCEPTEDまたはPK_CONNECTEDが届いた場合は、状態ストアで破棄し、その試行を失敗としてログに残します。
3. 双方が同時に要求
AとBがほぼ同時にPKを要求するケースです。
ここで必要なのは、CLIが勝手に勝者を決めることではありません。アプリとして採用するルールを決め、その結果を検証します。
- 先に確定した要求だけを採用する
- 一方の要求を明示的に拒否する
- 両者へ再操作を求める
どれを選ぶ場合でも、画面ごとに異なるattemptIdを表示し続ける状態は不合格です。
4. PK中に片方が離脱
次を確認します。
- 残った配信者が通常レイアウトへ戻る
- 視聴者もPKレイアウトを閉じる
- 終了済み
attemptIdの遅いイベントでPKが復活しない - 再接続を自動で行うか、ユーザー操作を求めるかが画面上で分かる
5. 目視確認を残す
CLIで判断できるのは状態遷移です。次の項目は人間が確認する必要があります。
- 二つの映像が意図した位置に表示されるか
- 名前、スコア、操作ボタンが重ならないか
- 縦横比や画面回転でレイアウトが破綻しないか
- 切断や拒否の説明が視聴者にも理解できるか
自動化できない項目が残ることは失敗ではありません。機械判定と目視判断の境界が明示されている方が、リリース判断の根拠を共有しやすくなります。
注意点とトレードオフ
SDKの生イベントとアプリイベントを混同しない
本稿のPK_CONNECTEDなどはアプリが定義するテスト契約です。SDKのAPI名として検索したり、そのまま実装したりしないでください。利用しているTencent RTCの構成と公式ドキュメントを確認し、アダプターで変換します。
一時サーバーは外部公開しない
収集サーバーは127.0.0.1にのみバインドしています。0.0.0.0へ変更すると、同一ネットワーク上の別端末から到達できる可能性があります。複数の実機を使う試験では、認証、TLS、ログ保管、ネットワーク境界を別途設計してください。
チケットは認証基盤ではない
役割別チケットは、ローカルテスト中の誤送信を防ぐための使い捨て値です。本番ユーザーの認証や、配信ルームへの参加権限には利用できません。
ヘッドレス実行だけにしない
CIではheadless: trueにできますが、PKは視覚的な不具合が発生しやすい機能です。状態遷移テストをCIで回しつつ、リリース前には表示ありの実行と実機確認を残すのが現実的です。
終了処理を必ず行う
ブラウザ、HTTPサーバー、配信セッションは管理主体が異なります。テスト失敗時にもfinallyで閉じ、アプリ側でも配信退出処理が完了することを確認してください。CLIプロセスを強制終了するだけでは、アプリ上の退出フローを検証したことにはなりません。
まとめ
PK配信の不安は、担当者が実装を理解していないからではなく、複数役の成功条件が共有されていないことから生まれます。
一時的なローカル収集サーバーと3つのBrowser Contextを使えば、公開されたテスト管理サーバーを常設せずに、同じ操作を繰り返せます。さらにrunIdとattemptIdを分けることで、再要求や遅延イベントによる古いPK画面の復活も検証できます。
まずは通常開始、承諾前キャンセル、片方の離脱の3ケースを固定し、その後に同時要求やネットワーク変化を追加すると、テスト用チャネルだけが増えて誰も管理しない状態を避けられます。
関係性の開示: 筆者はTencent RTCのコンテンツ制作に関与しています。本稿の製品仕様に関する記述は、Tencent RTCの公式ドキュメントを実装上の参照資料として使用しています。