Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

More than 5 years have passed since last update.

ElmでView(VDOM)のsnapshot testをいい感じにやる妄想

Posted at

実運用はしてない。ツールも作ってないし、そもそも今のAPIだとできないことがわかったので妄想。

背景

Elmで超高速 型安全スナップショットテストのススメ - Qiita

この記事に対して、

私の思いは、

ということで妄想する。

"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を自動生成する
  • 開発体験を損なわないワークフローとセットでやる

いい感じじゃないでしょうか。

0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?