初めましてこんにちは、今年の4月から新卒でフロントエンド開発部に入社した cw-suetake 🐧です。
いきなりですが、WebRTCを利用したビデオチャットなどを開発しているとE2Eテストがほしくなってきませんか?
開発者一人で開発していると動作確認のために複数のブラウザを起動したり、PCにたくさんのwebカメラやらマイクやらを接続して…それぞれのウィンドウで各種機能が動くか確認して…とにかく動作確認ひとつにしてもやることが多くなりがちです。
そうなってくると、ある程度の動作検証はE2Eテストに任せて楽をしたくなるものです。しかしWebRTCはブラウザ側に実装されているAPIに強く依存していますし、そもそもどうやってカメラやマイクが正しく動作することを保証すればいいのでしょうか?そんな悩みに対しての自分なりの案をご紹介します。
テスト対象
音声とカメラ映像を送受信してオンラインコミュニケーションが取れるビデオチャットをテーマにE2Eテストを実施していきます。
今回ベースとしたビデオチャットのアプリケーションの実装はこちらです。
こちらにさらに以下の機能を追加しておきました。
- 自分のマイクのミュートon / off切り替え機能
- 自分のカメラのon / off 切り替え機能
- 相手の音声の音量をレベルメーターで表示する機能
左上にあるRoom IDに任意の文字列を入力し、Joinボタンを押すことでルームに参加できます。入力したRoom IDが同じユーザーとマッチングし会話をすることができます。
右下には自分の映像がプレビューされており、映像の上には音声のミュートon / offとカメラのon / offの切り替えボタンがあります。これらを押すことで相手に映像および音声のon /offを切り替えることができます。
左には相手の映像が映し出され、その上には相手の音声の音量を示すメータを用意しました。
また、今回実施したいテストケースはユーザー1・ユーザー2がいると想定して以下の4つを実施していきます。
- 1は2の映像を見ることができる
- 2がカメラを消したとき1は2の映像を見ることができない
- 2の音声が1に届いている
- 2がミュートすると2の音声が1に届かなくなる
今回記事に登場するコードは全てこちらのリポジトリにあります、具体的な実装はこちらをご覧ください
テストツール
今回のテストは2つのブラウザを用意し、それぞれの画面を操作しお互いにビデオチャットできるような環境を作る必要があります。 複数ブラウザの起動のしやすさ、Jestとの連携のしやすさなどから今回はPuppeteerを利用します。
少ない設定でJestとの連携ができるjest-puppeteerもありますが、複数ブラウザ起動に対応していないため puppeteer を使用する · Jestを参考に設定します。実際の実装はこちら
カメラのテスト方針
E2Eで起動したブラウザにカメラ映像を入力する必要があります。ここでは実際のwebカメラの映像ではなく動画ファイルを擬似的にカメラデバイスとして認識させるオプションをブラウザに付与して起動することで動画ファイルの内容をカメラ映像として扱うことができます。
起動オプションでカメラ入力にしたい動画ファイルを指定します。
const browser1 = await puppeteer.launch({ args: [ "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", "--use-file-for-fake-video-capture=./e2e/fixtures/mock1.y4m", ], });
これでカメラデバイス問題が解決しました、次に受信した映像が正しいかを判定します。
映像の判定にはvisual regression testのように送信側と受信側でスクリーンショットを取りピクセル単位で色の差分を取ることも考えましたが人間の目で見ると問題なくても画像には差分が出てしまう結果となりました。映像伝送の遅延やコーデックが非可逆圧縮であることなど考えられる要因はいくつかありますが、期待した通りにはテストできませんでした。
そこで、今回はこのような動画を用意しました。
テスト動画にQRコード画像を上乗せした動画ファイルを作成しました。これを相手側で受信し、正しくQRコードの内容が確認できれば問題ないだろうという魂胆です。
QRコードの読み取りにはedi9999/jsqrcodeを利用しています。
test("1は2の映像を見ることができる", async () => { const user2VideoImage = await screenshotElement(page1, "#video"); const qrContents = await readQrCode(user2VideoImage); expect(qrContents).toBe("https://lp.chatwork.com/product-day/2022/"); }); test("2がカメラを消したとき1は2の映像を見ることができない", async () => { page2.click("#hideCameraBtn"); // ユーザー2がカメラをoffボタンを押す const user2VideoImage = await screenshotElement(page1, "#video"); await expect(readQrCode(user2VideoImage)).rejects.toBe( "Couldn't find enough finder patterns:0 patterns found" ); });
以上でQRコードから取り出した値が正しければ受信OK・QRコードが読み取れない場合は受信NGという判断ができそうです。実装はこの辺り
音声のテスト方針
音声もカメラデバイスと同様にブラウザの起動オプションで代わりとなる音声ファイルを指定することでE2Eでもテストができます。
const browser1 = await puppeteer.launch({ args: [ "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", "--use-file-for-fake-audio-capture=./e2e/fixtures/mock1.wav", ], });
音声は見た目に表れないため先ほどのようなチェックはできません、そこで相手に音声が"届いているか"という部分に注目してテストをしていきます。
相手から受信した音声の音量をレベルメーターとして表示している部分があるので、ここについて相手が音声を送信中はレベルメータが0より大きい数値を示しており相手がミュート時は0になっていることを確認していきます。
音量を知るにはAudioContextが利用できます、今回はざっくり全体の音量を知りたいだけなので音声周りの細かい考慮は省いています。
const audioLevelQueue = Array(10).fill(0); const audioCtx = new AudioContext(); const analyser = audioCtx.createAnalyser(); analyser.fftSize = 32; const sourceNode = audioCtx.createMediaStreamSource(stream); sourceNode.connect(analyser); let dataArray = new Uint8Array(analyser.frequencyBinCount); const getLevel = () => { analyser.getByteTimeDomainData(dataArray); return Array.from(dataArray) .map((i) => Math.abs(127 - i)) .reduce((a, b) => a + b); }; const audioLevelElem = document.getElementById("volume"); const refresh = () => { const level = getLevel(); /** * requestAnimationFrameは取得頻度が高いためバーの動きを減らすため * ここでは10回計測した最大値をその時の音量としています */ audioLevelQueue.shift(); audioLevelQueue.push(level); const maxLevel = Math.max(...audioLevelQueue); audioLevelElem.setAttribute("value", maxLevel); requestAnimationFrame(refresh); }; requestAnimationFrame(refresh);
ここで取得した音量をinputタグのvalueに設定しています。テストではこの値を読み取ることで音声が受信できているか・ミュート時はちゃんと無音になっているかをテストします。
test("2の音声が1に届いている", async () => { /** * その時の音を計測するため、息継ぎなどでたまたま喋ってない区間で * ある可能性があるのでここでは10回計測し1秒以上音声が届いていない * ことを確認しています */ let audioLevel = []; for (let i = 0; i < 10; i++) { audioLevel.push(await page1.$eval("#volume", (e) => e.value)); await setTimeout(100); } expect(audioLevel.some((level) => level > 0)).toBe(true); }); test.only("2がミュートすると2の音声が1に届かなくなる", async () => { await page2.click("#muteAudioBtn"); let audioLevel = []; for (let i = 0; i < 10; i++) { audioLevel.push(await page1.$eval("#volume", (e) => e.value)); await setTimeout(100); } expect(audioLevel.some((level) => level > 0)).toBe(false); });
さいごに
さて、以上の方針でwebRTCでもE2Eテストを諦めることなく実施することができました。
そういえば、勘のいい方はもうお気づきだと思うのですが先ほど登場した画像の中にあったQRコードを見てください。 もうお分かりですね、QRコードの内容は https://lp.chatwork.com/product-day/2022/ となっていました。 おや?このサイトは?実はそうなんです!
2022/10/07(金)の12:50からChatworkが主催するオンラインカンファレンス「Chatwork "Product" Day」が開催されます! 去年はChatwork Dev Dayという名前のカンファレンスでしたが今年はさらにパワーアップし、「プロダクトを知り、プロダクトづくりを学び、プロダクトづくりをする人たちにふれる」をコンセプトにdev+αなお話を聞くことができるそうです。
参加無料でYouTube Liveで視聴できるのでポップコーンを用意して全通参加するもよし、気軽に気になるセッションだけ覗きに行くもよしとなっております。ぜひご参加ください
Bib & etc
- Testing | WebRTC
- https://jestjs.io/ja/docs/puppeteer#jest-puppeteer-プリセットなしのカスタム例
- https://gist.github.com/StoneCypher/15c53aa753ffe2fe8fdc3d7f9ffed0b7
- https://github.com/puppeteer/puppeteer/issues/4752
- https://qiita.com/tamanugi/items/8cc1266265457f13b9ea
- https://qiita.com/n0bisuke/items/78faeaeef59df716d7cf
- https://github.com/esonderegger/web-audio-peak-meter
- https://developer.mozilla.org/ja/docs/Web/API/BaseAudioContext/createAnalyser
- https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Visualizations_with_Web_Audio_API
※「QRコード」は株式会社デンソーウェーブ様の登録商標です
※ (c) copyright 2008, Blender Foundation / www.bigbuckbunny.org