実運用はしてない。ツールも作ってないし、そもそも今のAPIだとできないことがわかったので妄想。
背景
Elmで超高速 型安全スナップショットテストのススメ - Qiita
この記事に対して、
Html形式で宣言するスナップショットテストって、「DOM構造を変えて改善したけど見た目は不変」なパッチでテストが通らないので画像diffに比べると煩雑で、関連して実装側とテスト側で似たような記述が必要なので二重管理になって筋悪なイメージなんですが、この辺どうかんがえるべきですか?
— Gadagarr (@gada_twt) 2018年12月10日
僕もクラス名を変えただけのようなリファクタリングも引っかかるので基本的な丸ごとHTMLを比較するのは筋悪だと思います。
— ABAB↑↓BA (@ababupdownba) 2018年12月10日
記事だと hasやhasNot 載せてませんが要素の数をカウントするcountで 内容や根本的な要素の数だけを担保すべきだと暗に紹介したつもりです。
で説明になっているでしょうか?
印象としては、
— Gadagarr (@gada_twt) 2018年12月10日
- countによる担保は、背後のデータ構造に対する単体テストで得られる情報からの増分が乏しく見える。Htmlテスト持ち出すまでもない?
- hasなどによる"最低限のテスト"も同様、維持コストは下がるが得られる情報・担保できる性質も減る
俺的snapshotテストのあり方を別で主張したい
私の思いは、
具体的に言うと"snapshot"という部分をもうちょっと活かしつつ、自動化による恩恵を最大限受けられる運用法があるはずに見える
— Gadagarr (@gada_twt) 2018年12月10日
ということで妄想する。
"Snapshot Testing"
ABさんの記事にあるJest docのリンクを読むと、
スナップショットのテストはUI が予期せず変更されていないかを確かめるのに非常に有用なツールです。
モバイルアプリでの典型的なスナップショットのテストケースでは、UIコンポーネントをレンダリングし、スクリーンショットを撮り、テストと一緒に保管している参照イメージと比較します。 2つの画像が一致しない場合テストは失敗します: 予期されない変更があったか、新しいバージョンのUIコンポーネントに更新される必要があるかのどちらかです。
強調筆者。強調部分の主張する「意図」と「検出されるべき内容」には同意する。これをどうElmでやるか。
まず3点確認する:
- スクリーンショットベースのスナップショットテストも可能だが、puppeteerなどを必要とするので高コスト(計算機リソース的に、及び時間的に)であり、「回転率」が悪い(手元開発環境でガンガン回すことは、不可能ではないが、ちょっと重い)。どちらかというとやるとしてもCIでやらせたい印象
- ABさんの記事で述べられているように、ElmのpureなVDOMを対象としてスナップショットするようにすれば、高速・省リソースで、回転率が良い。なんなら手元環境で
--watch
とかでも回せそうだ - できるだけ「スナップショット」的にやりたい。具体的なテスト内容を自分で記述しなければならないとなると、単に「renderしてSSを撮るだけでテストケース生成」というスナップショットテストの目指す使用感とは遠くなる。維持コストが上がり、負債化・形骸化につながる
この前提で、どう立ち向かうか。
VDOM diffを用いたsnapshot test
最新のelm-explorations/test
の持つHtml testは、噛み砕いて言えば、本来ユーザが内部をinspectすることができないVDOMデータ(Html msg
, 究極的にはVirtualDom.Node msg
)のdiffをとってくれるということである。
単純に考えれば、これを利用して、VDOMのdiffに基づくsnapshot testができるはずだ。VDOMは単なるツリーデータ構造に過ぎない。単なるデータ同士の比較なので、スクリーンショット撮影のためのheadless browserなどは不要で、生成関数は純粋なので参照点とテストデータを固定すれば参照データは一意に定まる。これをキャッシュして比較対象データとすればよい。
画像ベースではないので本当に見た目が変わっていないかはわからない。が、diffという形で全体の中の細かい差異が炙り出されれば、その後の回帰テストの対象範囲を絞り込むことができる。
誤算
……と思ったのだが、このようなことができそうだった唯一の現状のAPIであるQuery.contains
はdescendantを再帰的に探しはするものの、全体としての構造の差分をdiffで表示するようなAPIではなかった。(descendantが見つからなければ単にfailし、結果はrenderしたHtml文字列がactual/expected両方表示されてしまうので、細かな差異が簡単にわからないし、列挙されない)
いきなり頓挫してしまったが、ここで諦めず実現方法を考えておく。
Query
の各種expectationの実装には、内部構造としてInternal.ElmHtml.InternalTypes.ElmHtml
が使われており、これは比較的単純なHtmlノードのツリー表現になっている。testコード内でQuery.fromHtml
などを呼ぶことで生成される内部的なデータはこれになる。
Query.contains
は最終的にはDescendant.isDescendant
という非公開APIでこのElmHtml
データの探索を行っている。
したがってこのElmHtml
に対してよりfine-grainedなdiffingを行う実装を与え、これをQuery.diff
のようなAPIから提供するようにすれば、比較的大きな単位のHtmlをまるごと比較し、diffを検出するというようなテストが可能になるはずだ。
テスト結果としては、以下のようなイメージで対象となるVDOM全体のうち、diffが見つかった部分にドリルダウンするためのselectorと、見つかったdiffの視覚的な表現があると望ましい。これはCIなら下流ジョブなどで利用できる余地がある。
x VDOM diff found at `div[class="foo"] > div > div:nth-child(4) > div > a:nth-child(1)` !!
Expect: <a href="#">I am an anchor</a>
Actual: <a class="anchor" href="#">I am an anchor</a>
自動テストケース生成と実行
Diffingができる前提であれば(できないので妄想なのだが)、自動でテストケースを生成できるはずである。すなわち、
- topic branchの分岐点、あるいはmaster edgeなど適当な参照点を定める
- テスト対象とするview関数と、使用するテストデータの組み合わせを列挙する。これがsnapshot testの設定項目となる
- 1つのview関数につき、複数パターンのテストデータを用意して良い
- テスト対象は
Html msg
を返す関数であれば何でも良いので、どんな粒度でもテストできる - rootのview関数と、Model全体のダミーデータを使うことも不可能ではない。最も網羅的になる代わりに、実行時間は長くなるだろう
- ここにFuzzingを導入することもできそうだ
- そして概ね以下のようなことを行うスクリプトを用意する(対象ディレクトリは
elm.json
を読んでよしなに決める。以下はsrc
ディレクトリにソースがある場合):
$ git checkout <reference>
$ mkdir -p ./<reference>/src
$ cp -R src ./<reference>/src
(topic branchに戻ってきた際にmodule名が衝突しないよう、./<reference>/src内のファイル名とmodule名をうまく変換する)
$ git checkout <topic branch>
(elm.jsonのsource-directoriesに./<reference>/srcを追加する)
- これで、テスト対象のview関数が2バージョン、1つは
./src/
から、もう1つは./<reference>/src
から変換されたmodule経由で、それぞれ参照できることになる - あとは、テスト対象関数の両方のバージョンに指定されたテストデータを投入し、両者の結果を
Query.diff
で比較するようなテストケースが機械的に書けるので、こちらもスクリプト化する。何らかテンプレートエンジンを使ってもいいし、ベタにJSやshellscriptなどで書いても良い - 機械生成されたテストケースを実行して、もしdiffが見つかるようなケースがあれば、関数名・投入されたデータ・見つかった詳細なdiffが手に入り、「予測されないUIの変更」を検知できることになる
- 最後に
./<reference>/
ディレクトリ、自動生成されたテストファイルを消し、elm.json
を戻す
上記例ではスクリプトで毎回参照点のコードに基づく出力(これこそが"snapshot"にあたる)を得るための努力がなされることになるが、実際には初回生成以降キャッシュされるような枠組みを考えることになるだろう。その場合、master更新などの適当な契機でsnapshotの更新を行っていくことになる。CI環境ならpushとセットで、ローカル環境でもcommit hookなどでできそうだ。
フィードバック
Viewのテストをすると問題になるのが、上のツイートでも触れているように、リファクタリングなどによる意図した変更であってもテストとしては失敗となってしまうことで、参照データ側の更新が手動だったり高コストだったりすると、開発体験を損ないがちで、形骸化への道につながることだと思う。
今提案しているVDOM-based snapshot testは、対象関数とテストデータの指定という設定作業はあるものの、その後のケース生成は自動、実行はかなり安価・高速で、検出できる内容はリッチであるはず。Snapshot(参照データ)の更新も容易なので、開発体験への悪影響はかなり少なくなると期待できる。
さらに、これは私見だが、以下のようなワークフローでフィードバックを利用していけば更に良さそうだ:
- Diffの検出を"fail"というよりは、"warning"的に捉え、確認作業に組み込む
- CIで実行させている場合は、PullRequestに対してbot commentして通知する形をとる
- その場合、botに対するreactionを可能にしておく:
- 変更が意図したものであれば、approveする。Approveされたdiffの対象関数・テストデータの組み合わせについては、マージ時にCI環境のsnapshot更新が行われる
- 変更が意図したものでなければ、開発者はdiffがなくなるよう修正を行い、再度snapshot testを要求する(あるいは自動で実行される)
- 全てのdiffがapproveされるか、あるいはなくなるまで繰り返す
- (最終的にapproveされたdiffが存在しているならば、対象となるnodeのselectorが導出できているはず。CIの下流で、このselectorに対するheadless browserを用いた画像diff取得が自動で行われて確認できるようになってれば最高)
これ的なワークフロー処理はGitHubなら最近はいろいろAPIが出てきてるので、commentとは別の枠組みで作れるかも
おまけとして書いておくと、大規模なUI改変を行う場合。このときはそもそも多くのdiffが出ることを織り込み済みのはずで、
- どうせtest対象view関数なども変わってくるので、以前の設定を無理に維持しようとはしない。Test自体を一旦無効化しておいても良い
- 改変後のUIが固まってきたところで、徐々に設定を更新し、Snapshotを再生成して、CIに再導入する
「設定を書くだけで、あとは自動」レベルに簡素化できていれば、このような無理のない運用ができるはず。
おわり
というわけで、
- VDOM-basedで高速にやる
- 純粋なview関数からSnapshotを自動生成する
- 開発体験を損なわないワークフローとセットでやる
いい感じじゃないでしょうか。