Elixir 1.6または1.7から入るらしいProperty-Based Testingとは何なのかを見ていきます
Property-Based Testingとは
以下のようにサンプルになる値をテストするいつものやり方をExample-Based Testingと呼ぶとします
assert 1 + 1 == 2
Property-Based Testingは関数の性質をProperyと呼びます。例えば
def add(x, y) do
x + y
end
のような関数がある場合このadd
関数の引数を2つとってそれぞれを足してその結果を返すという性質(特性)をPropertyと呼びます
Propertyとは
以上のことからPropertyとは一言で言えばプログラムの特性と言えます
なのでProperty-Based Testingは特性に基づいてテストすることといえます
Example-Based Testingがプログラマが期待する入力値と出力値を元にテストすることと比較して、プログラムの特性に基づいてテストするというところが違います
この特性をテストするためにProperty-Based Testingでは大量のサンプルデータを自動的に生成してテストをします
Example-Based Testingがたった一つの正しい答えに基づいてプログラムの正当性を保障するのに対して、Property-Based Testingは一度の正しさを保障するのではなく大量のランダムなデータを渡すことで間違いを見つけるという点が主目的になっているのが大きな違いでしょう。
言い換えれば大量のランダムなデータを渡しても要求を満たしていることで帰納的に正しさを保障するテスト手法ともいえるでしょう。
使用例
後々Elixirのコアにmergeされる予定で現在はStreamDataとして別リポジトリでProperty-Based Testing用のフレームワークがElixirコアチームによって開発されています
mix.exsに依存を追加して試してみます
{:stream_data, "~> 0.1", only: :test}
use ExUnitProperties
property "length/1 is always >= 0" do
check all list <- list_of(term()) do
assert length(list) >= 0
end
end
check/2
macroに渡される最初の引数がジェネレータのリストでdo:
ブロックで実行されるのがテスト本体です
check/2
の後にall
がいきなり出てきてなんだこの未定義の変数は?それとも関数か?と面食らいますがコードを見てみるとproperty
macro内でcheck all
を含めた引数をquote
してASTのrepresentationを得てその表現をcheck/2
macroに渡してパターンマッチングしているので問題なく実行出来ているという仕組みです
macroの入れ子になっててややこしい
term/0
でElixirのtermをランダムで生成するジェネレータを作ります
iex(3)> term()
%StreamData{generator: #Function<58.22714937/2 in StreamData.tree/2>}
ジェネレータはStreamでEnumerable Protocolが実装してあるのでEnumで値を出力することも出来ます
iex(4)> term() |> Enum.take(3)
[{#Reference<0.3658627003.4231266308.20877>, true, false}, <<6>>, :y3G]
更にlist_of/1
でterm/0
で生成したジェネレータをジェネレータに突っ込んでランダムなtermのlistを生成するジェネレータを作ります
iex(5)> list_of(term())
%StreamData{generator: #Function<36.22714937/2 in StreamData.list_of/2>}
このジェネレータで生成された値が最終的にlist
に格納されassert length(list) >= 0
でExUnitの通常のテストが実行されます
デフォルトでジェネレータが走る回数は100
回のためこの場合100回、最大100個の値を持つリストが生成されassert length(list) >= 0
が実行されます
ジェネレータが走る回数を制限するにはcheck
macroにmax_runs
オプションを指定するとcheck内のジェネレータが走る回数が制限されます
例えば以下のようにmax_runs: 10
とすると最大10個のアイテムを持つリストが10回生成され10回テストされます
check all list <- list_of(term()), max_runs: 10 do
個別のジェネレータにもオプションがあり例えばlist_of/2
ではmax_length
で生成するリストの最大長を指定出来ます。なおmax_runs
でより小さい数を指定した場合max_runs
が優先されます
$ mix test
..
Finished in 4.0 seconds
1 doctest, 1 property, 0 failures
Randomized with seed 561962
ジェネレータ
ジェネレータが豊富に用意されているのでよく使いそうなものを羅列してそれぞれサンプルの値をEnum.take/2
で3つ程取ってみます
atom/1
Atomを生成する
# 英数字
iex(17)> atom(:alphanumeric) |> Enum.take(3)
[:DN, :lR, :x_]
# alias
iex(18)> atom(:alias) |> Enum.take(3)
[OJ, N3.I, RO.Qdv]
string/1
文字列を生成する
# ascii文字のみ
iex(252)> string(:ascii) |> Enum.take(3)
["", "@N", "EL"]
# 英数字のみ
iex(254)> string(:alphanumeric) |> Enum.take(3)
["u", "S", ""]
# Rangeで指定
iex(255)> string([?a..?z, ?A..?Z, ?0..?9], length: 10) |> Enum.take(3)
["OSRfonhf3y", "LmBZqdNsKT", "5OU6BBavps"]
boolean/0
真偽値を生成する
iex(24)> boolean() |> Enum.take(3)
[false, true, false]
float/1
浮動小数点数を生成する
iex(36)> float() |> Enum.take(3)
[0.0, 0.0, -1.125]
iex(37)> float(min: 0) |> Enum.take(3)
[0.0, 2.5, 1.5]
iex(38)> float(max: 10) |> Enum.take(3)
[10.0, 7.75, 2.25]
iex(39)> float(min: 5, max: 10) |> Enum.take(3)
[10.0, 10.0, 8.75]
integer/1
整数を生成する
iex(40)> integer() |> Enum.take(3)
[0, -2, 3]
iex(48)> integer(1..100) |> Enum.take(3)
[28, 59, 45]
list_of/2
データを元にリストを生成する
iex(85)> list_of(float()) |> Enum.take(3)
[[], [0.5, 1.0], []]
iex(86)> list_of(float(), length: 5) |> Enum.take(3)
[
[-0.5, 0.0, -2.0, 1.0, 0.0],
[0.5, 1.5, 5.0, 0.5, -0.5],
[-6.75, 5.0, 0.125, -0.75, 5.25]
]
iex(87)> list_of(float(), max_length: 3) |> Enum.take(3)
[[0.0], [], [0.625, 0.25]]
iex(88)> list_of(float(), min_length: 3) |> Enum.take(3)
[[0.0, 0.0, -0.5], [0.5, -2.5, -0.75], [0.625, 0.125, -0.75]]
fixed_list/1
固定長のlistを生成する
iex(33)> fixed_list([integer(), binary()]) |> Enum.take(3)
[[0, ""], [-1, <<231>>], [-3, "t"]]
fixed_map/1
要素が固定されたmapを生成する
iex(35)> fixed_map(%{integer: integer(), binary: binary()}) |> Enum.take(3)
[
%{binary: "", integer: 0},
%{binary: <<141, 177>>, integer: 0},
%{binary: <<238, 123, 72>>, integer: 3}
]
one_of/1
リストのうちから一つランダムで選ぶ
iex(107)> one_of([true, false]) |> Enum.take(3)
[false, true, true]
map_of/2
指定されたkeyとvalueからmapを生成する
iex(143)> map_of(atom(:alphanumeric), integer()) |> Enum.take(3)
[%{N: 1}, %{}, %{_k: -2, m: -3, u5x: 1}]
nonempty/1
空のenumが出力されないようにする
# 通常ジェネレータで出力されるEnumは空の値も含む
iex(31)> list_of(float()) |> Enum.take(3)
[[], [1.0, 0.0], [-0.625]]
# nonempty/1を入れると空のEnumを除外する
iex(32)> list_of(float()) |> nonempty() |> Enum.take(3)
[[-3.0], [3.0, -1.5], [-4.625, 1.75, -0.75]]
Pros/Cons
Property-Based Testingについてそれぞれメリット/デメリット
Pros
- 通常プログラムの成長に合わせてテストに期待される入力値も変わってくるが、ランダムな入力値を生成するので入力値の修正が不要
- 期待する入力と出力ではなく、どういう入力値と出力のとき要求が満たされるかを考える必要があるため、プログラムの仕様についての理解が深まる
- より少ない行数で頑強なプログラムを作れる
- テストを回せば回すほど網羅されるのでより確実になる
Cons
- 思ったよりランダムな値の生成に時間がかかる
試しに最大100個のアイテムを持つlistを100回生成し100回assertしてみたら10秒程かかった
$ time mix test
Finished in 11.5 seconds
1 property, 0 failures
Randomized with seed 833983
mix test 3.88s user 8.52s system 102% cpu 12.108 total
max_runs
オプションで10個のアイテムを10回assertするようにしたら1秒ほどで終わったのでジェネレータによる値の生成でテストにかかるコストを意識した方がいいかもしれない
$ time mix test
.
Finished in 0.7 seconds
1 property, 0 failures
Randomized with seed 963450
mix test 1.05s user 0.40s system 111% cpu 1.294 total
Property-Based Testingが向いているもの
どういう状況に向いているか
- エッジケースを攻めたいもの
-
NULL
、空文字列、マイナスの値、通常入力されなそうな文字列など
-
- 任意の入力が予想されるもの
- ユーザ入力のあるAPI
- 取りうる値の範囲が広いもの
- 取りうる組み合わせが多いもの
- 取りうる値の幅が狭くてもarityが3とか4とか
- 3個の引数がそれぞれ1〜10の値を取っても引数が一つ増えるだけでオーダーが変わってくる
Property-Based Testingが向いていないもの
- 逆に入力と出力が十分想定の範囲に収まるならExample-Based Testingで十分かも
まとめ
Property-Based Testingについて見ていきました
- Propertyとは → プログラムの特性、性質
- Property-Based Testingではジェネレータを使いランダムな値を生成しテストを実行する
- Example-Based TestingやTDDと対立するものでもないから適材適所で使えばより頑強なプログラムになりそう