3つの短いテストでKey-Valueストレージをテストする
それで、何らかの理由で、ある種のキーバリューストレージを実装する必要があるとしましょう。 ハッシュテーブルに基づく辞書でも、ツリーに基づく辞書でも、メモリに完全に保存することも、ディスクを操作することもできます-気にしません。 主なことは、次のことを可能にするインターフェースが必要であることです。
- キーによる値の書き込み
- 目的のキーを持つエントリが存在するかどうかを確認します
- キーによる値の読み取り
- 記録されたアイテムのリストを取得する
- リポジトリのコピーを取得する
古典的な例ベースのアプローチでは、典型的なテストは次のようになります。
storage = Storage() storage['a'] = 42 assert len(storage) == 1 assert 'a' in storage assert storage['a'] == 42
または:
storage = Storage() storage['a'] = 42 storage['b'] = 73 assert len(storage) == 2 assert 'a' in storage assert 'b' in storage assert storage['a'] == 42 assert storage['b'] == 73
そして一般に、そのようなテストはdofigaよりも少し多く書くことができ、また書く必要があります。 さらに、内部実装が難しいほど、とにかく何かを見逃す可能性が高くなります。 要するに、長く、退屈で、しばしば感謝のない仕事です。 それを誰かに押し付けるのはどんなに素晴らしいことでしょう! たとえば、コンピューターにテストケースを生成させます。 まず、次のようなことを試してください。
storage = Storage() key = arbitrary_key() value = arbitrary_value() storage[key] = value assert len(storage) == 1 assert key in storage assert storage[key] == value
これは最初のプロパティベースのテストです。 これは、従来のものとほとんど同じように見えますが、小さなボーナスはすでに印象的です-天井から値が取られていないため、代わりに任意のキーと値を返す関数を使用します。 別のはるかに深刻な利点があります-何度も何度もさまざまな入力データで実行して、空のストレージに要素を追加しようとすると実際に追加される契約をチェックできます。 さて、それはすべてうまくいっていますが、これまでのところ、従来のアプローチと比較してあまり有用ではありません。 別のテストを追加してみましょう。
storage = arbitrary_storage() storage_copy = storage.copy() assert len(storage) == len(storage_copy) assert all(storage_copy[key] == storage[key] for key in storage) assert all(storage[key] == storage_copy[key] for key in storage_copy)
ここでは、空のストレージを使用する代わりに、いくつかのデータを使用して任意を生成し、そのコピーが元のコピーと同一であることを確認します。 はい、ジェネレーターは潜在的にバグのあるパブリックAPIを使用して作成する必要がありますが、原則としてこれはそれほど難しいタスクではありません。 同時に、実装に重大なバグがある場合、生成プロセス中にフォールが開始される可能性が高いため、これは一種のボーナススモークテストと見なすこともできます。 しかし今、私たちは確信することができます-ジェネレーターが提供することができたすべてが正しくコピーされます。 そして、最初のテストのおかげで、ジェネレーターが少なくとも1つの要素でストレージを作成できることを確信しています。 次のテストの時間です! 同時に、ジェネレーターを再利用します。
storage = arbitrary_storage() backup = storage.copy() key = arbitrary_key() value = arbitrary_value() if key in storage: return storage[key] = value assert len(storage) == len(backup) + 1 assert key in storage assert storage[key] == value assert all(storage[key] == backup[key] for key in backup)
任意のストレージを取得し、そこに別の要素を追加できることを確認します。 そのため、ジェネレータは2つの要素を持つリポジトリを作成できます。 また、要素を追加することもできます。 など(数学的帰納法のようなことをすぐに思い出します)。 その結果、作成された3つのテストとジェネレーターにより、任意の数の異なる要素をリポジトリに追加できることを確実に検証できます。 短いテストは3つだけです! これは基本的に、プロパティベースのテストの全体的な考え方です。
- プロパティを見つけます
- さまざまなデータのヒープのプロパティをチェックする
- 利益!
ところで、このアプローチはTDDの原則と矛盾しません-テストはコードの前に同じ方法で書くことができます(少なくとも個人的には、私は通常これを行います)。 もう1つのことは、このようなテストをグリーンにすることは、従来のテストよりもはるかに難しいことですが、最終的に成功すると、コードが契約の特定の部分に実際に準拠することを確認します。
これはすべて順調ですが、...
プロパティベースのテストアプローチのすべての魅力により、多くの問題があります。 この部分では、最も一般的なものを見つけようとします。 そして、有用なプロパティを見つけることの実際の複雑さに関する問題(次のセクションで説明します)は別として、初心者の最大の問題は、多くの場合、良いカバレッジに対する誤った自信です。 実際、何百ものテストケースを生成するいくつかのテストを作成しましたが、何が間違っているのでしょうか? 前の部分の例を見ると、実際には多くのことがあります。 まず、書かれたテストでは、 storage.copy()がポインターをコピーするだけでなく、実際に「ディープ」コピーを作成するという保証はありません。 別のホール-探しているキーがストアにない場合、 ストレージのキーがFalseを返すという通常の検証はありません。 そしてリストは続きます。 さて、私のお気に入りの例の1つです。ソートを作成したとしましょう。何らかの理由で、要素の順序をチェックする1つのテストで十分だと思います。
input = arbitrary_list() output = sort(input) assert all(a <= b for a, b in zip(output, output[1:]))
そして、そのような実装は完全に合格します
def sort(input): return [1, 2, 3]
ここでのモラルが明確であることを願っています。
ある意味で前の2つの結果と呼ぶことができる次の問題は、プロパティベースのテストを使用することは、真に完全なカバレッジを達成するのが非常に難しいことが多いということです。 しかし、私の意見では、これは非常に簡単に解決されます-プロパティに基づいたテストだけを書く必要はなく、誰も従来のテストをキャンセルしませんでした。 さらに、人々は、具体的な例で物事を理解するのがはるかに簡単になるように配置されており、両方のアプローチを使用することを支持しています。 一般的に、私は次のアルゴリズムをほぼ開発しました。非常に単純な従来のテストを記述し、理想的にはAPIの使用方法の例として役立つようにします。 「文書化のための」テストで十分であると感じたらすぐに、完全なカバレッジからはほど遠い-プロパティに基づいたテストの追加を開始します。
フレームワークの問題、それらに何を期待するのか、なぜそれらが必要なのかについてです。結局のところ、誰もあなたの手でサイクルをテストすることを禁じません。 実際、喜びはテストの最初の秋までであり、CIでではなく、ローカルである場合は良いことです。 まず、プロパティベースのテストはランダム化されているため、ドロップされたケースを確実に再現する方法が必要です。また、自尊心のあるフレームワークでこれを行うことができます。 最も一般的なアプローチは、特定のシードをコンソールに出力することです。テストランナーで手動でパームオフし、ドロップケースを確実に再生(デバッグに便利)するか、テスト開始時に最初に自動的にチェックされる「不良」sidでディスクにキャッシュを作成します( CIの再現性に役立ちます)。 もう1つの重要な側面は、データの縮小(外部ソースの縮小)です。 データはランダムに生成されるため、つまり、1000要素のコンテナを使用した落下テストケースに完全に偽のチャンスを当てることができますが、これは依然としてデバッグの「喜び」です。 したがって、feylyaschyケースを検出した後の優れたフレームワークは、よりコンパクトな入力データのセットを検出しようとする多くのヒューリスティックを適用しますが、それでもテストはクラッシュし続けます。 最後に-多くの場合、テスト機能の半分は入力データジェネレーターであるため、単純なジェネレーターからより複雑なものをすばやく構築できる組み込みジェネレーターとプリミティブの存在も非常に役立ちます。
また、プロパティに基づく論理テストが多すぎるという批判も時折あります。 ただし、これには通常、次のスタイルの例が伴います。
data = totally_arbitrary_data() perform_actions(sut, data) if is_category_a(data): assert property_a_holds(sut) else if is is_category_b(data): assert property_b_holds(sut)
実際、(初心者向けの)アンチパターンは非常に一般的です。これをしないでください! そのようなテストを2つの異なるテストに分割し、それらに到達する可能性が小さい場合は不適切な入力データ(多くのフレームワークではこのための特別なツールもあります)をスキップするか、すぐに適切なデータのみを生成するより特殊なジェネレーターを使用することをお勧めします。 結果は次のようになります
data = totally_arbitrary_data() assume(is_category_a(data)) perform_actions(sut, data) assert property_a_holds(sut)
そして
data = data_from_category_b() perform_actions(sut, data) assert property_b_holds(sut)
有用な特性とその生息地
さて、プロパティに基づいてテストするのに何が便利ですか、主な落とし穴が整理されていることは明らかです...いいえ、主なことはまだ明確ではありません-これらの同じプロパティをどこから取得するのですか? 検索してみましょう。
少なくとも落ちないで
最も簡単なオプションは、テスト中のシステムに任意のデータを押し込み、クラッシュしないことを確認することです。 実際、これはファッショナブルな名前ファジングを備えたまったく別の方向です。専用ツール(たとえば、AFL別名American Fuzzy Lop)がありますが、ある程度拡張すると、プロパティに基づいたテストの特別なケースと見なすことができます。登っていない場合は、それから始めることができます。 それにもかかわらず、原則として、他のプロパティをチェックするときに潜在的な低下が通常非常によく出るので、明示的にそのようなテストはほとんど意味がありません。 この「プロパティ」に言及する主な理由は、読者をファザー、特にAFL(このトピックに関する英語の記事がたくさんあります)に導き、画像を完成させることです。
テストオラクル
最も退屈なプロパティの1つですが、実際には非常に強力なもので、見かけよりもはるかに頻繁に使用できます。 アイデアは、同じことを行うが異なる方法で実行される2つのコードがある場合があるということです。 そして、特に、任意の入力データを生成することを理解できず、両方のオプションでそれらを突き出し、結果が一致することを確認できます。 最も頻繁に引用されるアプリケーションの例は、最適化されたバージョンの関数を記述して、低速だが単純なオプションを残し、それに対してテストを実行する場合です。
input = arbitrary_list() assert quick_sort(input) == bubble_sort(input)
ただし、このプロパティの適用範囲はこれに限定されません。 たとえば、非常に多くの場合、テストするシステムによって実装される機能は、すでに標準言語ライブラリでも実装されているもののスーパーセットであることがわかります。 特に、通常、キー値ストレージ(ツリー、ハッシュテーブル、またはマークルパトリシアツリーなどのエキゾチックなデータ構造に基づくメモリまたはディスク)のほとんどの機能は、標準の標準辞書でテストできます。 あらゆる種類のCRUDのテスト-そこにもあります。
私が個人的に使用した別の興味深いアプリケーション-システムの数値モデルを実装するときに、特別なケースを分析的に計算し、シミュレーション結果と比較できます。 この場合、原則として、完全に任意のデータを入力に押し込もうとすると、正しい実装であっても、数値解法の限られた精度(したがって、適用可能性)のためにテストが低下し始めますが、修復の過程で、生成された入力データに制限を課すことにより、これらの同じ制限知られるようになる。
要件と不変条件
ここでの主な考え方は、多くの場合、要件自体がプロパティとして使いやすいように定式化されるということです。 そのようなトピックに関するいくつかの記事では、不変条件が個別に強調されていますが、私の意見では、これらの不変条件のほとんどは要件の直接の結果であるため、ここの境界は不安定です。
プロパティのチェックに適したさまざまな分野の例の小さなリスト:
- クラスフィールドには、以前に割り当てられた値(getter-setter)が必要です
- リポジトリは、以前に記録されたアイテムを読み取れる必要があります
- 以前に存在しないアイテムをリポジトリに追加しても、以前に追加されたアイテムには影響しません
- 多くの辞書では、同じキーを持ついくつかの異なる要素を保存できません
- バランスの取れた木の高さはこれ以上ない どこで -記録されたアイテムの数
- ソート結果は、順序付けられたアイテムのリストです
- base64エンコード結果にはbase64文字のみを含める必要があります
- ルート構築アルゴリズムは、ポイントAからポイントBに至る一連の許容される動きを返す必要があります
- 構築された輪郭のすべての点が満たされる必要があります
- 電子署名検証アルゴリズムは、署名が本物であればTrueを 、そうでなければFalseを返す必要があります
- 正規直交化の結果として、基底内のすべてのベクトルは単位長さとゼロの相互スカラー積を持たなければなりません
- ベクトル転送および回転操作は、その長さを変更してはなりません
原則として、ここではすべてが完了している、記事が完了している、テストオラクルを使用する、または要件内のプロパティを探すと言うことができますが、別に興味深い「特殊なケース」がいくつかあります。
誘導と状態のテスト
場合によっては、状態で何かをテストする必要があります。 この場合、最も簡単な方法:
- 初期状態の正確性をチェックするテストを作成します(たとえば、作成したばかりのコンテナーが空であることなど)
- 一連のランダム操作を使用してシステムを任意の状態にするジェネレーターを作成する
- ジェネレーターの結果を初期状態として使用して、すべての操作のテストを記述します
数学的帰納法に非常に似ています:
- ステートメント1を証明する
- ステートメントNが真であると仮定して、ステートメントN + 1を証明する
別の方法(破損した場所についてもう少し情報を提供することもあります)は、許容可能なイベントシーケンスを生成し、テスト中のシステムに適用して、各ステップの後にプロパティをチェックすることです。
前後に
突然、いくつかのデータの直接および逆変換のためにいくつかの関数をテストする必要があった場合、非常に幸運だと考えてください:
input = arbitrary_data() assert decode(encode(input)) == input
テストに最適:
- シリアライゼーション-デシリアライゼーション
- 暗号解読
- エンコード-デコード
- 基本行列を四元数に、またはその逆に変換します
- 直接および逆座標変換
- 直接および逆フーリエ変換
特別ですが、興味深いケースは反転です:
input = arbitrary_data() assert invert(invert(input)) == input
顕著な例は、マトリックスの反転または転置です。
べき等
一部の操作は、繰り返し使用した結果を変更しません。 典型的な例:
- 仕分け
- ベクトルと基底のあらゆる種類の正規化
- 既存のアイテムをセットまたは辞書に再追加する
- オブジェクトの一部のプロパティに同じデータを再記録する
- 標準形式へのデータのキャスト(JSONのスペースは、たとえば統一されたスタイルにつながります)
通常のデコード(エンコード(入力))==入力メソッドが、同等の入力データの表現が異なるために適切でない場合、due等性を使用してシリアル化-逆シリアル化をテストすることもできます(JSONの余分なスペース):
def normalize(input): return decode(encode(input)) input = arbitrary_data() assert normalize(normalize(input)) == normalize(input)
さまざまな方法、1つの結果
ここでのアイデアは、同じことを行うための方法がいくつかあることがあるという事実を利用することに要約されます。 これはテストオラクルの特殊なケースのように見えるかもしれませんが、実際にはそうではありません。 最も簡単な例は、いくつかの操作の可換性を使用しています。
a = arbitrary_value() b = arbitrary_value() assert a + b == b + a
些細なことのように思えるかもしれませんが、これはテストするのに最適な方法です。
- 非標準表現での数値の加算と乗算(bigint、有理数、それだけです)
- 有限体の楕円曲線上の点の「追加」(こんにちは、暗号!)
- セットの集合(内部では完全に非自明なデータ構造を持つことができます)
さらに、辞書への要素の追加には同じプロパティがあります。
A = dict() A[key_a] = value_a A[key_b] = value_b B = dict() B[key_b] = value_b B[key_a] = value_a assert A == B
このオプションはもっと複雑です-私は長い間、言葉でそれを説明する方法を考えていましたが、数学的表記だけが思い浮かびます。 一般に、このような変換は一般的です プロパティが保持するもの 、および引数と関数の結果の両方が必ずしも単なる数字ではなく、演算 そして -これらのオブジェクトに対するいくつかのバイナリ操作。 これで何をテストできますか:
- あらゆる種類の奇妙な数、ベクトル、行列、四元数の加算と乗算( )
- 線形演算子、特にあらゆる種類の積分、微分、畳み込み、デジタルフィルター、フーリエ変換など( )
- 異なる表現の同一オブジェクトに対する操作、例えば
- どこで そして 単一の四元数であり、 -クォータニオンを同等の基本行列に変換する操作
- どこで そして 信号は -畳み込み -乗算、および -フーリエ変換
もう少し「通常の」タスクの例-いくつかのトリッキーな辞書マージアルゴリズムをテストするには、次のようなことができます。
a = arbitrary_list_of_kv_pairs() b = arbitrary_list_of_kv_pairs() result = as_dict(a) result.merge(as_dict(b)) assert result == as_dict(a + b)
結論の代わりに
基本的に、この記事で伝えたかったことはこれだけです。 それがおもしろくて、もう少し多くの人がこのすべてを実践し始めることを願っています。 タスクを少し簡単にするために、言語ごとに有効性の程度が異なるフレームワークのリストを示します。
- Python: 仮説
- さび: Proptest
- Scala: Scalacheck
- C ++: Rapidcheck
- JavaScript: FastCheck
- Go: gopter
そしてもちろん、かつて素晴らしい記事を書いてくれた人々に特別な感謝をします。数年前、私はこのアプローチについて学び、心配をやめて、プロパティに基づいたテストを書き始めました。