Testing Vue components in the browser

Julia Evans

ブラウザで Vue コンポーネントをテストする

こんにちは!このブログで長く続けている取り組みのひとつが、Node やその他のサーバーサイド JS ランタイムを使わずにフロントエンドの JavaScript を書く方法を模索することです。

フロントエンドの JS プロジェクトでよくぶつかるのが、テストの書き方がわからないという問題です。以前 Playwright を使ってみたこともあるのですが、そのたびに新しいブラウザプロセスを立ち上げるのは遅くて扱いづらく、テストを制御するために Node のコードも必要でした。

結局、フロントエンドのコードはテストせずにそのままにしてしまっていて、あまり気持ちのいい状態ではありません。普段はプロジェクト自体をあまり更新しないので大きな問題にはなりにくいのですが、やはりもう少し安心して変更できるようになりたいと思っていました。なので、自分好みのフロントエンドテストの方法はずっと欲しいものリストに入っていたのです。

アイデア:テストはブラウザのタブでそのまま実行する

以前、この連載の過去記事への反応として、Alex Chan さんが Testing JavaScript without a (third-party) framework という素晴らしい記事を書いてくれました。ブラウザのページ内で動く、ごく小さなユニットテストフレームワークの作り方が解説されています。

当時この記事がとても気に入ったのですが、扱われていたのはユニットテストだけで、私がやりたかったのは Vue コンポーネントのエンドツーエンドの統合テストでした。やり方がわからなかったのです。

先日 Marco さんと話していたとき、彼が「Vue コンポーネントのテストなら、ブラウザでそのまま実行できるよ」といったことを言ったので、「よし、もう一度試してみよう!」と思ったのです。

これらのことは昨日やったばかりなので、改善の余地はたくさんありますが、忘れないうちに気づいたことをいくつか書き留めておきたいと思います。

今回少し手こずったのは、Vue の公式サイトではビルドプロセスの一部として Node を使うことが前提になっていることが多いからです(「ステップ1: npm install THING」という記述がたくさん出てきます)。私は Node や Deno などを使いたくなかったのですが、やってみるとそれほど複雑ではありませんでした。

今回テストの例として取り上げるのは、2023年に作ったこちらの zine フィードバックサイト です。

テストフレームワーク:QUnit

今回は QUnit を使いました。とてもうまく動いたのですが、仕組みについて特に語ることはないので、これで終わりにします。Alex さんの「自分でテストフレームワークを書く」というアプローチでもうまくいったと思います。こちらの手順に従いました。

QUnit には1つのテストだけを再実行できる「rerun test」ボタンがあるのがありがたかったです。私のテストではネットワークリクエストが多いので、1つだけ実行できることでデバッグ時の混乱がずいぶん減りました。

ステップ1:テスト用にコンポーネントを準備する

最初にやる必要があったのは、Vue コンポーネントをテスト環境で使えるようにすることでした。

メインのアプリを修正して、すべてのコンポーネントを window._components に入れるようにしました。だいたい次のような感じです。

const components = {
  'Feedback': FeedbackComponent,
  ...
}
window._components = components;

これで、普段のメインアプリとほぼ同じことをする mountComponent 関数を書くことができました(使いたいコンポーネントを含む小さなテンプレートを描画する、という処理です)。違いは次の2点だけです。

  1. props として使う追加のデータを、必要に応じて渡せることです。
  2. コンポーネントを一時的な非表示の div にマウントすることです。この div はテスト終了後に DOM から削除されます。画面外に配置している(position: absolute; top: -10000, ...)ので目には見えません。

mountComponent 関数の使い方は次のようになります。

const {div} = mountComponent(
  '<Page :feedbacks="feedbacks" id=2 />',
  {feedbacks: [testFeedback]},
);

そして、これがそのコードです。

function mountComponent(template, data) {
  const app = Vue.createApp({
    template: template,
    data: () => data,
  })
  for (const [c, v] of Object.entries(window._components)) {
    app.component(c, v);
  }
  const div = document.getElementById('qunit-fixture')
             .appendChild(document.createElement('div'));
  return div;
}

これで、プログラムからクリックしたり、フォームに値を入れたり、正しい内容が表示されているか確認したりできる div が手に入ります。

ステップ2:フィクスチャデータを用意する

クライアント側の JS がサーバーと正しく連携することを確認するエンドツーエンドの統合テストを書いていたので、データベースにテストデータが必要でした。そこで、テストデータを用意するための SQL を20行ほど書き、テストデータを既知の状態にリセットする SQL を実行するエンドポイントを開発サーバーに追加しました。

async function reset() {
    return fetch('/api/reset_test_data', {method: "POST"})
}

あとは、テストデータが必要なテストの冒頭で await reset() を実行するだけです。

正直、私の reset() 関数は毎回すべてを完全にリセットするわけではないので、あまりよくないのですが、とりあえず動くところから始めるには十分でした。いつでも改善できます。

ステップ3:基本的なテスト

基本的なテストはこんな感じです。div を描画して、おおよそ正しいデータが含まれていることを確認しているだけです。

QUnit.test('renders feedback content', async function (assert) {
  const {div} = mountComponent(
    '<Page :feedbacks="feedbacks" id=2 image=2 page_hash=2 />',
    {feedbacks: [testFeedback]},
  );
  assert.ok(div.textContent.includes('loved this section'));
})

これで基本的な部品は揃いました!ここからは、途中でぶつかったいくつかの問題について書いていきます。

ページの一部が描画されるのを待つ

私のテストにはネットワークリクエストがたくさんあり、それらが完了して、Vue のコードが結果を処理して DOM を更新するまでには時間がかかります。

テストの中に適当な sleep() を入れて、タイミングが合うことを祈るやり方は、遅くて不安定で、とてもイライラするものだということは、みんなとっくに学んでいると思います。なので、別の方法が必要でした。

私の理解では、一般的な対処法は、DOM を見て次に進んでよいかどうかを判断する方法を考えることです。たとえば「このボタンが表示されていれば、次に進める」といった具合です。

そこで、20ミリ秒ごとに条件が満たされたかポーリングする、小さな waitFor() 関数を書きました。2秒でタイムアウトします。

使い方はこんな感じです。

QUnit.test("click item", async function (assert) {
  const {div} = mountComponent(
    '<Feedback zine_id="test123" image_width="800px" />',
    {});
  const item = await waitFor(() => div.querySelector('.feedback-item'));
  item.click();
  // rest of test goes here... 
})

この考え方自体は世の中にたくさんの実装があり、どれも私のものよりよく考えられています(ざっと検索しただけでも、qunit-wait-forplaywright expect.poll などがあります)。

何を待つべきかを見極めるのは簡単ではない

場合によっては、DOM で待つべき対象を正しく見つけたつもりになっていました(「この textarea が表示されるのを待てばいい!」といった具合です)。しかし、プログラム内部の細かい挙動のせいで、実際にはもっと後の別の何かを待つ必要があり、その特定が難しいこともありました。

結局、あるコンポーネントでは、重要な処理が終わったときに DOM に適当な値を追加するように修正しました(たとえば data-this-thing-is-ready=true のように)。あまり気持ちのいいやり方ではありませんでした。

こうしたテストの問題を正しく直す方法は、アプリ自体をユーザにとってより信頼できるものにするリファクタリングなのではないかと思っています。DOM 上にあるのにユーザがまだ操作できない要素があるなら、そもそもまだ表示すべきではないのかもしれません!

要素を特定するために CSS クラスを追加する(でもそれでいいの?)

結局、テストで見つける必要がある HTML 要素に、いくつかクラスを追加しました。クリックする必要があるから、あるいは DOM に表示されるのを待つ必要があるからです。

このやり方は今後変えるかもしれません。フロントエンドのテストフレームワークでは、CSS クラスを使うのは避けて、代わりに getByRole のような方法や、最終手段として data-testid のようなものを使うことが推奨されているようです。アクセシビリティを高めつつ、テストもしやすくする方法があるはずだと感じています。

フォームへの入力は一筋縄ではいかない

フォームに入力するには、単に value をセットするだけでは足りず、要素が変更されたことを Vue に伝えるためにイベントを発火させる必要があります。たとえば、checkboxtextarea では必要なイベントの種類が異なります。

textarea.value = 'banana banana banana';
textarea.dispatchEvent(new Event('input'));
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));

これは少し面倒で、UI テストライブラリを使いたくなる理由がよくわかりました。たとえば次のような例があります。

テストカバレッジ

テストカバレッジがどのくらいあるのか知りたいと思っていたのですが、実は Chrome には JS と CSS のコードカバレッジ機能が標準で備わっていることがわかりました!

私の JS は esbuild で bundle.js というファイルにバンドルされているので、その bundle.js を見て、どの行がカバーされていないかを確認できました。

ただ、やり方は少し扱いづらいところがありました。これを動かすには Chrome DevTools でソースマップをオフにする必要があり、カバレッジデータを表示するまでの手順も、あまり直感的ではない決まった操作が必要です。

本当に楽しかった!

いつものことですが、私は(自分自身のためにやる以外は)フロントエンドやバックエンドの開発者として働いたことがなく、こうした超基本的なことをやる方法を常に学んでいる感覚です。

今回は本当に楽しく作業できました。フロントエンドのプロジェクトはテストがないせいでいつも脆く感じられるので、いつか自信を持てるテストスイートができたらいいなと思っています!

まだ考え中のこともいくつかあります。

  • この記事を書いている途中で Testing Library というフロントエンドのテストライブラリを見つけました。テストの書き方について、私の最初の考えとはかなり違うガイドラインがたくさんあります。すべてを Testing Library を使う形に書き換えてみたところ、なかなかいい感じだったので、今後どうなるか様子を見たいと思っています。Node なしで動く .umd.js ファイルを配布しているのもありがたいです。
  • これらのテストをコマンドラインで実行する方法がまったくないことについて、どう感じるかまだ決めかねています。基本的にはブラウザで作業しつつ、必要なら CI でも実行できるようなシンプルな方法があるのかもしれません。

原文は Julia Evans により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。