スナップショットテスト
スナップショットテストはご存知でしょうか? スナップショットテストはそうなって欲しい結果を何らかの形で保存しておき、それと実際の結果のdiffを取ることでテストをする手法です。何らかの形とは、画像・HTML・JSXだったりします。詳しくは以下のリンクを参考にしてください。
なぜスナップショットテストをしたいか?
なぜスナップショットテストをしたいのでしょうか? それはプログラム変更における影響が起きていないか、リグレッションテストを行うより早い開発段階でデグレーションを発見するためです。例えば、あるロジック対する単体テストを書いておきロジックの妥当性を担保していたとしても、そのロジックを用いて画面の描画を行う場合、その画面がどういう結果をもたらすかは単体テストは担保してくれません。そのため多くの場合、コストの高いE2Eテストや人間が手動でそれを発見しなければなりません。スナップショットテストはリグレッションテストの割合を軽減してくれます。
Humbleオブジェクトパターン
一つの解決策としてはクリーンアーキテクチャの本にも記載されている、Humbleオブジェクトパターンなどを用いる方法です。Humbleオブジェクトパターンは、GUIにおけるViewやDBなどの出力先への境界は単体テストが難しいという前提で、なるべく単体テストに吸収出来るように寄せるというシンプルながら強力な手法です。
例えばある整数をviewでは文字列に変換したいとしましょう。ここでは正しく文字列に変換されたかどうかを調べる術は、実際にアプリケーションを起動して確かめなければなりません。
type alias Model =
{ x : Int, y : Int }
view : Model -> Html Msg
view { x, y } =
div []
[ h1 [] [ text <| String.fromInt x ]
, h2 [] [ text <| String.fromInt y ]
]
それを防ぐために、ViewModelというviewに使われるだけ(表示用)のモデルを用意することで、このViewModelに変換するtoViewModel(Presenter)の妥当性を調べることができる。それによりviewにまつわるロジックの担保をおこなうと言うのがHumbleオブジェクトパターンです。
type alias Model =
{ x : Int, y : Int }
type alias ViewModel =
{ x: String, y: String }
toViewModel : Model -> ViewModel
toViewModel { x, y } =
{ x = String.fromInt x, y = String.fromInt y }
view : Model -> Html Msg
view model =
let
{ x, y } = toViewModel model
in
div []
[ h1 [] [ text x ]
, h2 [] [ text y ]
]
非常にシンプルで強力なパターンですが、本来作る必要がなかったViewModelやPresenterを用意しなくてはならないため、開発コストが掛かってしまい担保したいロジックに対してコストが掛かりすぎると言うのが問題として挙げられます。Elmは型に対して厳密なため、特に上のような単純過ぎるロジックの場合、行う必要がないと判断されてしまうケースがほとんどです。そのため、このパターンを利用するかどうかの選択をすること自体が、大きなコストだと言えます。
Elmにおけるスナップショットテスト
私自身、Humbleオブジェクトパターンをコストと感じつつも品質を担保するには必要なコストだと思い活用し続けていました。しかしちょうど一ヶ月ほど前、Elmの公式テストフレームワークである、elm-explorations/testに、Htmlテストが統合されました!これは見事にそのコストを解消してくれました!一言で言うとHtmlテストは、単体テストレベルでスナップショットテストを高速かつ型安全におこなってくれるものでした!
Htmlテストは、以下の3つのモジュールを組み合わせて使います。本記事では順番にサンプルを載せながら説明をしていきたいと思います。
-
Query
- Htmlを絞り込んだり、検証(assert)をおこなう
-
Selector
- タグや属性などの絞り込みに使う要素(CSSセレクタのようなもの)
-
Event
- Htmlのイベントをシミュレーションして検証をおこなう
Test.Html.Query, Selector
ここではHtmlの比較をおこなうために使うQueryとSelectorのモジュールについて説明をしていきます。Queryで一番大事な概念は、SingleとMultipleになります。非常に簡単な概念で、単一のタグであればSingle, 複数のタグであればMultipleになります。
Elmのviewの型であるHtml msg
型からQuery.fromHtml
関数によってSingle msg
型に変換されます。Htmlテストではかならず、単一のタグからスタートすることになります。
-- <h1>foo</h1>
h1 [] [ text "foo" ] |> Query.fromHtml
Multipleになるケースとして、Singleから条件に一致するすべての要素を取るfindAllか、ある要素の子要素を取るchildren関数を使います。
{--|
<li>aaa</li>
<li>bbb</li>
<li>ccc</li>
--}
ul []
[ li [] [ text "aaa" ]
, li [] [ text "bbb" ]
, li [] [ text "ccc" ]
]
|> Query.fromHtml
|> Query.children [ Selector.tag "ul" ]
逆にSingleからMultipleになるケースとして、複数の要素の先頭を取るfirstと任意のインデックスの要素を取るindexがあります。
{--|
<li>aaa</li>
--}
ul []
[ li [] [ text "aaa" ]
, li [] [ text "bbb" ]
, li [] [ text "ccc" ]
]
|> Query.fromHtml
|> Query.children [ Selector.tag "ul" ]
|> Query.first
{--|
<li>bbb</li>
--}
ul []
[ li [] [ text "aaa" ]
, li [] [ text "bbb" ]
, li [] [ text "ccc" ]
]
|> Query.fromHtml
|> Query.children [ Selector.tag "ul" ]
|> Query.index 1
assert
実際にテストを書いてみましょう。count
の数だけ文字列をリピートする操作をリストの数だけliにするviewのロジックがあったとしましょう。(説明がわからなくてもテストを見れば一目瞭然です!)
type alias RepeatableStr =
{ count : Int, str : String }
type alias Model =
{ repeatList : List RepeatableStr }
view : Model -> Html Msg
view { repeatList } =
ul [] <|
List.map
(\{ count, str } ->
li [] [ text <| String.repeat count str ]
)
repeatList
テストでは与えられたrepeatListが生成したviewに対して、以下のようなList (Html msg)
(liのリスト)が含まれていることをテスト出来ます。一般的な文字列を用いてHtml(やJSX)と比較するスナップショットと違い、Elmのコンパイラによって検査されているため、間違った記法はそもそもコンパイルエラーになってしまうことが特徴です。
suite : Test
suite =
describe "The Main module"
[ describe "view"
[ test "repeatList" <|
\_ ->
let
model =
{ repeatList =
[ RepeatableStr 1 "a"
, RepeatableStr 2 "b"
, RepeatableStr 3 "c"
, RepeatableStr 4 "d"
]
}
in
view model
|> Query.fromHtml
|> Query.contains
[ li [] [ text "a" ]
, li [] [ text "bb" ]
, li [] [ text "ccc" ]
, li [] [ text "dddd" ]
]
]
]
Html msg
でわざわざ全体を比較する必要が無い場合がほとんどです。そのようなときには、hasやhasNotを用いて検証します。
test "The list has both the classes 'items' and 'active'" <|
\() ->
div []
[ ul [ class "items active" ]
[ li [] [ text "first item" ]
, li [] [ text "second item" ]
, li [] [ text "third item" ]
]
]
|> Query.fromHtml
|> Query.find [ tag "ul" ]
|> Query.has [ tag "ul", classes [ "items", "active" ] ]
test "The div element has no progress-bar class" <|
\() ->
div [ Attributes.class "button" ] []
|> Query.fromHtml
|> Query.find [ Selector.tag "div" ]
|> Query.hasNot [ Selector.tag "div", Selector.class "progress-bar" ]
テストが通らなかったケースはどうなるのか見てみましょう。例えば以下の検証で、<li>z</li>
を期待してみましょう。
test "repeatList" <|
\_ ->
let
model =
{ repeatList =
[ RepeatableStr 1 "a"
, RepeatableStr 2 "b"
, RepeatableStr 3 "c"
, RepeatableStr 4 "d"
]
}
in
view model
|> Query.fromHtml
|> Query.has
[ Selector.tag "li", Selector.text "z" ]
]
すると、Query.fromHtml
の結果やどう比較使用して、どう失敗したかを教えてくれます。さらに実行時間に注目してください。非常に高速にテストが終了していることがわかります。これは実際にブラウザを立ち上げることなく、仮想DOMによる純粋なロジックをしているため単体テストと何も変わらないためです。これは非常に強力で柔軟です。ブラウザを用いたテストは不安定で低速です。本番環境に近いという点では重要ですが、ロジックのテストを担保するために不安定なテストでは、保守が難しく捨てられてしまいがちです。本番環境に近い状態でのテストはE2Eが担保するため、このような表示ロジックを行うテストでは高速で安定して回せることのほうが重要だと言えます。
↓ Tests
↓ The Main module
↓ view
✗ repeatList
▼ Query.fromHtml
<ul>
<li>
a
</li>
<li>
bb
</li>
<li>
ccc
</li>
<li>
dddd
</li>
</ul>
▼ Query.has [ tag "li", text "z" ]
✓ has tag "li"
✗ has text "z"
TEST RUN FAILED
Duration: 178 ms
Passed: 0
Failed: 1
他にも今回は紹介しませんが、Multipleに対して数を比較したり複数の検証を掛ける関数が存在します。SingleとMultipleの違いさえ抑えておけば後は型が導いてくれます。非常に単純なのでマスターしましょう。
Event
最後にEventの検知について簡単に紹介します。Htmlの検証について仮想DOMを用いた手法を用いて検証していることを紹介しました。Eventも例外ではありません。とても単純です。生成したSingleに対して、simulateでシミュレートしたイベントを記述してあげるだけです。イベントは何を発行するでしょうか?そうMsgです。expect関数は、実装モジュールで定義しているMsgが発行されているかを検証します。
import Test.Html.Event as Event
type Msg
= Change String
test "Input produces expected Msg" <|
\() ->
Html.input [ onInput Change ] [ ]
|> Query.fromHtml
|> Event.simulate (Event.input "cats")
|> Event.expect (Change "cats")
検証はこれだけで良いのでしょうか?Msgが発行されていることが保証されているだけで、正しくviewの値が書き換えられているかどうかを確認したくなります。しかし、The Elm Architectureにより、update関数において、すべてのMsgが捌かれていることが保証されています!(なんて素晴らしいのでしょうか!) updateのMsgの分岐で複雑なロジックがあれば別途切り出して単体テストをおこなえば良いでしょうし、それによって書き換えたロジックが正しいviewが生成されているかどうかは、さきほど説明したHtmlテストをおこなえば良いでしょう。ロジックの途中で非同期APIを利用したり乱数などが生成される場合には、Cmdという素晴らしい仕組みが安全性を担保してくれます。システムとして正しく動いているかどうかを検証したくなったタイミングでE2Eなどのリグレッションテストをおこないますが、今までよりかなり比重は小さくなっていると思います。
まとめ
Elmにおけるスナップショットテストを紹介しました。これは単なるElmにおけるスナップショット手法というわけではなく、単体テストと同じ粒度で高速かつ安全に行えるという点で非常に使い勝手の良いテスト手法を提供していることとなります。そのような仕組みを利用することで、テストそのものの敷居が低くなり、安全で安心なアプリケーションづくりが可能になります。是非Elmをお試しください!