ジンジャー研究室

長めのつぶやき。難しいことは書きません。

Vitest の Browser Mode (experimental) でファイル読み込みのテストを書く

趣味でブラウザ上に画像や音声を読み込んで作業する React アプリを作っているのだが、 Vitest + Testing Library でテストをしようと思ったらファイル読み込み部分でつまづいた。Node.js 上でブラウザ環境をシミュレートしている部分がそのままでは上手く動かないので、 polyfill を入れたり沢山モックを差し込んだりするとなんとか動く。が、色々弄りすぎて本当にテスト出来ているのか怪しいし、やはりリアルなデータでテストしたい。

で、リアルなブラウザ環境でテスト出来ないかなと調べていたところ、2つの候補が挙がった。

両方とも experimental 。前者は Vitest をそのままブラウザ上で実行するというもので、後者は Playwright のテストをコンポーネント単位で行えるようにするもの。どちらも一長一短だが、今回はアサーションもブラウザ上で行うのが都合が良かったので前者の Vitest Browser Mode を採用した。

導入

導入は簡単。vite.config.ts の testbrowser の設定を追加するだけ。デフォルトでは webdriverio が使われるのだが、馴染みのある playwright を使うことにする。

export default defineConfig({
  ...
  test: {
    ...
    browser: {
      enabled: true,
      provider: "playwright",
      name: "chromium",
      headless: true,
    },
  },
});

このように設定を変えてテストを実行すると、あれこれライブラリが足りないと親切なプロンプトが出るので従っていくと色々インストールしてくれる。(CI には npx playwright install を追加する必要がある)

テストが再び動くまで

上記の手順でとりあえずテストを起動できるようにはなるのだが、そのままでは正常に動かなかった。 @vitejs/plugin-react can't detect preamble というエラーに遭遇して途方に暮れていたところ、Vitest のコントリビュータが書いている example を発見。この example は公式ドキュメントにマージされていないので、 2023/9 現在この fork されたブランチでしか見ることができない。惜しい。

まず、 setupFiles におまじないを入れると上記エラーが消える。

// 引用: https://github.com/vitest-dev/vitest/blob/userquin/feat-isolate-browser-tests/examples/react-testing-lib-browser/src/test/setup.ts
import "@testing-library/jest-dom";

if (typeof window !== "undefined") {
  // @ts-expect-error hack the react preamble
  window.__vite_plugin_react_preamble_installed__ = true;
}

あとは、ブラウザ環境なので本物の DOM の上にマウントするようにテストコードを書き換える必要がある。

// 引用: https://github.com/vitest-dev/vitest/blob/userquin/feat-isolate-browser-tests/examples/react-testing-lib-browser/src/utils/test-utils.tsx
export async function createContainer(id: string, node: ReactNode) {
  const container = document.createElement('div')
  container.setAttribute('id', id)
  document.body.appendChild(container)
  const root = createRoot(container)
  root.render(node)

  await nextTick()

  return root
}

testing-library を使っている場合、 root の代わりに within(root) を返しておくと screen と同じように使えたりする。

ところで、上のコードの通りにすると複数の root からそれぞれ React アプリが起動するため、同じグローバルオブジェクトを参照していたりすると問題になることがある。自分のケースでは jotai の Provider で <App> を囲むなどして問題を回避することができた。

他に細かい部分では timers/promisessetTimeout が応答しなくなったりしたのでブラウザ標準の setTimeout で書き直したりした。

が、問題としてはそのくらいで、テストの本質的な部分に関してはほぼノータッチで移行することができた。 polyfill にも別れを告げることができた。

実ファイルを読み込むまで

ブラウザ環境で動かすため、テストに使うファイルを fs モジュールで入手することが出来なくなってしまった。そこで、代わりに Vite の loader を使って直接 import した。Vite では import foo from "foo.bar?raw" のように書くとファイルをテキスト形式で読み込むことが出来るが、バイナリは対応していないようだったので、自前で用意した。

const arrayBufferLoader = () => ({
  name: "arraybuffer-loader",
  transform(_code: any, id: string) {
    const [path, query] = id.split("?");
    if (query != "buffer") {
      return null;
    }
    const hex = fs.readFileSync(path, "hex");
    return `
    const hex = "${hex}";
    const arrayBuffer = new Uint8Array(hex.match(/../g).map(h => parseInt(h, 16))).buffer;
    export default arrayBuffer;
    `;
  },
});

export default defineConfig({
  plugins: [arrayBufferLoader(), ... ],
  ...
});

参考: javascript - Import raw image data into a script with vite - Stack Overflow

これで import foo from "foo.bar?buffer" のようにして ArrayBuffer を読み込むことが出来るようになった。ただしこのままだと foo の型が分からないと TS のコンパイラに文句を言われるので、vite-env.d.ts に次のような宣言を加える。

declare module "*?buffer" {
  const src: ArrayBuffer;
  export default src;
}

これで @ts-ignore とか as とか使わなくても ArrayBuffer として扱われる。嬉しい。

その他、モックの制約など

今回は最終的に使わなくなったのだが、vi.mock が Browser Mode では動かないと書かれている。その Issue は未だ open だが、vi.hoisted が実装されたことにより named export に対して spyOn できるようになったとのこと。

it's now possible to use vi.spyOn on an ES module named export in browser environment

実際やってみたら確かに問題なく動いていそうだった。

実際の移行の様子

github.com

感想

  • ファイル読み込みのテストでモックまみれになっていた部分がごっそりなくなってスッキリした
  • テストの本質的な部分に関してはほぼ書き換えずに移行できた
  • Playwright を起動する時間だけ立ち上がりが遅いが、個人的には全く気にならないレベルだった

ブラウザ上のテスト実行、流行るといいなぁ。