🎻

SPAでないSymfonyプロジェクトでjQueryなどを使ったフロントエンドの処理を機能テストする方法

2021/01/15に公開

前置き:例えばこんな要件ありますよね

よくある要件として、フォームに カテゴリサブカテゴリ という2つの入力項目があり、選択されている カテゴリ に応じて サブカテゴリ の選択肢が変化してほしい、といったものを考えてみます。

この場合、もちろんSymfony側ではカスタムバリデーションを書くなどしてカテゴリとサブカテゴリの組み合わせが正しくない場合にエラーになるように実装することになるでしょう。

しかし、それだけだとフロントエンド側では間違った選択肢を普通に選べてしまうので、ユーザーは送信してみるまで間違いに気づくことができず、とても不親切です。(というかこの要件でその実装だったら普通にクレームでしょう😅)

ちなみに、カスタムバリデーションの書き方については以下の別記事などをご参照ください。

というわけで仕方なくjQueryで

$('#foo_category').on('change', () => {
  $('#foo_subCategory').find('option').prop('disabled', true)
  switch ($(this).val()) {
    case 'カテゴリA':
      $('#foo_subCategory').find('option:{カテゴリAに対応するサブカテゴリだけに絞り込む条件}').prop('disabled', false)
      break
    case 'カテゴリB':
      $('#foo_subCategory').find('option:{カテゴリBに対応するサブカテゴリだけに絞り込む条件}').prop('disabled', false)
      break
    case 'カテゴリC':
      $('#foo_subCategory').find('option:{カテゴリCに対応するサブカテゴリだけに絞り込む条件}').prop('disabled', false)
      break
    default:
      break
  }
}).change()

のような処理を書くことになります。

JSの処理も機能テストしたいけどできない

これぐらいの処理ならまだいいですが、もし カテゴリ サブカテゴリ 孫カテゴリ ぐらいまで登場して、複雑な組み合わせに対応する必要がでてきたりすると、さすがに自動テストがないと不安になってきます。

しかし、SymfonyのWebTestCaseを使った機能テストではJavaScriptは実行されないので、フロントエンドの処理をテストすることはできません。

これは困りました。

こんな感じでやってみたら上手いこと機能テストできた

というわけでちょっと知恵を絞ってみまして、結果的に割といい感じでJSの処理の機能テストを実行する方法を編み出したので、共有したいと思います。

1. jestをインストール

今回は jest を使いましたが、別にテストフレームワークは何でもよいです✋

$ yarn add --dev jest
# or
$ npm i -D jest

npm scriptとして実行できるように package.json にコマンドを追記します。

  # package.json
  
  "scripts": {
    :
    :
+   "test": "jest"
  },

2. jest用のテストディレクトリを切る

例として、Symfonyデフォルトの tests ディレクトリの隣に tests-js という名前でディレクトリを切ります。

jestはデフォルトでプロジェクトルート配下の *.test.js というファイルすべてをテストファイルと見なしてくれるので、ディレクトリ名は何でもOKです。

そして、テスト対象のページのHTMLファイルを置くための tests-js/html ディレクトリも作って、Gitで全ファイルを無視するように以下の内容で .gitignore を設置しておきます。

*
!.gitignore

この時点で、ディレクトリ構成はこんな感じになっています。

$ tree -a tests-js
tests-js
└── html
    └── .gitignore

1 directory, 1 files

3. Symfonyの機能テストからページのHTMLをファイルに出力するようにする

Symfony側で当該ページの機能テストを実行した際に、そのページのレンダリング済みのHTMLを先程の tests-js/html ディレクトリに出力するようにします。

// tests/Controller/FooControllerTest.php

class FooControllerTest extends WebTestCase
{
    public function testNew()
    {
        // 既存のテスト
        // ...
        
        // jsのテストのためにHTMLをダンプ
        $crawler = $client->request('GET', '/foo/new');
        file_put_contents(__DIR__ . '/../../tests-js/html/FooControllerTest_new.html', $crawler->filter('body')->html());
    }
}

4. jestのテストコードを作成

ここまでで、Symfonyの機能テストを実行したあとなら必ず tests-js/html/FooControllerTest_new.html ファイルが存在するという状態を作ることができました。

次は、このHTMLファイルがある前提で、jestのテストを書いていきます。

// tests-js/FooControllerTest_new.test.js

/**
 * @jest-environment jsdom
 */

// body要素の内部HTMLを取得
const fs = require('fs')
const bodyHtml = fs.readFileSync('./tests-js/html/FooControllerTest_new.html')

beforeEach(() => {
  // テストコンテキストのdocument.bodyに当該ページのHTMLをセット
  document.body.innerHTML = bodyHtml

  // キャッシュを削除し、再度requireが実行されるように
  jest.resetModules()

  // HTML内のscriptタグを抽出して、ページで使われているJSファイルをすべてrequire
  const scriptTagMatcher = new RegExp('<script src="(/build/[^>]+\.js)">', 'g')
  for (const match of bodyHtml.toString().matchAll(scriptTagMatcher)) {
    require('../public' + match[1])
  }
})

// あとは普通にjQueryを使ってテスト
test('カテゴリAを選択した場合はカテゴリA用のサブカテゴリ以外はdisabledになる', () => {
  expect($('#foo_subCategory option:{カテゴリAに対応するサブカテゴリだけに絞り込む条件}').prop('disabled')).toEqual(false)
  expect($('#foo_subCategory option:{カテゴリBに対応するサブカテゴリだけに絞り込む条件}').prop('disabled')).toEqual(false)
  expect($('#foo_subCategory option:{カテゴリCに対応するサブカテゴリだけに絞り込む条件}').prop('disabled')).toEqual(false)

  $('#foo_category').val('カテゴリA')

  expect($('#foo_subCategory option:{カテゴリAに対応するサブカテゴリだけに絞り込む条件}').prop('disabled')).toEqual(false)
  expect($('#foo_subCategory option:{カテゴリBに対応するサブカテゴリだけに絞り込む条件}').prop('disabled')).toEqual(true)
  expect($('#foo_subCategory option:{カテゴリCに対応するサブカテゴリだけに絞り込む条件}').prop('disabled')).toEqual(true)
})

beforeEach() の中で、頑張って <script> タグの src からビルド後のJSファイルのパスを特定して rquire しています。

なのでこのテストは、tests-js/html/FooControllerTest_new.html が作成されたときのアセットのビルド結果が残っている状態でないと正常に動作しません。
実質的には、Symfony側の当該ページの機能テストが実行された直後にこのテストも実行される必要があります。この点には注意が必要です。

ビルド前の生ファイルをrequireできればHTMLとJSの依存関係はもう少し緩くなるのですが、Symfony側でWebpackEncoreを使っている場合だとプロダクトコードのJSファイルにESの import 文が使われていることがあって、それだと require では読み込めないのでしょうがなくこうしました。

jestでESのモジュールを読めるようにすることはできるみたいですが、ドキュメント をチラ見した感じ結構大変そうだったので今回は楽な方法を選びました🙏

なお、beforeEach() の中で毎回HTMLを初期化してJSファイルを require し直しているので、各テストケース間で副作用を気にする必要はありません👌

同じファイルに対する require はキャッシュされてそのままだと2回目には実行されない(参考)ので、jest.resetModules() によってキャッシュを削除している点に要注意です。

また、冒頭の

/**
 * @jest-environment jsdom
 */

というアノテーションは、Jestのv27以降で必要になりました。(ドキュメント

5. 実行する

作ったjestのテストは

$ yarn test
# or
$ npm run test

で実行できます。

常にPHPのテストとセットで実行できるように、composer.json にスクリプトとして登録しておくと便利です。

  # composer.json
  
  "scripts": {
      :
      :
+     "test": [
+         "bin/phpunit",
+         "npm run test"
+     ]
  },

これで、

$ composer test

でPHPとJSのテストがセットで走ります👌

まとめ

というわけで、SPAではないピュアSymfonyプロジェクトにフロントエンドのテストを組み込む方法について解説してみました。個人的には結構面白い発明だと思っています👨‍🎓✨

他にもっと賢い方法あるよ!という方がいらっしゃったら ぜひ教えてください

GitHubで編集を提案

Discussion