Testing Vue components in the browser

Julia Evans

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

原文は Julia Evans により に公開されました。 このブログを購読する

やあ!このブログで長く続けているプロジェクトのひとつが、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にマウントし、テストが終わったらDOMから削除されること。このdivはページ外に配置(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を25行ほど書き、開発サーバーにその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から先に進んでよいかどうかを判断する方法を見つけることです。例えば「このボタンが表示されていれば、次に進める」といった具合です。

そこで、20msごとに条件が満たされたかどうかをポーリングする小さな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でも実行できるようなシンプルな方法があるかもしれません。

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

コメント