最近仕事でスナップショットテストを実施した。一定の仕様でデータを文字列で吐き出す機能があり、その出力が入力に対して安定しているかどうかを保証しておきたいのだ。その時はvitestに搭載されている機能を利用した。
ざっくり言うと、スナップショットテストとは、テストしたい結果をファイルなどの永続ストレージに保存しておき、テストごとにそれを読み取ってテスト対象と照らし合わせ、変化していないことをを確かめるものだ。
特にWeb関連の開発シーンでは、出力が安定していてほしいといったシチュエーションでスナップショットテストがよく使われる。
今回紹介するSnapshot4sは、Scala向けのスナップショットテストを行うためのライブラリ/sbtプラグインだ。3つのテストフレームワークに対応しており、ゼロコンフィグで動作する。 加えてScalaの2.12から3までのバージョンで動作し、Scala.jsにも対応している。
また、たいした分量ではないがサンプルコードをGitHubで公開しておく。
今回の面白ポイント
- インラインスナップショット機能があり、Scalaのソースコードをパースして直接書き換える
snapshot4s
Snapshot4sは2つのパーツから構成されている。sbtプラグインとテストフレームワーク用バインディングだ。使用するにはこの両方をプロジェクトに導入する必要がある。
sbtプラグイン
sbtプラグインは実際にスナップショットテストを行う際のデータやコードの書き換えを担当している。sbtプラグインを導入するにはproject/plugins.sbt
に以下の記述を追加する:
addSbtPlugin("com.siriusxm" % "sbt-snapshot4s" % "0.1.6") // バージョンは記事投稿時の最新
また、build.sbt
でこのプラグインを有効化する記述を書く:
lazy val root = project .in(file(".")) .enablePlugins(Snapshot4sPlugin) // ...
project
のチェーンにenablePlugins
をぶら下げればOKだ。
バインディング
実際にテストフレームワークからsnapshot4sを呼び出すにはバインディングが必要となる。要するにテストとスナップショットとを仲介してくれる存在だ。
執筆時点ではsnapshot4sは以下のテストフレームワークに対応している:
- ScalaTest
- MUnit
- Weaver
詳しくは公式ドキュメントを参照してほしい。
今回はサンプルで利用するフレームワークとして、一般に産業でよく用いられているScalaTestを利用する。
テストフレームワーク用のバインディングはライブラリの形で提供される。build.sbt
にライブラリ依存を追加しよう:
import snapshot4s.BuildInfo.snapshot4sVersion val scala3Version = "3.5.2" lazy val root = project .in(file(".")) .enablePlugins(Snapshot4sPlugin) .settings( name := "Snapshot4s exercise", version := "0.1.0-SNAPSHOT", scalaVersion := scala3Version, // ここにsnapshot4sのライブラリ libraryDependencies += "com.siriusxm" %% "snapshot4s-scalatest" % snapshot4sVersion % Test, // ここはScalaTest入れてるだけ libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % Test, // (ライブラリに順序はない) )
スナップショットテストを書く
さて、スナップショットテストのためにはテストされる対象が必要になる。今回はただの文字列、JSON、バイナリでスナップショットを行ってみる。
このためにまずいくつかライブラリを入れておいた:
val circeVersion = "0.14.1" // ... libraryDependencies ++= Seq( "io.circe" %% "circe-core", "io.circe" %% "circe-generic" ).map(_ % circeVersion), libraryDependencies += "org.scodec" %% "scodec-bits" % "1.2.1", libraryDependencies += "org.scodec" %% "scodec-core" % "2.3.2", // ...
CirceはJSONライブラリ、scodecはバイナリオペレーション用のライブラリだ。
文字列でスナップショットテスト
Snapshot4sでは、スナップショットの方法として2つ選ぶことができる。インラインスナップショットか、ファイルスナップショットかだ。
インラインスナップショットテスト
インラインスナップショットでは、スナップショットの生成時にsnapshot4sがテストコードをパースし、該当部分を直接書き換える(!!)。実際にやってみよう。
テストコードとして以下のようなオブジェクトを用意しておく:
object Module { def lipsum = """Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.""" }
ただのLorem Ipsumを生成するだけのコードだ。
次に、src/test/scala/MySuite.scala
に以下のようなファイルを作成する:
import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers // snapshot4sを利用するためのimport import snapshot4s.generated.* import snapshot4s.scalatest.SnapshotAssertions class MySuite extends AnyFunSpec, SnapshotAssertions, Matchers { describe("Module") { it("should emit lipsum -- inline snapshot") { assertInlineSnapshot( Module.lipsum, ??? } } }
assertInlineSnapshot
は、第一引数がスナップショット(第二引数)と等しいことを確認し、合致しなければテストを落とす。スナップショットがない最初は第二引数に???
を埋めておこう。
これで準備は整った。sbt test
を実行するとテストが落ちる:
[info] MySuite: [info] Module [info] - should emit lipsum -- inline snapshot *** FAILED *** [info] Snapshot does not exist. (MySuite.scala:11)
スナップショットがまだ存在していない状態なのでテストが落ちたのだ。スナップショットを生成してもらうには、sbt上でsnapshot4sPromote
を実行する。
sbt:Snapshot4s exercise> snapshot4sPromote [info] Patch applied to /home/windymelt/src/github.com/windymelt/snapshot4s-exercise/src/test/scala/MySuite.scala
コードに戻ると、なんとテストコードが直接書き換えられている!
class MySuite extends AnyFunSpec, SnapshotAssertions, Matchers { describe("Module") { it("should emit lipsum -- inline snapshot") { assertInlineSnapshot( Module.lipsum, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." ) } } }
これがsnapshot4sの面白いところだ。ScalaはScala自体をパースできるので、直接ソースコードの該当箇所を変形してスナップショットを埋め込んだのだ。
再度test
するとテストが通過する。
ファイルスナップショットテスト
ファイルスナップショットを使うと、ソースコードに結果を直接埋め込む代わりにファイルに文字列を保存する。
ファイルスナップショットを使うにはassertFileSnapshot
を使う。これは第一引数に比較対象のString
を、第二引数にスナップショットのファイル名を与える。
ファイルスナップショットの場合は文字列しか保存できないため、文字列以外のデータでテストしたいときは何らかの変換が必要だ。
describe("Module")
の中にもう一つテストを加えよう:
class MySuite extends AnyFunSpec, SnapshotAssertions, Matchers { describe("Module") { it("should emit lipsum -- inline snapshot") { // ... } it("should emit lipsum -- file snapshot") { assertFileSnapshot(Module.lipsum, "lipsum.txt") } } }
これでまた同様にsnapshot4sPromote
してtest
するとテストが通るはずだ。
スナップショットはsrc/test/resources/snapshot/
以下に保存されているので確認してみよう。
応用: JSONでスナップショットテスト
Circeを使ってJSONを生成するコードがあるとしよう。Main.scala
に以下のようなオブジェクトを作ろう:
object JsonModule { import io.circe.generic.auto.{*, given} import io.circe.syntax.{*, given} import io.circe.{*, given} case class JsonResult(fact: BigDecimal, power: BigDecimal) private def fact(n: BigDecimal): BigDecimal = n match { case n if n <= 1 => 1 case n => n * fact(n - 1) } private def power(n: BigDecimal) = n.pow(2) def makeObject(n: Int) = JsonResult(fact(n), power(n)).asJson }
makeObject
を呼び出すと、引数の階乗と二乗をJSONオブジェクトに入れて返却する。これをテストしよう。
class MySuite extends AnyFunSpec, SnapshotAssertions, Matchers { describe("Module") { // ... } describe("JsonModule") { it("should return JSON -- inline snapshot") { assertInlineSnapshot( JsonModule.makeObject(10).toString, """{ "fact" : 3628800, "power" : 100 }""" ) } it("should return JSON -- file snapshot") { assertFileSnapshot(JsonModule.makeObject(10).toString, "json.json") } }
テストコードはこのような形になる。JSONはすぐ文字列にできるので、基本的に文字列にしてスナップショットする。
応用: scodecでスナップショットテスト
さて、直接ソースコード上の文字列に変換できないバイナリはどうしよう。Main.scala
に以下のようなオブジェクトがあるとしよう:
object BinaryModule { import scodec.Codec import scodec.bits.hex import scodec.bits.ByteVector val StringBOMCodec = scodec.codecs.utf8 def withBOM(s: String): ByteVector = hex"EF BB BF" ++ scodec.codecs.utf8.encode(s).require.bytes }
withBOM
を呼び出すと、文字列をUTF-8でエンコーディングし、Byte Order Markをつけて返却する。
この関数はバイナリ(ByteVector
)を返すので直接コード上にバイナリを書くことができない。snapshot4sでは、あらゆるオブジェクトをインラインスナップショットテスト可能にするために、「あるオブジェクトを受け取ってソースコードを返す関数」を定義できるようにしている。
この型はsnapshot4s.Repr
として表現され、given
を利用することで自前で定義できる:
import scodec.bits.ByteVector import snapshot4s.Repr class MySuite extends AnyFunSpec, SnapshotAssertions, Matchers { // ... describe("BinaryModule") { // ByteVectorに対するReprを与える given Repr[ByteVector] = (bv: ByteVector) => s"""{ import scodec.bits.hex; hex"${bv.toHex}" }""" it("should return UTF-8 w/BOM -- inline snapshot") { assertInlineSnapshot( BinaryModule.withBOM("windymelt"), ??? ) } } }
さて、これを実行すると定義した通りにソースコードが挿入される(ちなみにフォーマッタの影響は受けず、問題なくテストは通る):
import scodec.bits.ByteVector import snapshot4s.Repr class MySuite extends AnyFunSpec, SnapshotAssertions, Matchers { // ... describe("BinaryModule") { given Repr[ByteVector] = (bv: ByteVector) => s"""{ import scodec.bits.hex; hex"${bv.toHex}" }""" it("should return UTF-8 w/BOM -- inline snapshot") { assertInlineSnapshot( BinaryModule.withBOM("windymelt"), { import scodec.bits.hex hex"efbbbf77696e64796d656c74" } ) } } }
確認しやすい形式に変換するようにしておくと便利だろう。
ちなみにassertFileSnapshot
は文字列しか受け付けないため、ダンプする関数を使って表現しよう:
import scodec.bits.ByteVector import snapshot4s.Repr class MySuite extends AnyFunSpec, SnapshotAssertions, Matchers { // ... describe("BinaryModule") { it("should return UTF-8 w/BOM -- inline snapshot") { // ... } it("should return UTF-8 w/BOM -- file snapshot") { assertFileSnapshot( BinaryModule.withBOM("windymelt").toHexDump, "binary.txt" ) } } }
まとめ
Scalaのスナップショットライブラリであるsnapshot4sについて概略を説明した。概略といっても機能はほぼこれだけなので説明することはもうない。シンプルながら使い勝手の良いライブラリで、自分も使い方に迷うことはなかった。