老犬が新しいトリックを教える:QuickCheckを使用したコードカタ

仲間のプログラマーに自分のコードに対してさらに異なるセルフテストを作成するように勧めると、彼らはしばしばこれが複雑で退屈な仕事だと不平を言う。 そして、いくつかの点で彼らは正しい。 実際、従来の単体テストを使用する場合、多くの場合、個々の動作のケースをチェックするために多くのコードを記述する必要があります。 はい。テストの質は、特に複雑なシステムで、些細なユースケースが大成功を収めるとき、特に疑問を提起しますが、テストを書くことを誰も考えていないより複雑なシナリオでは、不快な問題が発生します。



QuickCheckで長い間使用されているテスト方法について聞いたことがありますが、それを正しく行うには最終的なプッシュが十分ではありませんでした。 この推進力は、この素晴らしい図書館の著者であるジョン・ヒューズからのこのプレゼンテーションでした。



QuickCheckアプローチとは



アプローチの本質は非常に簡単に説明できます。テスト例を作成するのではなく、 任意の入力データに対するシステムの動作を決定するルールを設定します 。 ライブラリ自体は、多数のランダムな入力データを生成し、コードの動作が確立されたルールに準拠しているかどうかを確認します。 そうでない場合は、テストの例が示されます。



それは有望ですか? かなり。





しかし、 単純なbydoprogrammerは、HaskellでもErlangでもなく、より一般的な言語で書いている人に、この奇跡にどのような側面からアプローチできますか? たとえば、Javaでプログラミングするときのほうが快適です。 関係ありません! Googleは、JUnitにはJUnit-QuickCheckと呼ばれる対応するプラグインがあることをほぼ即座に示唆しています。



プログラミングへの新しいアプローチを試す最良の選択肢は、既知のものを書くことです。 だから私はロバート・マーティンから古典的な素因数カタを取った。 私の記事を掘り下げる前に、すぐにそれをよく理解することをお勧めします。



行こう



まず、空のプロジェクトを作成します。 XMLファイルのシートで読者を飽きさせないために、これにはGradleを使用します。 これにより、プロジェクトの説明全体がいくつかの行に収まります。



apply plugin: 'java' repositories { mavenCentral() } dependencies { testCompile ( "junit:junit:4.11", "org.hamcrest:hamcrest-all:1.3", "org.junit.contrib:junit-theories:4.11", "com.pholser:junit-quickcheck-core:0.4-beta-1", "com.pholser:junit-quickcheck-generators:0.4-beta-1" ) }
      
      







各中毒はここでは偶然ではありません。 ここでJUnitが必要な理由を説明する必要はありませんが、残りの依存関係についていくつか説明します。







TDDの原則に従って、最も単純な理論から始めます。これにより、フィードバックループが機能していることをすばやく確認できます。



 import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import com.pholser.junit.quickcheck.ForAll; import org.junit.contrib.theories.Theories; import org.junit.contrib.theories.Theory; import org.junit.runner.RunWith; @RunWith(Theories.class) public class PrimeFactorsTest { @Theory public void allOk(@ForAll Integer number) { assertThat(true, is(true)); } }
      
      







この簡単なトリックにより、時間を大幅に節約できます。 TDDを使用している人が複雑なテストを作成するのに多くの時間を費やしているのをよく見かけますが、最終的に実行すると、完全に無関係な問題が原因で機能しないことがわかります(依存関係はダウンロードも登録もされていませんJDKがインストールされ、プロジェクトが正しく構成されていない、コードが正しく記述されていない、その他の多くのばかげたエラーがあります)。 それは常に非常にイライラし、動作するリズムを混乱させます。 まだTDDを試そうとしているこれらの新人に対処することは特に困難です。



したがって、私自身は常に最も単純で、最も些細な、モロニックなテストから始めて、同じことをすることをお勧めします。 あなたはただそれを実行し、私がそれが通過するときに見るものをチェックし、それが落ちるときを見る必要があります。 これは、私のシステムが戦闘の準備ができていることを意味し、Red-Green-Refactorサイクルを妨げるものは何もありません。



最初の作業理論



素数を識別する方法の問題に煩わされないように(私のコードはこれを行う必要があります!)、単に既知の数を配列に打ち込みます。 明らかに、リストの制限のため、テストする数値の範囲も制限する必要があります。 この問題は後ほど修正します。 主なことに気を取られないようにするために、コードにインポートを記述しません。コード自体に限定します。



 @Theory public void primeNumberIsItsOwnFactor(@ForAll @InRange(minInt = 1, maxInt = 50) Integer number) { List<Integer> firstPrimeNumbers = Arrays.asList(2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47); assumeThat(number, isIn(firstPrimeNumbers)); List<Integer> factors = PrimeFactors.extract(number); assertThat(factors, hasItem(number)); }
      
      







JUnit-QuickCheckプロジェクトの@ForAll



および@InRange



を使用して、指定された間隔で乱数を自動的に生成します。 次に、 assumeThat



関数を使用してそれらをさらにフィルター処理し、後続のコードが配列で指定した数値でのみ機能するassumeThat



にします。 assumeThat



assertThat



の違いは、次の番号がテストに失敗した場合、最初の関数がテストを停止し(次の例に進む)、2番目がエラーを通知する(そして後続のすべての例でテストを停止する)ことです。 テストで仮定を使用することは、条件式を使用して値をフィルタリングすることよりも慣用的です。



このテストは最初に該当しますが( extract



メソッドの実装がないため)、修正は簡単です。 すべてのテストに合格するソリューションは簡単です。



 public class PrimeFactors { public static List<Integer> extract(Integer number) { return Arrays.asList(number); } }
      
      







事前に驚いたりinしたりしないでください。 このコードは仕様に従って完全に機能し、 50を超えない素数を素因数に分解します。 コードに他の数値を操作する方法を教えるには、新しい理論を書いてください。



スケルトンで肉を作る



数の要因のセットにはどのような特性がありますか? 明らかに、それらの積は数そのものと等しくなければなりません。



 @Theory public void productOfFactorsShouldBeEqualToNumber(@ForAll @InRange(minInt = 2, maxInt = 50) Integer number) { List<Integer> factors = PrimeFactors.extract(number); Integer product = 1; for (Integer factor: factors) product = product * factor; assertThat(product, is(number)); }
      
      







この理論は...落ちません! そして実際、コードが数値自体を返す場合、常にそうです。 地獄



さて、別の理論、今回はより成功しました。



 @Theory public void everyFactorShouldBeSimple(@ForAll @InRange(minInt = 2, maxInt = 50) Integer number) { List<Integer> factors = PrimeFactors.extract(number); assertThat(factors, everyItem(isIn(firstPrimeNumbers))); }
      
      







各要素は単純でなければなりません(単純な要素のリストに載っています)。そのため、理論は安定して定期的に低下し始めます。 そして、これはまさに私たちが必要とするものです。 たとえば、次のエラーが発生したとします。



 org.junit.contrib.theories.internal.ParameterizedAssertionError: everyFactorShouldBeSimple("10" <from 10>) at org.junit.contrib.theories.Theories$TheoryAnchor.reportParameterizedError(Theories.java:215) at org.junit.contrib.theories.Theories$TheoryAnchor$1$1.evaluate(Theories.java:169) at org.junit.contrib.theories.Theories$TheoryAnchor.runWithCompleteAssignment(Theories.java:153) at org.junit.contrib.theories.Theories$TheoryAnchor.runWithAssignment(Theories.java:142) ...
      
      







この数の除数を見つけることができる最も簡単なコードを書きましょう。



 public class PrimeFactors { public static List<Integer> extract(Integer number) { if (number % 2 == 0) return Arrays.asList(2, number / 2); return Arrays.asList(number); } }
      
      







テストを再度実行します。 これらは、見つかった新しい値に自動的に該当します。



 org.junit.contrib.theories.internal.ParameterizedAssertionError: everyFactorShouldBeSimple("15" <from 15>) at org.junit.contrib.theories.Theories$TheoryAnchor.reportParameterizedError(Theories.java:215) at org.junit.contrib.theories.Theories$TheoryAnchor$1$1.evaluate(Theories.java:169) at org.junit.contrib.theories.Theories$TheoryAnchor.runWithCompleteAssignment(Theories.java:153) ...
      
      







彼もハックしましょう!



 public class PrimeFactors { public static List<Integer> extract(Integer number) { if (number % 2 == 0) return Arrays.asList(2, number / 2); if (number % 3 == 0) return Arrays.asList(3, number / 3); return Arrays.asList(number); } }
      
      







テストを何度も繰り返し実行します。新しい値を見つけるたびに、実装が該当します。 ただし、テストに合格するように素数を返すことはできません。 これを行うと、以前の理論(数値の積をチェックする)が破綻し始めます。 したがって、正しいアルゴリズムを段階的に実装する必要があります。



徐々に(そして実際、非常に迅速に)この一連のハッキングは、最初の正しい決定につながります。



 public class PrimeFactors { public static List<Integer> extract(Integer number) { List<Integer> factors = new ArrayList<>(); for (int divisor = 2; divisor <=7; divisor++) { while ((number > divisor) && (number % divisor == 0)) { factors.add(divisor); number = number / divisor; } } factors.add(number); return factors; } }
      
      







もちろん、「正しい判断」という言葉は、この段階ですべてのテスト安定して合格することだけを意味します。 ただし、明らかに一般的なケースには適していません。



少し休憩して反射する必要があります。 それ自体が現在のコードの反例を選択する理論は、非常に便利なものであることが判明しました。 コードを操作するプロセスは、高速で正確でトリッキーなパンチを提供するロボットとのピンポンに変わります。 新しい例はコードを壊すことについて考える時間を費やす必要はありません。なぜなら、それらは自分で生まれるからです。 代わりに、アルゴリズム自体についての考えに完全に集中し、フローモードでそれを挽くことができます。 これは、コミットでこのような大きな飛躍が発生した理由の一部を説明しています。 それは、中間ステップが強化されて完全なコミットを形成するには、コードが非常に速く生成されたというだけです。



これまでのところ、すべてが非常にクールなようです。 私たちはほんの2、3の理論を書き、それらは半自動的に私たちのアルゴリズムを育てました。 その美しさではないですか? ただし、次に何が起こるか見てみましょう。



ショートパンツから成長する時です



幸福感は徐々に過ぎ、目は初期段階で慎重に旋回した鋭い角に注意を払い始めます。 もちろん、このコードは仕様に従って動作しますが、この仕様は2〜50の数値に対してのみ定義されています。 この間隔で、プログラムなしで実行できます。頭の中ですべてを数えるだけです。



続けましょう。 すべての理論で上限を10回上げます!



 @Theory public void primeNumberIsItsOwnFactor(@ForAll @InRange(minInt = 2, maxInt = 500) Integer number) { ... } @Theory public void productOfFactorsShouldBeEqualToNumber(@ForAll @InRange(minInt = 2, maxInt = 500) Integer number) { ... } @Theory public void everyFactorShouldBeSimple(@ForAll @InRange(minInt = 2, maxInt = 500) Integer number) { ... }
      
      







突然 、新しい問題が発生します。私たちの理論は、47を超える素数があることを認識していません(おっと、 ユークリッドを知っている人はいません)。 素数を決定する新しい方法を考え出す必要があります。



標準のJavaライブラリーにある、少しごまかします(またはすべてが正直ですか?)。また、 既製のシンプルな実装を使用します。 コードの美しさと均一性に違反しないように、対応するマッチャーの形式で作成します。



 @Theory public void primeNumberIsItsOwnFactor(@ForAll @InRange(minInt = 2, maxInt = 500) Integer number) { assumeThat(number, isProbablySimple()); List<Integer> factors = PrimeFactors.extract(number); assertThat(factors, hasItem(number)); } @Theory public void productOfFactorsShouldBeEqualToNumber(@ForAll @InRange(minInt = 2, maxInt = 500) Integer number) { ... } @Theory public void everyFactorShouldBeSimple(@ForAll @InRange(minInt = 2, maxInt = 500) Integer number) { List<Integer> factors = PrimeFactors.extract(number); assertThat(factors, everyItem(isProbablySimple())); } private Matcher<Integer> isProbablySimple() { return new BaseMatcher<Integer>() { @Override public boolean matches(Object item) { return (item instanceof Integer) && (BigInteger.valueOf((Integer) item).isProbablePrime(5)); } @Override public void describeTo(Description description) { description.appendText("prime number"); } }; }
      
      







ここで、コードは大きな数値の分解に当てはまります。 それを修正する時が来ました!



 public class PrimeFactors { public static List<Integer> extract(Integer number) { List<Integer> factors = new ArrayList<>(); for (int divisor = 2; divisor <= number; divisor++) { ...
      
      







古いループ境界(7)をnumber



に修正し、すべてが再び機能するように思われます。



少しだけ残っています。テストの境界をさらに広げ、結果を楽しむためです。 そして、突然の驚きが私たちを待っています...



厳しい現実に直面



現実はこのようなものです:



 @Theory public void primeNumberIsItsOwnFactor(@ForAll @InRange(minInt = 2, maxInt = Integer.MAX_VALUE) Integer number) { ... } @Theory public void productOfFactorsShouldBeEqualToNumber(@ForAll @InRange(minInt = 2, maxInt = Integer.MAX_VALUE) Integer number) { ... } @Theory public void everyFactorShouldBeSimple(@ForAll @InRange(minInt = 2, maxInt = Integer.MAX_VALUE) Integer number) { ... }
      
      







テストの上限を500からInteger.MAX_VALUE



(2 ^ Integer.MAX_VALUE



)に増やすとすぐに、テストが非現実的に長く動作し始めました。 各テストの1分。 問題は何ですか? 何が悪いの?



QuickCheckスタイルのテストの予期しない副作用は、テストされたコードの速度に対する感度です 。 ただし、考えてみると、これは非常に論理的です。コードが最適化されておらず、実行速度が遅い場合、100回呼び出すと、この非最適性が100倍も見えやすくなります。 「古典的な」単体テストでは、このスローダウンはそれほど顕著ではありませんが、ここではすべての栄光に現れています。



コード内のプラグを見つける必要がある場合はどうしますか? 2つのオプションがあります。プロファイラーを手に取り、測定値を取得するか、精査の方法でエラーを探します。



しかし、私たちのコードでは、特別なことは何も見ていません。すべてが見えています。 問題は、私たちがあまりにも長い間サイクルを走っていて、無駄に電気を無駄にしていることです。 因子分解アルゴリズムに精通している人なら誰でも、与えられた数の平方根を超えない因子をチェックするだけで十分であることを覚えています。 覚えていない人は、 ボブおじさんに行くことができます。



修正を適用します。 繰り返しますが、ループの上限を変更しますが、今回はMath.sqrt(number)



ます。



 public class PrimeFactors { public static List<Integer> extract(Integer number) { List<Integer> factors = new ArrayList<>(); for (int divisor = 2; divisor <= Math.sqrt(number) + 1; divisor++) { ...
      
      







これは作業の結果にどのように影響しましたか? テストは再び迅速に機能し始め、その違いは本当に印象的です。



今、すべてが大丈夫です! すべてのテストに合格し、コードはすっきりしていて、興味深い体験が得られました。Habrに関する記事を書く時が来ましたか? そして、別の考えが私の頭に忍び込みます...



テストをテストする



停止、私の友人、私は自分自身に言う、あなたはサイクルの境界条件を正しく書き留めましたか? 数値のルートに1を追加することは本当に必要ですか、それとも不要ですか?



些細な質問のようです。 しかし、100のテスト値で実行されるテストがあります! 彼らはここで誰が間違っているかを示します。



ループの先頭で「+1」を減算し( divisor <= Math.sqrt(number);



)、テストを実行します。



素晴らしい、彼らは通ります!



divisor < Math.sqrt(number);



)、もう1つユニットを取りdivisor < Math.sqrt(number);







テストに再び合格しました!



なに?



そして、ここで私はもう一度考えなければなりませんでした。 さらに悪いことに。



 public class PrimeFactors { public static List<Integer> extract(Integer number) { List<Integer> factors = new ArrayList<>(); for (int divisor = 2; divisor < Math.sqrt(number) - 2; divisor++) { ...
      
      







私は明らかに間違ったコードを書きました(9番でも乗数が見つかりません) が、テストではすべてがうまくいっていると言われています 。 私はそれらを再び開始します-彼らは再びすべてがうまくいっていると言います。 私はそれらを再び起動します-何度も成功します。 フォールは非常にまれにしか発生せず、テストでときどき発見される誤ったアルゴリズムの反例は、将来の実行のために保存されません。



このテスト動作の理由は何ですか?



テストの境界をInteger.MAX_VALUE



に増やすことで、パフォーマンスの問題を見つけて修正することができましたが、新しいnewに陥りました。 トリックは、テストでこれらの範囲設定を使用すると、 ほとんどの場合、大きな数値が使用されることです(分布が均一に分布するため)。 そして、コードに導入された欠陥は、素数の平方にのみ現れます(説明を必要としないことを願っています)。



残念なことに、私はもう少しチートし既存の仕様のコピーを作成するよりも成功したソリューションを思い付くことができませんでしたが、再び狭い境界線でのみ。



 @Theory public void everyFactorShouldBeSimple(@ForAll @InRange(minInt = 2, maxInt = Integer.MAX_VALUE) Integer number) { List<Integer> factors = PrimeFactors.extract(number); assertThat(factors, everyItem(isProbablySimple())); } @Theory public void everyFactorShouldBeSimpleEspeciallyForSmallNumbers(@ForAll @InRange(minInt = 2, maxInt = 200) Integer number) { everyFactorShouldBeSimple(number); }
      
      







不器用に見えますが、少なくとも、ループを駆動するのに必要な正確な上限を見つけることができます( divisor <= Math.sqrt(number)



)。



この(一見)簡単な例で、私たちに出会ったすべての発見をまとめてまとめてみましょう。



結果として何を得たのか



なじみのない地域での1回の実験でも、多くの発見があります。 QuickCheckアプローチのすべての機能を1つのバンドルに集めて評価しようとします。



ラコニック仕様





確かに、そのようなことがあります。 私は3つの理論のみを書かなければならず、それぞれがアルゴリズムの1つの機能をテストしました。 これは、旧バージョンのkataで発生する12個の従来の単体テストよりも著しく少ないです。 この機能は、この手法の明確なプラスで記述します。



検証可能なプロパティを慎重に策定する必要性





理論がうまく機能するためには、入力パラメーターに関して不変である検証のための定性的特性を考え出さなければなりません。 時にはそれは本当に複雑になることがあります。 テストコード内でテストアルゴリズムを完全に実装する必要があるように思われるかもしれません。



上記の例では、 isProbablePrime



メソッドを使用することができました。 isProbablePrime



メソッドは、高速ヒューリスティックアルゴリズムを使用して、簡単にするために番号を不正確にチェックします。 ただし、そのようなアルゴリズムが存在しない場合、検証オプションはどうなりますか? 実際、定義上、素数は除数のない数です。 そして、数の単純さを確認するには、 それを除数分割する必要があります。



これはおそらく、QuickCheckテストで最も難しい瞬間です。 理論で使用するための優れた不変式を作成することがいかに難しいかを理解するには、さらなる研究が必要です。



遅いコード感度





一方で、これは良いことです。なぜなら、コードが最適でないことをすぐに示唆できるからです。 一方、原則としてコードを大幅に高速化できない場合は、テストの遅い動作を受け入れるか、テストパラメーターとして使用されるランダム値の数を減らす必要があります。 また、ランダムな値の数を減らすと、テストによってコード内の潜在的な欠陥が見つかるという自信が適切な程度に低下します。



この理由から、エンドツーエンドのテストにQuickCheckを使用することは最良のアイデアではないかもしれないとすでに推測したと思います。 ただし、本当にしたい場合は、を試すことができます



境界条件の影響を受けない





おそらくこれはJUnit-QuickCheckライブラリの特定の機能ですが、他の言語ではこの状況のほうが優れています。 または、テスト用に選択した特定の例の機能ですか。 それでも、これは、ライブラリが私たちのために有用に選択するランダムな値に常に軽く依存するべきではないことを示しています。 それでも、頭を使って一生懸命考え、書かれたコードの正確さを再確認する必要があります。



QuickCheckはTDDにも使用できます!





感覚は異なりますが、非常に現実的です。 理論がより少ない(そしてそれぞれがより多くのケースをテストする)という事実により、実用的なコードに導くテストメソッドのチェーンを構築するのは簡単です。 一方、コードを新たに追加された理論を通過させるためにあまりにも大きなステップを踏む必要がある場合、これは問題になります。 しかし、人々は古典的なTDDでそのような問題に遭遇します(そしてそれらを解決する方法を見つけるか、原理的にTDDを恐れ始めます)。



コードをテストする場合、従来のテストケースとQuickCheckスタイルのパラメーター化された理論の組み合わせが適切に機能する可能性があります。 私は間違いなくこの分野で私の研究を続け、興味深い発見を共有しようとします。



All Articles