型とプロパティ
以下の文章はこちらの記事を翻訳したものです。
通常、HaskellのQuickCheckのようなプロパティベースドのテストライブラリを見ると、テストがデータ型に密接に関連していることが分かります。テスト関数は、期待される型の引数から生成すべきデータを推測し、それを基にプロパティを設定します。しかし、この方法には問題があります。
データの縮小を型に結び付けると、テストが脆弱になりがちです。この問題については、以前に詳しく説明しました(参考リンク)。
それでも、この問題を克服し、データ生成を型に結び付けることが可能だとしても、それは最善の方法とは言えません。なぜなら、多くの場合、特定の型の任意の値よりも、もっと具体的なデータを生成したいと考えているからです。例えば、Hypothesisの戦略モジュールを見ると、多くの生成要素が型のように見えますが、実際にはほとんどの要素には、それをさらに具体的に定義するオプションが用意されています。
以下の例を見てみましょう:
from statistics import mean
from hypothesis import given, strategies as st
@given(st.lists(st.floats(allow_nan=False, allow_infinity=False), min_size=1))
def test_mean_is_in_bounds(ls):
assert min(ls) <= mean(ls) <= max(ls)
このテストでは、テスト対象のドメインを制限することで、一般的には成立しないプロパティに焦点を当てます。例えば、空のリストの平均はエラーを引き起こすため、これは特別なケースとしてテストする必要があります。また、NaN
や無限大の値を含む計算は、常に明確な結果を持たないことがあるため、これらの値が含まれないように指定しています。これにより、テストはより具体的な条件下でのプロパティを検証することができます。
これはプログラミングにおいてよく遭遇する状況です。特定の制限されたドメイン内でのみ成り立つプロパティがあり、そのドメインに特化したテストを他のデータ範囲のテストと組み合わせる必要がしばしばあります。このような場合、テストはより具体的なデータ生成を求めます。
しかし、これらの具体的な要件が既存の型に自然にマッピングされるとは限りません。
以下のコードを例に考えてみましょう:
from statistics import mean
from typing import List
from hypothesis import given, strategies as st
@given(ls=st.lists(st.floats(allow_nan=False, allow_infinity=False), min_size=1))
def test_mean_is_in_bounds(ls: List[float]):
assert min(ls) <= mean(ls) <= max(ls)
このコードは現在の形では動作しませんが、実装を目指すプルリクエストはすでに詳細なレビュー段階にあります。このテストでは、以前の例で設定した「全ての浮動小数点数が有限であり、リストが空でない」という条件を削除しています。これは、型ベースのアプローチだけでは特定の制約やドメイン特有のニーズに対応するのが難しいことを示しています。テストはより柔軟な設計を必要とし、型だけでは不十分な場合があることを明らかにしています。
したがって、テストを有効にするためには事前条件を追加する必要があります。
import math
from statistics import mean
from typing import List
from hypothesis import assume, given, strategies as st
@given(ls=st.lists(st.floats(allow_nan=False, allow_infinity=False, allow_infinite=False), min_size=2))
def test_mean_is_in_bounds(ls: List[float]):
assume(all(math.isfinite(x) for x in ls))
assert min(ls) <= mean(ls) <= max(ls)
ただし、この方法は元のアプローチよりもかなり長く、可読性が低下していることがわかります。
Haskellでは、特定の問題に対応するために新しい型宣言を使って新しい型を作るのが一般的です。たとえば、「NonEmptyList」や「FiniteFloat」のような新型を作り、「NonEmptyList[FiniteFloat]」のように実際に使いたい型を明示します。このやり方は、型システムを活用して、もっと簡潔で意図がはっきりしたテストコードを書くのに役立ちます。
Pythonでも似たようなことはできますが、新しいラッパータイプを作るか、または少し手間を省くためにlistやfloatのサブクラスを作ることもできます。ただし、Pythonでビルトイン型をサブクラス化するのは避けた方が良いです。予期せぬ動作が起こることがあるからです。しかし、Pythonでこの方法を使うと、コードが冗長になりやすいです。
そもそも、なぜ新しい型をわざわざ作る必要があるのでしょうか?特に、その型をたった一つのテストでしか使わない場合、本当に必要なのは型ではなく、データジェネレーターです。新しい型を作るのは、実はAPIの制約を回避するための一時的な解決策に過ぎません。
依存型を持つ言語であれば、型システム自体にこれらを表現する自然な方法があるかもしれませんが、実際に上手くいくかは疑問です。一般に、データ生成のニーズは型とは逆の方向性を持っているため、自動的にデータ生成器を作るのは難しいです。型は値を使う述語であり、データ生成器は値を作る関数です。型に基づいて自動的に生成器を作るためには、実質的に述語を逆転させる必要があるのです。このようなアプローチは、まだ多くの未解決の問題を抱えていますが、この分野の研究成果には注目しています。
それにもかかわらず、特定のテストケースのためだけに特別な型を作ることは、あまり実用的ではないと思われます。もし強力な型がプログラムに自然に存在するなら、それを利用してより良いテストを行うことは有意義です。しかし、特定のテストのためだけに、その正確な有効入力範囲を表す特定の型を作るのは、実際のテストにとってそれほど役立ちません。なぜなら、実行時のエラーの影響はコンパイル時にキャッチされるエラーの影響よりもはるかに小さいからです。これはテストが誤って失敗するだけであり、プロパティベースドテストが長時間実行されるか非決定的な場合に重要ですが、本番環境での問題にはなりません。
しかし、この問題は、多くの現代の言語とそのプロパティベースドのライブラリにはあまり当てはまらない、無理に作り出されたものです。明示的なデータジェネレーターの使用は、明らかな改善です。静的型付け言語では、各データジェネレーターに戻り値の型が指定されています。このアプローチは、テストの精度を高めるために必要な特定の入力範囲を簡単に定義でき、コードの可読性や保守性を維持しながら、テストの品質を向上させることができます。
HaskellのQuickCheckでも、forAllを使って明示的にデータジェネレーターを直接使用できますが、これは新しい型を使うアプローチと同じくらい面倒です。特に複数のデータジェネレーターを使いたい場合や、縮小を有効にしたい場合(forAllWithShrinkを使って縮小関数を明示的に渡す必要があります)には、特に面倒です。
これは、型ベースのアプローチに固有の問題です。より使いやすいデータ生成を実現するには、データジェネレーターから始めるべきです。これにより、データ生成の調整が容易になり、テストコードがより直感的で読みやすくなります。その結果、プロパティベースのテストでは、複雑な型階層や新しい型を作るのではなく、テストすべきプロパティの定義に焦点を当てることができます。
データ生成が簡単になると、人々はより具体的なテストを行いやすくなります。しかし、データ生成が手間がかかると、人々はそれを避けがちです。または、Hypothesisのassume
やQuickCheckの==>
のような効果の低い方法でテストに前提条件を加えることがあります。どちらの方法も、テストの品質が低下し、見逃されるバグが増えるリスクがあります。このような状況を避けるためには、テスト用のデータ生成をより簡単で直感的に行えるような方法が求められます。
データ生成のカスタマイズが簡単になると、テストの品質が向上し、バグを効果的に特定し修正することが可能になります。そのため、多くの場合、テスト実行を効果的にするためには、データジェネレーターを使ったアプローチをデフォルトにすることが望ましいです。
幸いにも、ほとんどの新しいプロパティベースドテストの実装では、データジェネレーターから始めるアプローチがデフォルトになっています。唯一の例外は、HaskellのQuickCheckをそのままコピーしたものです。
元々は、動的な言語(Erlang、Clojure、Pythonなど)や型システムが弱い言語(例えばCなど)向けの実装で、データジェネレーターを最初に考える方法が採用されていました。タイプファーストのアプローチはほとんど不可能でしたが、このデータジェネレーターを先に考える方法がはるかに使いやすいと証明されました。このアプローチにより、テストコードは柔軟で、理解しやすく、効果的になります。これによって、プログラミング言語の種類や強度に関わらず、プロパティベースのテストが広範囲にわたって有効に利用されるようになります。
さらに、このアプローチは静的型付けとも完全に互換性があります。例えば、Haskellの比較的新しいプロパティベースドのテストライブラリであるHedgehogは、この方法を採用しており、Haskellだけでなく他の言語でも優れた結果を示しています。
データジェネレーターが本当に必要な場合、型からデータジェネレーターを派生させることも十分可能です。Hypothesisの実装からそのヒントを得ることができます。同様のことは、Haskellでも次のようにして簡単に実現できます。これはQuickCheckの型クラスを模倣したものです:
class Arbitrary a where
arbitrary :: Gen a
その後、arbitrary
を他のデータジェネレーターと同じように使うことができます。Hedgehogではこの機能はないかもしれませんが(ただし、hedgehog-quickcheckパッケージを使用してQuickCheckのArbitraryを使うことはできます)、原則としては何も妨げるものはありません。
これにより、テストのためのデータ生成に関して、プログラマーにはより多くの柔軟性と選択肢が与えられます。型からデータジェネレーターを派生させることにより、より簡潔で効果的なテストコードを書くことができ、プロパティベースドテストの効率と品質をさらに向上させることができます。
この機能があれば、新しいデータジェネレーターの定義もはるかに簡単になります。@givenのサポートをあまり使わなくても、builds
と組み合わせて使うことで、型のデフォルト戦略からカスタムジェネレーターへのシームレスな移行が可能です。例えば、builds(MyType)
を実行すると、各コンストラクタ引数が自動的に補完されます(適切に注釈されている場合)。また、builds(MyType, some_field=some_generator)
のように特定のデフォルトをオーバーライドし、他のデフォルトはそのままにすることもできます。
このAPIはPythonの動的な性質が役立つ一例ですが、Haskellでも少しのテンプレートHaskellを使えば同じことができるでしょう。
このアプローチはデータジェネレーターに限らず、データジェネレーターを基にしたテスト仕様の柔軟性が、型システムの強さに関わらず他の方法よりも優れていることが多いです。この方法は、テストの柔軟性と効率を高めるために、プログラミング言語の特性を最大限に活用することを可能にします。