ふとスナップショットテストってなんだろう、どういう場面で向いていて、どういう場面には向いていないんだろうと考える機会があって色々調べてました。丁寧な記事にしようとしたのですが、上手くまとまらなくて挫折してしまった… とはいえこのまま手元に置き続けておくのも勿体ないので、下書き段階のものを公開して供養します。
スナップショットテストとは
スナップショットテストとは、あるプログラムの出力を以前の出力と比較し、両者に差分があるかをテストする手法のことです。予め以前のバージョンのプログラムの出力 (スナップショット) のどこかに保存しておき、新しいバージョンのプログラムの出力と比較し、差分があったら fail させます。これにより、プログラムの出力内容が予期せぬうちに変わってしまっていた場合に気づくことができます。
例: React コンポーネントのテストへの適用
代表的な利用例が Jest を使った React コンポーネントのテストです。
// Jestのドキュメントから引用 // from: https://deltice.github.io/jest/docs/ja/snapshot-testing.html import React from 'react'; import Link from '../Link.react'; import renderer from 'react-test-renderer'; it('renders correctly', () => { const tree = renderer.create(<Link page="http://www.facebook.com">Facebook</Link>).toJSON(); expect(tree).toMatchSnapshot(); });
この例では、<Link>
コンポーネントの render 結果をシリアライズし (const tree = renderer.create(...)
)、その結果をスナップショットとして保存・比較するよう Jest に指示しています (expect(tree).toMatchSnapshot()
)。このテストを初めて実行すると、ファイルシステムに以下のようなスナップショットファイルが作成されます。
exports[`renders correctly 1`] = ` <a className="normal" href="http://www.facebook.com" onMouseEnter={[Function]} onMouseLeave={[Function]} > Facebook </a> `;
2 回目以降テストを実行する際は、このスナップショットファイルとの差分が計算されます。もし<Link>
コンポーネントの実装にミスがあり、予期せぬうちに render 結果が変わってしまった場合はスナップショットに差分が発生し、テストが fail するようになります。
スナップショットテストに対する一般的な理解
これは個人的な意見ですが、スナップショットテストが React コンポーネント以外にどんな場面へ適用可能なのか、というのはあまり知られていないように思います (僕も含めて)。スナップショットテストは非常に優れた性質を持っており、様々な場面へ適用できる有用なテスト手法です。しかしスナップショットテストがどういった特性を持っていて、どういう向き不向きがあるのか、といったことはほとんど知られていません。ここぞという時に使えるよう、使い時くらいは分かっていると便利ですよね。
どういう場面では向いているか
という訳でここからは、スナップショットテストがどういった場面で有用なのかが分かるよう、スナップショットテストの向き不向きについて考えてみます。ざっとスナップショットテストが向いている場面について書き出すと、こんな感じでしょうか。
- 出力に差分が出たことを検知したい時
- 差分があった時に fail して警告ほしい、どういう差分が出たのか教えてほしい、みたいなケースを想定
- 有意な出力を出せる時
- もうちょっと噛み砕くと、出力にテストしたいことが埋め込まれていて、かつ人間の目に読みやすい形になっている時
- 例えばコンパイラが出力するコンパイルエラーのメッセージにて、エラーの発生行数が期待通りに印字されているかをテストしたい、みたいなケースを考えます
- この場合はターミナルに出力されたエラーメッセージに発生行数が
error: index.ts:10:3
みたいな形式で埋め込まれているので、ターミナルへ出力されたテキストををそのままスナップショットとして取れれば良いです - 逆にエラーメッセージが何件出たかをこれでテストするのは難しいです
- 沢山エラーメッセージが出ると巨大なスナップショットが生成されてしまいます
- 人間の目で数えることになって大変
- この場合はターミナルに出力されたエラーメッセージに発生行数が
- 素朴に
expect(typeCheck(program).errors.length).toEqual(100)
のような assertion を書くくらいで十分なはず
- テスト対象がテスタブルなインターフェイスを公開してくれていない時
- Virtual DOM など
<TodoList items={items} />
が render された結果に items がitems.length
個含まれているかをテストしたい、みたいなケースを想定- jsdom をインストールして DOM API を使ってテストしようと思えばできなくはないです (
expect(document.querySelectorAll('.item').length).toEqual(5);
) - けど Virtual DOM をそのままシリアライズしてそのスナップショットを取ったほうがずっと簡単にテストできます
- スナップショットテストであれば出力さえ取れればテストできるので、テスタブルなインターフェイスが無くても何とかなる可能性があります
- Virtual DOM など
- テストがない朽ちたコードを触る時
- 触ろうにもテストが無いのでまずはテストを追加したい / けどテストを追加しようにもユニットテストを差し込めるようなインターフェイスになっていない…みたいな状況を想像してください
- こういう時スナップショットテストが役に立ちます
- ロガーを用意して、そのロガーを使ってアプリケーションのあらゆる場所でデバッグ出力をして、その出力をスナップショットテストする、というもの
- 何かの拍子でログ出力の順番が変わったり、出力の内容が変わった時に気づくことができます
- アプリケーションコードにはログ出力するコードを差し込むだけで、インターフェイスを全く変更する必要が無いので安全かつ簡単に導入できます
- Golden Master と呼ばれている手法らしいです
どういう場面では向いていないか
逆にどういう場面では向いていないかというと…
- 上記のメリットを殆ど享受できない場合、スナップショットテストは向いてません
- そんなの当たり前では、と思うかもしれないけど、この観点は意外と重要だったりします
- というのもこういうケースの場合、他のテスト手法を採用したほうが適切な場合が多いため
- 例えば 2 つの整数を受け取ってその和を返す
add
関数のテストがしたくて、以下のようなテストを書いたとします - さて何が問題でしょうか
- まず答えが何になるべきなのかスナップショットテストからは分かりません
- パッと見では
3
になるべきですが、正確なことはスナップショットファイルを見に行かなければ分かりません
- パッと見では
- また、スナップショットテストにしてしまうと、正解のデータを書き換えるには一度テストを走らせる必要があります
expect(add(1, 2)).toEqual(3)
ならexpect(add(1, 3)).toEqual(4)
と書き換えるだけで良いはずでした
- ...というようにテストとしては動いているけど、なんか過剰な感じがしてしまう
- こういう場合は素朴に数値として assert したほうが、テストが落ちた時にデバッグしやすいし、テストが読みやすいし、スナップショットファイルも要らなくなってシンプルになります
- 小学校レベルの知識で解けるのであれば、大学レベルの知識を使って解くのではなくてそっちを使って解きましょう、みたいな
- ゴブリンを倒すのであれば大量に MP を消費する大魔法を使わずとも、錆びた剣を振りかざすので十分なはず
- 乱用することなく、状況に応じて道具を使い分けるという話
- 出力の差分が巨大になる場合、スナップショットテストは向いてません
- 例: 2 京行の差分が出る時
- 例: Visual Regression Test で、5GB の画像が出力される時
- テストフレームワークが処理するのに時間が掛かるし、最悪の場合クラッシュします
- 出力の差分を見て、その妥当性を判断するのが困難な場合、スナップショットテストは向いていません
- 例: 5 万行の差分が出るテスト
- 1 分で 50 行見たとしても見終わるのに 1000 分掛かります
- 9 時に出社して差分を見始めて、お昼過ぎてもまだまだで、定時過ぎてもまだまだで、終電が無くなった頃にようやく見終わる、みたいなスケール
- 機械より人間が頑張るスタイル
- ただし差分の内容を見ずに変わったことだけ分かれば良い場合や、最初の 100 行だけ見て妥当ですねと判断できる場合であればこの限りではないです
- 出力の変化が激しい場合
- 例: 現在時刻を表示するクロックコンポーネントのテスト
- テストを実行する時刻によって出力が変わって毎回テストが fail します
- 毎回人の目でチェックしないといけません
- 機械より人間が頑張るスタイル 2
- ただし何らかの手段で出力を固定できれば、問題を回避できる場合があります
- クロックコンポーネントの例では時間を司る API をモックして時刻を固定すれば良い
- Visual Regression Test なら変化する部分を黒塗りしてから比較すれば良い
- このような出力の固定化が困難な場合はスナップショットテスト以外のテスト手法を検討しましょう
- 例: アプリケーションのソースコードそのもののスナップショットテスト
- ソースコードを 1 文字書き換えたらテストが fail する、みたいな
- 誰もそんなテスト書かないと思いますが…
- 良い具体例が思いつかなかった!本当はちょっとどこかを弄ったら毎回 fail するのは良くないよね、みたいなことを言いたかった。
- 例: 現在時刻を表示するクロックコンポーネントのテスト
事例
今までで見つけた利用例などを書いてみます。
- AWS CDK が出力する Cloud Formation のテンプレートのテスト
- 生成される Cloud Formation のテンプレートが変わっていないこと == プロビジョニングされるインフラストラクチャが変わらないことを保証できるというもの
- 公式ドキュメントでも紹介されています
- TypeScript コンパイラが出力するメッセージのテスト
- TypeScript コンパイラでは、コンパイラがコンパイル時に出力するメッセージをスナップショットとして取り、期待通りのメッセージが表示されるかをテストしています
- https://github.com/microsoft/TypeScript/blob/2428ade1a91248e847f3e1561e31a9426650efee/tests/baselines/reference/incrementAndDecrement.errors.txt
- https://github.com/microsoft/TypeScript/blob/6ed344f2c8b66935ace4a2cc78efd7618840a248/tests/baselines/reference/ES5for-of32.types
- eslint formatter のテスト
その他使えそうなところ
以上の情報を元に他にどういう場面で使えそうか、というのを考えてみます。
- nginx が返すレスポンスのテスト
- ある URL にリクエストを飛ばした時に、期待されるヘッダや body から構成されるレスポンスが返ってくるかをテストしたい、みたいな
- ちゃんと期待するヘッダが付いてるレスポンスが返ってくるかなどをテストできるはず