Posted at
ElixirDay 23

ElixirでProperty-Based Testing

More than 1 year has passed since last update.

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/2macroに渡される最初の引数がジェネレータのリストでdo:ブロックで実行されるのがテスト本体です

check/2の後にallがいきなり出てきてなんだこの未定義の変数は?それとも関数か?と面食らいますがコードを見てみるとpropertymacro内でcheck allを含めた引数をquoteしてASTのrepresentationを得てその表現をcheck/2macroに渡してパターンマッチングしているので問題なく実行出来ているという仕組みです

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/1term/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が実行されます

ジェネレータが走る回数を制限するにはcheckmacroに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と対立するものでもないから適材適所で使えばより頑強なプログラムになりそう


参考