Magnolia Tech

いつもコードのことばかり考えている人のために。

『実践プロパティベーステスト』の例題をScalaで解いていく その1

今年読んだ技術書のベスト3を選べと言われたら、間違いなく『実践プロパティベーステスト』を取り上げます。

日本では過去に類似の本も出ていないし、これからもプロパティベーステストだけで1冊の本が出版される可能性も限りなく低いことを考えると、さっさと読んでおいた方がいい1冊と言えます。

記載されているサンプルコードをそのまま写経して動かすだけでも学びはあるけど、やはり何らかの変化が有った方がより学びが深まる。

ちょうどこの本に興味を持つきっかけがScala用のプロパティベーステスティングフレームワークであるScalaCheckに興味を持ったタイミングだったこともあって、ScalaCheckベースでサンプルコードを書き直していきながら、調べたこととかをつらつらと書いていきます。

scalacheck.org


プロパティベーステストは、テスト対象のコードが備えるべき特性(プロパティ)の定義と、それを検証するために利用するテストデータの生成を分離することで、コードを書いた人が想定しなかったコードの不具合を特定するためのテスト手法です。

しかし、抽象的な思考が求められる手法のため、プロパティベーステスト用のテスティングフレームワークのサンプルコードだけでは、なかなか実践的なテストコードの書き方を習得するまで至るのは難しく、とにかく他人が書いたテストコードを読むか、自分で書いてみて発見していくしかなかったので、このような本の登場で実践的な書き方が早く身に付けられる道筋ができたのは素晴らしいことです。

ではさっそく始めていきます。


テスト環境の整備

まずは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を書き換えてください。

Releases · sbt/sbt · GitHub

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というテストが成功したことが分かります。

見れば分かるレベルの内容ですが一応解説をしておくと…

  1. Propertiesを継承し、テストオブジェクトを作成

    Propertiesへの引数がそのままテスト全体の名称になります。

  2. 具体的なテスト内容は、propertyで定義

    慣れないとちょっと引っかかる記法ですが、Mapの更新にも使われるupdateメソッドへの糖衣構文と同じです。

    内部実装としては、propertyという変数を経由してPropertySpecifierというクラスのupdateメソッド呼び出しに変換されますが、利用者側では気にする必要はありません。(Map以外で出てくると、ちょっとアレっ?と思う、Scalaの独特な記法だと思っています。)

  3. forAll関数の引数には、ランダムに生成されたテストデータを引数として取り、Boolean型の結果を返すコードブロックを渡します

    標準コレクションでもおなじみのforAll関数は、要素の中に一つでもfalseとなるものがあれば全体をfalseと判定します。つまり、たくさんのテストデータが自動生成された結果、一つでもテストの結果が失敗すればテスト全体が失敗した、とみなされます

    ScalaCheckのプロパティベーステストは、以下の形式で書くことになります。

    forAll(ジェネレータ): (コードブロックへの引数) =>
    ...
    ...
    Booleanを返すコード
    

    最後はBoolean型を返すため、a == bといった、テストの真偽を判定するコードを書く形式になります。

  4. ヘルパー関数として、booleanという関数を用意

    本書のサンプルに従って、ヘルパー関数を用意しました。引数として渡された変数の値に拠らず、必ずtrueを返します...結果としてテストは必ず成功します

    なお、先ほどのコードでは、本書に出てきたあらゆる型のデータを任意に生成してくれるany()のような便利ジェネレータがScalaCheckには無かったので、任意のAnyVal型のデータを生成するようにしていますが、本質的な意味は変わりません。

  5. ジェネレータは省略可

    ScalaCheckはジェネレータは省略可となっています。続くコードブロックの引数の型から型推論可能な場合は、自動的に型に合わせたジェネレータが選ばれます。そのため、先ほどのコード例は、以下のように書き換えることができます。

      property("always works") = forAll: (a: AnyVal) =>
     boolean(a)
    

デフォルトでは100回のテストが実行され、このコードサンプルでは全てのテストが必ず成功します。


とりあえず、テストが通る環境までは作ることができました。