WebRTCを使ったアプリケーション開発時に使えるReactコンポーネントライブラリを作った

はじめに

GWの成果物として、Reactのコンポーネントライブラリを作って公開しました。

github.com

私はWebRTCを使ったWebアプリケーションを時々作るのですが、デバイス選択機能を毎回自作しており、コンポーネントとしてまとめておけば、いつでも手軽に使えて嬉しいのでは?と思い、作ってみました。
音声の入力・出力、映像の入力のデバイス選択機能と、映像のプレビュー表示機能を実装しました。
vitest、Playwrightを使ったテストや、GitHub Actionsでのリリース作業の自動化などもやったので、得られた知見を記事としてまとめておこうと思います。

ユニットテスト

ユニットテストにはvitestを使っています。
navigator.mediaDevices.enumerateDevices() でデバイスの一覧を取得するカスタムフックのテストコードは以下のようになっています。

describe('useGetDevices', () => {
    const fakeDeviceInfo: MediaDeviceInfo = {
        deviceId: 'device-id',
        groupId: '',
        label: 'fake device',
        kind: 'audioinput',
        toJSON: vi.fn(),
    };

    beforeEach(() => {
        vi.stubGlobal('navigator', {
            mediaDevices: {
                getUserMedia: vi.fn().mockResolvedValue({
                    getTracks: () => [],
                }),
                enumerateDevices: vi.fn<any, MediaDeviceInfo[]>().mockResolvedValue([fakeDeviceInfo]),
            },
        });
    });

    afterEach(() => {
        vi.restoreAllMocks();
    });

    it('should get media devices', async () => {
        const { result } = renderHook(() => useGetDevices());
        act(() => result.current[1]());
        await waitFor(() => expect(result.current[0].length).toBe(1));
        expect(result.current[0]).toStrictEqual([fakeDeviceInfo]);
    });
});

ダミーのデバイス情報である fakeDeviceInfo を返すようにした navigator.mediaDevices.enumerateDevices() をグローバルでstubとして登録し、テストの際はそれが呼び出されるようにしています。
Reactのテストにおいては、カスタムフックの処理を実行する際は act を利用します。
result.current[1] は、カスタムフックで定義されているデバイス情報取得用の関数なので、これを act の中で呼び出し、stateが更新されるのを待って expect でチェックをしています。
navigator.mediaDevices.enumerateDevices() はPromiseを返す関数であるため、非同期処理が完了するのを待ってからチェックを行うために waitFor を使っています。

ハマったポイントとして、「vitestの globals オプションを true にすると、 window.MediaStreamundefined になる」という問題がありました。
vitestの globals オプションを true にすると、 beforeEach / afterEachdescribeexpect を都度importしなくても使えるようになるのですが、このとき、 window を書き換えてしまい、 window.MediaStreamundefined になるようです。
この挙動が正常なのかバグなのかは不明ですが、とりあえずは深堀りせずに対応することにしました。
対応方法としては2通りあり、vitestの globals オプションを false にする方法と window.MediaStream をモックする方法が考えられます。
とりあえず前者で対応しようとしたところ、 ReferenceError: expect is not defined というエラーが出ました。
以下のissueのコメントによれば、matcherを拡張することで回避できるらしいです。
github.com
ただ、私の環境では複数回実行したところうまく動かないケースが何度かありました。
おそらく、環境の問題か何らか設定が正しくないのが原因と思われますが、こちらもとりあえず深堀りはせずに、2つ目の方法を試すことにしました。
2つ目の方法では、以下のように window.MediaStream をモックしてテストを実行します。

vi.stubGlobal('MediaStream', class MediaStream {});
useGetMediaStreamMock.mockImplementation(() => {
    return [new MediaStream(), vi.fn()];
});

こうすると、とりあえず無事にテストは通るようになりました。
ただ、コンソールには以下のようなエラーメッセージが表示されます。

Error: Not implemented: HTMLMediaElement.prototype.play

これは、jsdomにおいて、 HTMLMediaElement.play() が実装されていないことによるものです。
なので、以下のようにモックしてあげればエラーは解消します。

videoElementPlayMock = vi.spyOn(window.HTMLVideoElement.prototype, 'play').mockResolvedValue();

E2Eテスト

E2EテストにはPlaywrightを使っています。
ヘッドレスブラウザにはChromiumを使っているのですが、今回は navigator.mediaDevices.enumerateDevices()navigator.mediaDevices.getUserMedia() を使っているので、フェイクの映像を使えるようにする必要があります。
テスト実行前の準備で、以下のようにオプションを指定してブラウザを起動することで、フェイクのデバイス情報やMediStreamを使えるようになります。

browser = await chromium.launch({
    args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'],
});

ヘッドレスブラウザで navigator.mediaDevices.enumerateDevices() を呼び出すと、以下の3つのデバイス情報が返されます。

  • Fake Default Audio Input
  • Fake Default Audio Output
  • fake_device_0

バイス選択のモーダルを開いてから、デバイス情報を取得して画面に表示されるまでには少し時間が空くため、 waitForFunction で、 select の子要素が1以上になるのを待ってからテストを進めるようにしています。

await page.waitForFunction(
    () => window.document.getElementById('device-select-audio-input-device').childNodes.length > 0
);

また、 video にプレビューの映像が表示されるのを待ってからテストを進める際は video 要素の readyState が4になるのを waitForFunction で待つようにしています。

await page.waitForFunction(() => window.document.querySelector<HTMLVideoElement>('video').readyState === 4);

video 要素の readyState が4というのは HAVE_ENOUGH_DATA を表しており、これをもってして映像が表示できているとみなしています。
developer.mozilla.org

E2Eテストでは、ステップごとに適宜スクリーンショットを取るようにしており、撮影した画像はGitHub Actionsの actions/upload-artifact を用いてアップロードするようにしています。
これはデバッグなどに役立てられるよう、テストが失敗した場合でも常に実行されるようにしています。

- uses: actions/upload-artifact@v3
  if: always()
  with:
    name: screenshots
    path: ./*.png

GitHub Actions上でヘッドレスブラウザを使ってダミーの映像でテストをすると、以下のようなスクリーンショットが得られます。


リリースの自動化

ビルド、npmへのアップロード、GitHubのReleaseの作成といった一連のリリース処理は、GitHub Actionsで自動化しています。
github.com

リリース処理は、手動で新しいtagを追加することで実行されます。
npmには version コマンドがあり、 from-git のサブコマンドを使うと、gitのタグとpackage.jsonversion を同期してくれます。

npm version from-git --no-git-tag-version

これでpackage.jsonとpackage-lock.jsonが更新されるので、変更をコミットしてpushするようにしています。

このとき、 actions/checkout で以下のように指定して、masterの履歴をすべて取ってこないとエラーになりました。

- uses: actions/checkout@v3
  with:
    ref: master
    fetch-depth: 0

GitHubのReleaseの作成には softprops/action-gh-release を利用しています。
generate_release_notes のオプションを true にしておくと、リリースノートをいい感じに自動生成してくれるので便利です。

タグを打つ部分だけは手動ですが、npmへのアップロードやGitHubのリリースなど、リリース作業のいろいろをいい感じに自動化できたのでとても楽になりました。
npmでライブラリなどを公開する際は参考にしてみてください。

おわりに

いくつかはまったポイントはありましたが、無事にReactコンポーネントライブラリを公開できました。
PlaywrightでのE2Eテストは開発体験が良くてめちゃくちゃ良かったです。
それと、npmの version コマンドはあまり使ったことがなかったのですが、package.jsonのversionを手動で書き換える必要がなくてとても良かったです。

このコンポーネントライブラリはOSSとして公開しているので、Issueでの要望やPR等、お気軽に送っていただけると嬉しいです。
Starを付けたり、 npm install していただけるだけでもめちゃくちゃ喜びます。
ぜひ使ってみてください。
github.com