世の中には property based testing と呼ばれるテスト手法があります。
テスト対象の関数が満たすべき 性質 を記述すると、その性質を調べるのに適切な入力データを自動生成して実際に関数にあたえ、その出力が想定している性質を満たしているかをチェックするテスト手法です。
Elm では elm-test
の Fuzz
モジュール を使うのが一般的です。
Fuzz モジュールの基本的な使い方自体は @jinjorさんがすでに記事にしてくれています 。
この記事では、さらに Fuzzer の合成・変換について掘り下げることで、世界にひとつだけの目に入れても痛くないかわいい我が子のような Fuzzer を手に入れる方法をご紹介します。
ここで僕のかわいい愛娘のさくらちゃんをご覧ください。
以降の内容は、elm-test 4.20
(Elm 0.18) を想定しています。
レベル0. プリミティブなやつ
オリジナルな Fuzzer
って言っときながら、いきなり全然オリジナルじゃないやつです。
bool : Fuzzer Bool
-- `Int` の値を生成する.
-- `NaN` とか `Infinity` とか `-Infinity` とかは生成しません。
-- 他の Fuzzer もそうだけど、ただの一様分布ではなく、コーナーケースとかよく使われる値を多めに生成してくれます。
int : Fuzzer Int
float : Fuzzer Float
-- `0.0` から `1.0` の値を生成する.
-- もちろんこれもしっかり `0.0` とか `1.0` みたいな値を多めに生成します。
percentage : Fuzzer Float
-- 印字可能なASCII文字の文字列を生成します.
-- `""` も多めに生成します。
-- `""` だと困るようなケースはレベル3で `nonEmptyString` を作りましょう。
string : Fuzzer String
char : Fuzzer Char
レベル1. 関数の引数でカスタマイズ
まずは Fuzz
モジュールに用意されている関数に引数を与えることでオリジナル Fuzzer
を手に入れましょう。
こういうやつです。
intRange : Int -> Int -> Fuzzer Int
floatRange : Float -> Float -> Fuzzer Float
constant : a -> Fuzzer a
以下のようにオリジナル Fuzzer
を作れます。
myInt : Fuzzer Int
myInt = intRange -50 200
{-| さくらちゃんの胃袋のサイズを生成するFuzzer.
マイナスになることはないが、無限に草を食べるので上限が存在しない。
上限が無い場合には `Random.maxInt` を使います。
-}
stomachSize : Fuzzer Int
stomachSize = intRange 0 Random.maxInt
myFloat : Fuzzer Float
myFloat = floatRange -pi pi
{-| 常に同じ文字列を返すFuzzer.
そんなん Property based testing の意味がないじゃん!
ということで、これ単体で使うのではなく、後に紹介する `oneOf` とかと一緒につかいます。
-}
meanLess : Fuzzer String
meanLess = concatant "This always generates same string."
レベル2. かんたんな合成
レベル0 や レベル1の Fuzzer
から Maybe
, Result
, List
のような型の Fuzzer
を作ってみましょう。
maybe : Fuzzer a -> Fuzzer (Maybe a)
result
: Fuzzer error
-> Fuzzer value
-> Fuzzer (Result error value)
list : Fuzzer a -> Fuzzer (List a)
array : Fuzzer a -> Fuzzer (Array a)
{-| 以下のいずれかが生成される.
* `myInt` で生成される `Int` に `Just` をつけたもの
* Nothing
-}
myMaybeInt : Fuzzer (Maybe Int)
myMaybeInt = maybe myInt
myResult : Fuzzer (Result Int Float)
myResult = result myInt myFloat
{-| ランダムな文字列をいくつか並べたリストを生成する.
空リストのこともある。
空リストを含めたくない場合はレベル3の `nonZeroList` を使います。
-}
myList : Fuzzer (List String)
myList = list string
myArray : Fuzzer (Array Bool)
myArray = array bool
レベル3. 合成の方法を自分で指定する
map : (a -> b) -> Fuzzer a -> Fuzzer b
map2 : (a -> b -> c) -> Fuzzer a -> Fuzzer b -> Fuzzer c
...
andMap : Fuzzer a -> Fuzzer (a -> b) -> Fuzzer b
andThen : (a -> Fuzzer b) -> Fuzzer a -> Fuzzer b
-- 自分で作った型の Fuzzer を作る場合
data Point = Point Int Int
myPoint : Fuzzer Point
myPoint = map2 Point myInt int
-- 特殊な制約がある Fuzzer を作る場合
{-| `""` ではないことが保証されている文字列
1文字目と「空かもしれない文字列」をくっつければ必ず空ではない文字列になります。
-}
nonEmptyString : Fuzzer String
nonEmptyString =
map2
(\c str -> String.fromChar c ++ str)
char string
{-| `[]` ではないListを作る関数
-}
nonZeroList : a -> Fuzzer (List a)
nonZeroList a =
map2 (::) a (list a)
{-| `!` を含まない文字列
逆に限られた文字しか許さない文字列を作りたい場合は、
この方法では空文字列がたくさん生成されてしまうため、レベル4の方法をオススメします。
-}
mySpecialString : Fuzzer String
mySpecialString =
map (String.filter (\c -> c /= '!')) string
レベル4. 複雑な仕様に従う文字列を生成する
Parser を自分で書いてテストする場合などに重宝します。
elm-data-url
ではBNF記法で示されている仕様にしたがった文字列を生成し、
その結果を再度文字列にエンコードしたものと元の文字列が一致するか確認することで正常系のテストにしています。
BNFで書く必要がないくらい単純なものであればレベル3の String.filter
を使ったもので十分ですが、Regex
モジュールではなくあえて Parser を使うってことはたぶんこの手法を使うべき複雑なやつだと思います。
-- ListであたえたいずれかのFuzzerを使う
oneOf : List (Fuzzer a) -> Fuzzer a
-- `oneOf` とほぼ同じだけど、Listの要素の各Fuzzerの重要度を指定すると、
-- 重要度が高いFuzzerを優先的に使ってくれる。
frequency : List (Float, Fuzzer a) -> Fuzzer a
ためしに、RFC6838 の restricted-name
を生成する Fuzzer
を作ってみます。
restrictedName : Fuzzer String
restrictedName =
Fuzz.map2 (++)
restrictedNameFirst
restrictedNameTail
restrictedNameFirst : Fuzzer String
restrictedNameFirst =
Fuzz.map String.fromChar <|
Fuzz.oneOf
[ upper
, lower
, digit
]
restrictedNameTail : Fuzzer String
restrictedNameTail =
Fuzz.map (String.fromList << List.take 126) <|
Fuzz.list <|
Fuzz.oneOf
[ upper
, lower
, digit
, restrictedMark
]
restrictedMark : Fuzzer Char
restrictedMark =
Fuzz.oneOf <| List.map Fuzz.constant [ '!', '#', '$', '&', '-', '^', '_', '.', '+' ]
キミだけの Fuzzer
を手に入れよう!