今年読んだ技術書のベスト3を選べと言われたら、間違いなく『実践プロパティベーステスト』を取り上げます。
日本では過去に類似の本も出ていないし、これからもプロパティベーステストだけで1冊の本が出版される可能性も限りなく低いことを考えると、さっさと読んでおいた方がいい1冊と言えます。
記載されているサンプルコードをそのまま写経して動かすだけでも学びはあるけど、やはり何らかの変化が有った方がより学びが深まる。
ちょうどこの本に興味を持つきっかけがScala用のプロパティベーステスティングフレームワークであるScalaCheck
に興味を持ったタイミングだったこともあって、ScalaCheck
ベースでサンプルコードを書き直していきながら、調べたこととかをつらつらと書いていきます。
プロパティベーステストは、テスト対象のコードが備えるべき特性(プロパティ)の定義と、それを検証するために利用するテストデータの生成を分離することで、コードを書いた人が想定しなかったコードの不具合を特定するためのテスト手法です。
しかし、抽象的な思考が求められる手法のため、プロパティベーステスト用のテスティングフレームワークのサンプルコードだけでは、なかなか実践的なテストコードの書き方を習得するまで至るのは難しく、とにかく他人が書いたテストコードを読むか、自分で書いてみて発見していくしかなかったので、このような本の登場で実践的な書き方が早く身に付けられる道筋ができたのは素晴らしいことです。
ではさっそく始めていきます。
テスト環境の整備
まずはScalaのScalaCheckの実行環境を整備します。
ScalaCheckは、各種Scala用のテスティングフレームワークの中から呼び出して使うこともできますが、ここではScalaCheck自体をテスティングフレームワークとして使い、sbt
からtest
コマンドでテストが実行される環境を用意します。
テンプレートからのプロジェクト作成
Scalaの新規プロジェクトの作成方法はいくつか有りますが、ここではsbt new
コマンドでGiter8
形式のテンプレートから作成します。
% sbt new scala/scala3.g8 name [Scala 3 Project Template]: pbt Template applied in /path/to/./pbt % cd pbt
テンプレートから作成されたbuild.sbt
にはテスティングフレームワークとしてmunit
が指定されていますが、これをScalaCheck
に置き換えます。
- libraryDependencies += "org.scalameta" %% "munit" % "0.7.29" % Test + libraryDependencies += "org.scalacheck" %% "scalacheck" % "1.17.0" % Test
また、不要なサンプルコードのファイルを削除しておきます。
% rm src/test/scala/MySuite.scala % rm src/main/scala/Main.scala
sbt
は、最新のバージョンが使われるようにリリースされているバージョンを確認して、必要に応じてproject/build.properties
を書き換えてください。
2023年12月27日時点の最新バージョンは、1.9.8です。
最初のプロパティベーステスト
まずは、「1.4 プロパティを実行する」の例にならって必ず成功するテストを例に始めていきます。
(なお、以降のコードは全てScala 3.3以降でサポートされたFewer Braces記法で書かれいるため、従来のScalaのコードとはずいぶん見た目が異なっています)。
src/test/scala/PbtTest.scala
import org.scalacheck.Properties import org.scalacheck.Prop.forAll import org.scalacheck.Arbitrary.* object PbtTest extends Properties("Pbt Test"): property("always works") = forAll(arbitrary[AnyVal]): (a: AnyVal) => boolean(a) def boolean(v: AnyVal): Boolean = true
上記のコードをテストとしてsbt
から実行します。
$ sbt ...(省略)... sbt:pbt> test [info] compiling 1 Scala source to /path/to/pbt/target/scala-3.3.1/test-classes ... [info] + Pbt Test.always works: OK, passed 100 tests. [info] Passed: Total 1, Failed 0, Errors 0, Passed 1 [success] Total time: 3 s, completed xx xx, xxxx, xx:xx:xx xx
100回のテストに成功し、Pbt Test
というテストが成功したことが分かります。
見れば分かるレベルの内容ですが一応解説をしておくと…
Properties
を継承し、テストオブジェクトを作成Properties
への引数がそのままテスト全体の名称になります。具体的なテスト内容は、
property
で定義慣れないとちょっと引っかかる記法ですが、Mapの更新にも使われる
update
メソッドへの糖衣構文と同じです。内部実装としては、
property
という変数を経由してPropertySpecifier
というクラスのupdate
メソッド呼び出しに変換されますが、利用者側では気にする必要はありません。(Map以外で出てくると、ちょっとアレっ?と思う、Scalaの独特な記法だと思っています。)forAll
関数の引数には、ランダムに生成されたテストデータを引数として取り、Boolean型の結果を返すコードブロックを渡します標準コレクションでもおなじみの
forAll
関数は、要素の中に一つでもfalse
となるものがあれば全体をfalse
と判定します。つまり、たくさんのテストデータが自動生成された結果、一つでもテストの結果が失敗すればテスト全体が失敗した、とみなされますScalaCheckのプロパティベーステストは、以下の形式で書くことになります。
forAll(ジェネレータ): (コードブロックへの引数) => ... ... Booleanを返すコード
最後はBoolean型を返すため、
a == b
といった、テストの真偽を判定するコードを書く形式になります。ヘルパー関数として、
boolean
という関数を用意本書のサンプルに従って、ヘルパー関数を用意しました。引数として渡された変数の値に拠らず、必ず
true
を返します...結果としてテストは必ず成功しますなお、先ほどのコードでは、本書に出てきたあらゆる型のデータを任意に生成してくれる
any()
のような便利ジェネレータがScalaCheckには無かったので、任意のAnyVal
型のデータを生成するようにしていますが、本質的な意味は変わりません。ジェネレータは省略可
ScalaCheckはジェネレータは省略可となっています。続くコードブロックの引数の型から型推論可能な場合は、自動的に型に合わせたジェネレータが選ばれます。そのため、先ほどのコード例は、以下のように書き換えることができます。
property("always works") = forAll: (a: AnyVal) => boolean(a)
デフォルトでは100回のテストが実行され、このコードサンプルでは全てのテストが必ず成功します。
とりあえず、テストが通る環境までは作ることができました。