SPAでないSymfonyプロジェクトでjQueryなどを使ったフロントエンドの処理を機能テストする方法
前置き:例えばこんな要件ありますよね
よくある要件として、フォームに カテゴリ
と サブカテゴリ
という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プロジェクトにフロントエンドのテストを組み込む方法について解説してみました。個人的には結構面白い発明だと思っています👨🎓✨
他にもっと賢い方法あるよ!という方がいらっしゃったら ぜひ教えてください!
Discussion