こんにちは!ここでの私の長期プロジェクトの一つは、 Nodeを使わずにフロントエンドのJavaScriptを書く方法を模索すること やその他のサーバー用JSランタイムを使わないことです。

フロントエンドのJSプロジェクトでよく直面する問題の一つは、テストの書き方がわからないことです。以前Playwrightを使ってみましたが、毎回新しいブラウザプロセスを起動するのが遅くて扱いにくく、テストを制御するためのNodeコードも必要でした。

その結果、フロントエンドのコードをテストせず、なんだか良くない気分になります。通常、プロジェクトをあまり更新しないのでそれほど問題になりませんが、もっと自信を持って変更できたらいいですよね! だから、気に入るようなフロントエンドテストの方法は長い間私のウィッシュリストに入っていました。

アイデア: ブラウザタブ内でテストを実行する

Alex Chanは以前、私のこのシリーズの投稿の一つに対する返事として、サードパーティのフレームワークなしでJavaScriptをテストするという素晴らしい記事を書きました。その記事では、ブラウザのページ内で動作する小さなユニットテストフレームワークの書き方を説明していました。

当時この記事は気に入りましたが、ユニットテストについてのみ触れており、私はVueコンポーネントのエンドツーエンド統合テストを書きたかったので、どうすればいいかわかりませんでした。

そこで先日、Marcoと話していたときに「Vueコンポーネントのテストはブラウザでそのまま実行できるよ」と言われて、「あ、そうか!もう一度試してみよう!」と思いました。

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

Vueのサイトでは通常、何らかの形でNodeをビルドプロセスに使うことを前提としている(「ステップ1: npm install THING」のような記述が多い)ため、私にとっては少し厄介でした。しかし、実際にはそれほど複雑ではありませんでした。

テストについて話すプロジェクトは、2023年に書いたこのzineフィードバックサイトです。

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

QUnitを使いました。うまく動作しましたが、その仕組みについて特に面白いことはないので、それだけにしておきます。Alexの「自分でテストフレームワークを書く」アプローチも機能したと思います。こちらの指示に従いました。

QUnitに「テストを再実行」ボタンがあり、1つのテストだけを再実行できる点が気に入りました。テストには多くのネットワークリクエストがあるため、1つのテストだけを実行できる方法があると、デバッグがずっとわかりやすくなります。

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

最初に必要だったのは、テスト環境にVue コンポーネントをセットアップすることでした。

メインアプリを変更して、すべてのコンポーネントを window._components に配置しました。以下のような感じです:

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

これにより、mountComponent 関数を書くことができました。この関数は基本的に通常のメインアプリと同じことを行います(使用したいコンポーネントで小さなテンプレートをレンダリングする)。唯一の違いは以下の点です:

  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がサーバーと正しく連携していることを確認するためのエンドツーエンド統合テストを書いていたため、データベースにテストデータが必要でした。そこで約25行のSQLを書いてデータベースにテストデータをセットアップし、開発サーバーにエンドポイントを追加してテストデータを既知の状態にリセットする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();
  // テストの残りの部分はここに... 
})

この概念の実装は他にもたくさんあり、私のものよりよく考えられているようです。(簡単なGoogle検索で: 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の開発者ツールでソースマップをオフにする必要があり、カバレッジデータを表示するには特定の、あまり明白ではない一連の操作を行う必要があります。

これは本当に楽しかった!

いつものように、これらの投稿ではフロントエンドやバックエンドの開発者として働いたことがほとんどない(自分自身のため以外は!)ので、非常に基本的なタスクのやり方を常に学んでいる気分です。

本当に楽しかったです。私のフロントエンドプロジェクトはテストされていないため、いつも脆く感じていましたが、もしかしたら自信を持てるテストスイートが手に入る日が来るかもしれません!

まだ考えていること:

  • この記事を書いているときに、Testing Libraryというフロントエンドテストライブラリを見つけました。私の最初のアイデアとは大きく異なるテストの書き方に関するガイドラインがたくさんあります。Testing Libraryを使ってすべてを書き換える実験をしてみましたが、かなり良い感じがしたので、これからどうなるか見てみましょう。彼らはNodeなしで動作する .umd.js ファイルを提供しています。
  • これらのテストをコマンドラインで実行する方法がまったくない点について、どう感じるべきかわかりません。主にブラウザで作業しつつ、必要に応じてCIでも実行できる簡単な方法があるのかもしれません。