テスト
Elm
property-based-testing
Fuzzer

[Elm]キミだけのッ最強Fuzzerを手に入れろ!

世の中には property based testing と呼ばれるテスト手法があります。
テスト対象の関数が満たすべき 性質 を記述すると、その性質を調べるのに適切な入力データを自動生成して実際に関数にあたえ、その出力が想定している性質を満たしているかをチェックするテスト手法です。
Elm では elm-testFuzz モジュール を使うのが一般的です。

Fuzz モジュールの基本的な使い方自体は @jinjorさんがすでに記事にしてくれています
この記事では、さらに Fuzzer の合成・変換について掘り下げることで、世界にひとつだけの目に入れても痛くないかわいい我が子のような Fuzzer を手に入れる方法をご紹介します。
ここで僕のかわいい愛娘のさくらちゃんをご覧ください。

P_20171025_215601_vHDR_On.jpg

以降の内容は、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."

P_20180422_163624_vHDR_Auto.jpg

レベル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 を手に入れよう!

P_20180510_144042_vHDR_Auto.jpg
さくらちゃんにご飯をあげる