はじめに
GWの成果物として、Reactのコンポーネントライブラリを作って公開しました。
私は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.MediaStream
が undefined
になる」という問題がありました。
vitestの globals
オプションを true
にすると、 beforeEach / afterEach
や describe
、 expect
を都度importしなくても使えるようになるのですが、このとき、 window
を書き換えてしまい、 window.MediaStream
が undefined
になるようです。
この挙動が正常なのかバグなのかは不明ですが、とりあえずは深堀りせずに対応することにしました。
対応方法としては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.jsonの version
を同期してくれます。
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