無数の値を使ってコードをテストする方法

著者:Johannes Link

2019年8月20日

JUnitなどのツールを使って単体テストを記述することは、コードの品質を確保するうえで欠かせない技術です。しかし、発生する可能性がある問題すべてを確認するために多くのテスト・ケースが必要となる関数の場合、テストは面倒でエラーが起こりやすくなります。その救世主となり得るのがプロパティベース・テスト(PBT)です。PBTにより、テスト・ケースを多数書く作業から解放されます。本記事では、PBTとは何か、JUnit 5プラットフォームでPBTを使用する方法、そしてどうすればサンプルベース・テストの拡張、場合によっては置き換えが可能かについて説明します。

 

サンプルベース・テスト

個々の測定値を受信(receive)し、それぞれの出現頻度を数えて集計するAggregatorクラスを扱っているとします。オブジェクトが意図どおりに動作するかどうかを確認するために、次の簡単なJUnit 5テストを使用できます。

import java.util.*;
import org.junit.jupiter.api.*;
class AggregatorTests {
    @Test
    void tallyOfSeveralValues() {
        Aggregator aggregator = new Aggregator();
        aggregator.receive(1);
        aggregator.receive(2);
        aggregator.receive(3);
        aggregator.receive(2);
        Map<Integer, Integer> tally = aggregator.tally();
        Assertions.assertEquals(1, (int) tally.get(1));
        Assertions.assertEquals(2, (int) tally.get(2));
        Assertions.assertEquals(3, (int) tally.get(1));
    }
}

多くの場合、この種のテストはサンプルベースと呼ばれます。具体的な入力例を使い、特定の状況において、生成された出力が意図した結果と一致しているかどうかを確認するからです。ほとんどの開発者は、何年あるいは何十年も似たようなテストを書いています。通常、一般的なプログラミング・エラーはそのようなテストでうまく検出できます。しかし、筆者の心の奥には、いつも1つの悩みの種がありました。Aggregatorが5つの測定値でも動作することは、どうすれば確認できるでしょうか。5,000個の要素がある場合や要素が何もない場合、負の値などもテストすべきなのでしょうか。虫の居どころが悪い日なら、自分のコードや同僚の開発者のコードに対する疑念が限りなく生じてきます。

 

プロパティ

しかし、正確さに関するこの疑問には、別の角度から迫ることができます。どんな事前条件や制約(たとえば、入力パラメータの範囲)であれば、テスト対象の機能に対して特定の事後条件(計算結果)が得られるでしょうか。また、その過程で違反してはいけない不変量はどれでしょうか。

事前条件と、期待される特性の組合せを、プロパティと呼びます。

それでは、Aggregatorのいくつかのプロパティを簡単な日本語で表現してみます。

  • すべての測定値は、集計(tally)にキーとして出現する

  • 測定されなかった値は、集計に出現しない

  • 集計に出現した回数の合計は、受信した測定値の数と等しくなる

  • 測定の順序によって集計結果が変わることはない

最初の文から3番目の文までは、いずれもかなり一般的な宣言です。最後の文が成立するためには、少なくとも2つの測定値が必要です。それぞれのプロパティは、要素リストが空でも、かなり長くても、その長さにかかわらず、任意の要素リストに適用できます。測定値は、Integer型の範囲全体に広がる可能性があり、重複することもあります。

 

プロパティ・テストの自動化

それでは、どうすれば自動テストにプロパティを使えるのでしょうか。宣言そのものは、コードに変換できそうです。最初のプロパティをJavaメソッドで表すのは簡単で、次のようになります。

boolean allMeasuredValuesShowUpAsKeys(List<Integer> measurements) {
    Aggregator aggregator = new Aggregator();
    measurements.forEach(aggregator::receive);
    return measurements.stream()
        .allMatch(m -> aggregator.tally().containsKey(m));
}

実際に自動テストを行うために足りないのは、一連の入力リストを生成する方法と、メソッドにそのリストを提供する方法、そして条件がfalseを返したらすぐにテストを失敗させる方法です。これをすべて標準のJUnitにより行うこともできますが、専用の生成ロジックが多数必要になります。また、単純なケースは実現できますが、生成する値が複雑になり、ドメイン固有性が高まった場合、すぐに厄介になります。そこで、別のテスト・エンジンであるjqwikを使います。

jqwikを使う場合、先ほどのコードを実行可能なプロパティにするために必要となるのは、次の微調整だけです。

import java.util.*;
import net.jqwik.api.*;
class AggregatorProperties {
    @Property
    boolean allMeasuredValuesShowUpAsKeys(
        @ForAll List<Integer> measurements)
   {
        Aggregator aggregator = new Aggregator();
        measurements.forEach(aggregator::receive);
        return measurements.stream()
         .allMatch(m -> aggregator.tally().containsKey(m));
   }
}

このコードを詳しく見てみます。

  • プロパティは、コンテナ・クラスの中のメソッドです。メソッドには、わかりやすい名前をつけるべきです。すべての測定値がキーとして出現することを示すallMeasuredValuesShowUpAsKeysという名前では、プロパティの意図が適切に要約されています。

  • メソッドをプロパティ・メソッドとしてマークするためには、IDEやビルド・ツールでプロパティ・メソッドとして認識されるように、@Propertyアノテーションを付加する必要があります(IDEやビルド・ツールがJUnitプラットフォームをサポートしている場合)。

  • パラメータに@ForAllアノテーションを付加することで、フレームワークにインスタンスを生成させたいことをjqwikに伝えることができます。パラメータの型であるList<Integer>は、基本的な事前条件と見なされます。

  • プロパティの必要条件を伝えるもっとも簡単な形は、ブール値を返すことです。別の方法として、AssertJやJUnit 5自体などの任意のアサーション・ライブラリを使うことができます。

jqwikプロパティの実行が成功した場合、JUnitのテストが成功したときのように、何も起きません。他に指示を与えていない場合、jqwikでは異なる入力パラメータを使って各プロパティ・メソッドが1,000回ずつ実行されます。必要に応じて、この数はいくらでも多くすることも少なくすることもできます。

 

失敗するプロパティ

失敗するプロパティについて確認するために、リストの3番目にあるプロパティ(集計に含まれる、出現回数の合計は、受信した測定値の数と等しくなる)を取り上げます。

@Property
boolean sumOfAllCountsIsNumberOfMeasurements(
          @ForAll List<Integer> measurements) 
    {
        Aggregator aggregator = new Aggregator();
        measurements.forEach(aggregator::receive);
        int sumOfAllCounts = 
            aggregator.tally().values()
         .stream().mapToInt(i -> i).sum();
      return sumOfAllCounts == measurements.size();
}

現在の集計関数にはバグが含まれているため、どの値も一度しかカウントされません。この場合、jqwikはそのバグを検知する例を見つけてくれるはずです。そして、そのとおりになります。次に出力を示します。

org.opentest4j.AssertionFailedError: 
  Property [AggregatorProperties:sumOfAllCountsIsNumberOfMeasurements] 
    falsified with sample [[0, 0]]
                              |--------------------jqwik-----------------
tries = 11                    | # of calls to property
checks = 11                   | # of not rejected calls
generation-mode = RANDOMIZED  | parameters are randomly generated
seed = -2353742209209314324   | random seed to reproduce generated values
sample = [[0, 0]]
originalSample = [[2068037359, -1987879098, 1588557220, -130517, ...]]

かなり多くの情報が出力されました。テストの試行回数(tries)、実際にテストを実行した回数(checks)、乱数のシード(seed)、不成立のサンプル(sample)が表示されているのがわかります。また、場合によって役立つこともあるその他の情報も表示されています。

この例では、jqwikによって、重複した要素を持つリストが生成され、うまくバグが突き止めてられています。自分でサンプル・テストを書いた場合、このパターンを考えついたことも、考えつかなかったこともあるかもしれません。PBTライブラリを使うことで、追加のサンプルを考え出さなくとも、掘り下げたテストが実現しました。ただし、プロパティベース・テストで行われないことには注意する必要があります。このテストでは、プロパティが正しいことは証明できません。行われるのは、プロパティが成立しないサンプルを見つけようとすることだけです。

 

jqwikとJUnit 5の統合

jqwikはスタンドアロン・フレームワークではなく、JUnit 5にフックするテスト・エンジンです。JUnit 5は、テストを書いて実行する最新のアプローチを提供するだけでなく、実にさまざまなテスト・エンジン向けのプラットフォームとして設計されています。jqwikの設計上のメリットは、IDEやビルド・ツールに必要なのがJUnitプラットフォームとの統合だけで、個々のテスト・エンジンとの統合は必要ない点です。テスト・エンジンの開発者は、テスト仕様を検出して実行するパブリックAPIなどに悩む必要がないため、この点は大きなメリットになります。テスト・エンジンでは、自動的にIDEとビルド・ツールのサポートが継承されます。

さらに、JUnitプラットフォームでは、任意の数のエンジンを並行して使うこともできます。必要なのは、MavenやGradleの設定に依存性を1つ追加することだけです。Javaでもっとも多く使われている2つのIDEであるIntelliJとEclipseでは、現時点で十分にJUnit 5をサポートしています。GradleとMavenも同様です。

 

その他のjqwik機能

現在、jqwikには、プロパティベース・テストを実施する際に求められる必須機能がすべて搭載されています。たとえば、多くの標準の型をすぐに生成できます。NumberStringCharacterBooleanの各型に加え、組込みのコンテナ型であるListSetStreamIteratorOptionalが認識されます。そのため、Set<List<String>>型のパラメータを使用でき、jqwikで自動的に文字列のリストのセットを生成してくれます。

プロパティ・メソッドのシグネチャでの値生成に直接的に影響を与えられるアノテーションが多数存在します。そこで、次のようにしてSet<List<String>>型を拡張します。 

@ForAll @Size(3) Set<List<
     @CharRange(min='a', max='f') String>>
        aSetOfListsOfStrings

この変更により、aからfまでの文字だけで構成される文字列のリストを含み、長さが3であるセットだけが生成されます。

値は、完全にランダムに生成されるわけではありません。空文字列、数値0、範囲の最大値および最小値といった、極端なケースや一般的な境界値が考慮されます。制約が十分に厳しければ、jqwikは可能な値の組合せすべてを網羅的に生成してくれます。

 

プログラムによる値生成

場合によっては、jqwikにデフォルトのジェネレータがないクラスを扱うこともあるでしょう。また、プリミティブタイプに関するドメイン固有の制約が特殊なため、既存のアノテーションでは不十分なこともあります。そういった場合は、パラメータ・ジェネレータの提供をテスト・コンテナ・クラスの別のメソッドに委譲できます。次に示すのは、プロバイダ・メソッドを使ってドイツの郵便番号を生成する方法の例です。

@Property @Report(Reporting.GENERATED)
void letsGenerateZipis(@ForAll("germanZipi") String zipi) { }
@Provide
Arbitrary<String> germanZipi() {
    return Arbitraries.strings()
        .withCharRange('0', '9')
        .ofLength(5)
        .filter(z -> !z.startsWith("00"));
}

@ForAllアノテーションのStringの値は、同じクラス内にあるメソッド名を参照しています。このメソッドは、@Provideアノテーションが付加され、Arbitrary<T>型のオブジェクトを返すものとする必要があります。Tには、提供するパラメータの型を静的に記述します。

通常、パラメータ提供メソッドでは、最初にArbitrariesの静的メソッドを呼び出します。そしてほとんどの場合、その後に1つまたは複数のフィルタリング、マッピング、または組合せのアクションを行います。次のセクションでは、この点について説明します。


❝ランダムな生成によって起きる問題の1つは、ランダムに選択された不成立のサンプルと、失敗するプロパティの背後にある問題との関係が多くのノイズによって埋もれることが多いという点にあります。❞


フィルタリング、マッピング、組合せ

すべての値生成のベースタイプとして、Arbitraryクラスにはいくつかのデフォルト・メソッドがあり、それらのメソッドを使って生成動作を変更することができます。通常は、Arbitrariesクラスに存在する静的ジェネレータ関数のいずれかを最初に呼び出します。ほとんどのジェネレータ関数は、Arbitraryの特定のサブタイプを返します。このサブタイプにより、美しいインタフェースを通じて追加設定を行うことができます。

ここでは、1から300までの整数のうち、6の倍数である数を生成するものとします。以下に示すのは、これを実現する2つの方法です。

Arbitraries.integers()
           .between(1, 300)
           .filter(anInt -> anInt % 6 == 0)

次のように記述することもできます。

Arbitraries.integers().between(1, 50)
           .map(anInt -> anInt * 6)

いずれの方法が優れているでしょうか。優劣がスタイルや可読性の問題にすぎない場合もありますが、それ以外の場合、選択する方法によってはパフォーマンスに影響を及ぼす可能性もあります。先ほどの2つの選択肢を比べた場合、与えられた仕様に近いのは前者であることがわかりますが、生成されたすべての値のうち6分の5がフィルタリングによって破棄されます。そのため、後者の方が効率的ですが、多少わかりにくくなっています。通常、プリミティブ値の生成は非常に高速であるため、効率性よりも可読性の方が重視されます。

多くの場合、実際のドメイン・オブジェクトには、ほとんど関連性のない別個の部分がいくつか存在します。たとえば、人を表すPersonには、姓と名が必要でしょう。 そのため、無関係のベース・ジェネレータを複数使用した後に、それらの生成結果を組み合わせるのがよいでしょう。次の例では、2つのArbitraryエンティティを組み合わせて1つにすることで、ドメイン・クラスPerson用のArbitraryを作成しています。

@Provide
Arbitrary<Person> validPerson() {
    Arbitrary<String> firstName = Arbitraries.strings()
        .withCharRange('a', 'z')
        .ofMinLength(2).ofMaxLength(10)
        .map(this::capitalize);
    Arbitrary<String> lastName = Arbitraries.strings()
        .withCharRange('a', 'z') 
        .ofMinLength(2).ofMaxLength(20);
    return Combinators
        .combine(firstName, lastName).as(Person::new);
}

このテクニックを使用して、8つまでのArbitraryエンティティを1つに組み合わせることができます。必要に応じて、独自のArbitraryエンティティを登録し、ドメインの型のパラメータすべてに自動的に適用されるようにすることもできます。

 

縮小の重要性

ランダムな生成によって起きる問題の1つは、ランダムに選択された不成立のサンプルと、失敗するプロパティの背後にある問題との関係が多くのノイズによって埋もれることが多いという点にあります。この懸念については、次に示す簡単な例で説明できます。

@Property(shrinking = ShrinkingMode.OFF)
boolean rootOfSquareShouldBeOriginalValue(
     @Positive @ForAll int anInt ) 
{
    int square = anInt * anInt;
    return Math.sqrt(square) == anInt;
}

このプロパティは、2乗した値の平方根は元の値に等しくなければならないという自明な数学的概念を表しています。最初の行では、shrinkingアノテーション属性を使って縮小をオフにしています。このプロパティを実行すると、次のようなメッセージが表示されて失敗します。

originalSample = [1207764160],
sample = [1207764160]
org.opentest4j.AssertionFailedError:
    Property [rootOfSquareShouldBeOriginalValue] 
        falsified with sample [1207764160]

jqwikで見つかった、失敗するサンプルは無作為に選択したものです。この数値自体からは、失敗の原因に関する明確な手がかりは得られません。かなり大きい数だという事実も、偶然のことかもしれません。この時点で、ログを追加するか、デバッガを起動してこの問題に関する情報をさらに収集するかのいずれかを行うことになるでしょう。


❝PBTは、関数、コンポーネント、プログラム全体に対して汎用的で望ましいプロパティを見つけることができるという考え方に基づいています。多くの場合、そういったプロパティは、ランダムに生成されたテスト・データでは成立しないでしょう。❞


それでは、別の方法をとり、ShrinkingMode.FULLを使って縮小をオンにしたうえでプロパティを再実行してみます。失敗するのは同じですが、見つかった不成立のsampleは変化していることがレポートからわかります。

sample = [46341]
originalSample = [1207764160]

46,341という数はかなり小さく、元のサンプルとも違う数です。jqwikは、1,207,764,160で失敗した後も、同じように失敗する、より簡単なサンプルを探し続けます。この検索フェーズを縮小と呼びます。元のサンプルから始め、小さい数を探そうとするからです。

それでは、このケースで46,341が特殊な点は何でしょうか。皆さんの想像どおりかもしれませんが、46,341の2乗は2,147,488,281で、Integer.MAX_VALUEよりも少しだけ大きな値になります。そのため、整数のオーバーフローが発生します。つまり、前述のプロパティが成立するのは、Integer.MAX_VALUEの平方根までの整数のみです。

縮小は、PBTにおける重要なトピックです。縮小により、失敗したプロパティの分析の多くがはるかに簡単になるからです。また、PBTの不確定性も減らしてくれます。ただし、優れた縮小を実装するのは、複雑なタスクです。理論的に言えば、検索空間が非常に大きい場合があるという、検索問題に遭遇します。掘り下げた検索には時間がかかるため、縮小を効率的かつ高速に行うために多くの経験則を適用します。

 

プロパティ検出のパターン

PBTの最初の一歩を踏み出すとき、適切なプロパティを探すのは難しいタスクだと感じるかもしれません。プロパティの代表的な例に比べれば、現実世界でのプロパティの特定には別の種類の考え方が要求されます。プロパティを探す際に参考になる一連の便利なパターンが役立つかもしれません。幸いにも、そのすべてを自力で探す必要はありません。PBTが誕生してからしばらく時間がたっているため、小さいながらもよく知られている、プロパティベース・テストのパターンのコレクションが存在します。筆者個人のリストは当然ながら未完のものですが、ここではいくつかの典型的なソースを挙げてみます。

プロパティとしてのビジネス・ルール:ドメイン仕様自体をプロパティとして解釈し、記述できる場合があります。たとえば、「年間取引高がXユーロより大きく、請求書の額がZユーロよりも大きいすべての顧客に対し、Y%の追加値引きを適用する」というビジネス・ルールを考えてみてください。これは、XとZに対してArbitrariesを使い、計算された値引率が実際にYであることを確認することで、そのままプロパティに変換できます。

逆関数:関数に逆関数がある場合、まずその関数を適用し、次に逆関数を適用すれば、元の入力が返されます。

べき等関数:べき等関数を複数回適用しても、結果は変わりません。たとえば、リストを2回並べ替えても、結果は変わりません。

不変関数:コードのプロパティには、ロジックを適用しても変わらないものがあります。たとえば、ソートやマッピングでは、コレクションのサイズは変わりません。また、リストから値をフィルタで除外しても、残った値の順序は元の順序と同じです。

交換可能性:一連の関数が交換可能である場合、関数を適用する順番を変えても最終的な結果は変わりません。たとえば、ソートしてからフィルタリングしても、フィルタリングしてからソートしても、同じ結果となります。

テスト・オラクル:テスト対象の関数の代替実装を知っている場合もあるでしょう。その場合は、テスト・オラクルとしてその実装を使うことができます。関数を使った結果はすべて、元の実装と代替実装で同じになるはずです。代替実装のサンプルをいくつか示します。

  • 簡単で遅いものと複雑だが速いもの
  • パラレルとシングルスレッド
  • 自作と商用
  • 古いもの(リファクタリング前)と新しいもの(リファクタリング後)

計算しにくいが検証しやすい:ロジックによっては、実行することは難しいが確認するのは簡単というものがあります。たとえば、素数を探す作業と、素数かどうかを確認する作業について考えてみてください。

帰納法(最初に小さな問題を解くこと):ドメイン・チェックを、ベース・ケースと、そのベース・ケースから導出された汎用的なルールに分割できるかもしれません。

ステートフル・テスト:特にオブジェクト指向の世界では、オブジェクトの動作を、有限の状態セットと、その状態を変化させるアクションがあるステート・マシンとして記述できることがよくあります。状態遷移空間の調査は、PBTの重要なユースケースです。そのため、jqwikはこれに対する特別なサポートを提供しています。

ファジング:予期しないさまざまな入力データを大量に投入しても、コードは決して誤動作すべきではありません。そのため、このパターンの主な考え方は、多種多様な入力を生成し、テスト対象の関数を実行して、以下のことを確認するというものです。

  • 例外が発生しない。少なくとも、予期しない例外は発生しない。
  • HTTPリクエストに対してリターン・コード5xxが発生しない。さらに進んで、常に2xxステータスを要求する場合もある。
  • すべての戻り値が有効である。
  • ランタイムに適切なしきい値が設定されている。

ファジングは、既存のコードやシステムの堅牢性を詳細に調べる場合にさかのぼって行われることが多い手法です。

前述のパターンをコードに適用できるようになるためには、訓練が必要です。しかし、こういったパターンは、テスト記述者がスランプに打ち勝つための優れた開始点となるでしょう。自分のコードのプロパティについて考えれば考えるほど、サンプルベース・テストからプロパティベース・テストを導き出すチャンスを認識するようになります。プロパティベース・テストは、ときには補完的役割を果たし、ときには古いテストを置き換えるものとなります。

 

まとめ

PBTは新しい技術ではなく、HaskellやErlangなどの言語では、10年超にわたって効果的に使われ続けています。PBTは、関数、コンポーネント、プログラム全体に対して汎用的で望ましいプロパティを見つけることができるという考え方に基づいています。多くの場合、そういったプロパティは、ランダムに生成されたテスト・データでは成立しないでしょう。

jqwikは、JVMベースのプロパティ・テスト・エンジンです。JUnit 5プラットフォーム用に構築されているため、あらゆる最新のIDEやビルド・ツールとの統合がシームレスです。(まだ)JUnit 5を使っていない方は、いくつかの代替ツールも利用できます。本記事は、PBTの表面をなぞったにすぎません。さらに深く知りたい方は、こちらのブログ・シリーズからご覧ください。

Java Magazine December 2019の他の記事

Arquillian:簡単なJakarta EEテスト
ArchUnitでアーキテクチャの単体テストを行う
新しいJava Magazine
作ってみよう:自分だけのテキスト・エディタ(パート1)
クイズに挑戦:Collectorsの使用(上級者向け)
クイズに挑戦:ループ構造の比較(中級者向け)
クイズに挑戦:スレッドとExecutor(上級者向け)
クイズに挑戦:ラッパー・クラス(中級者向け)
書評:Core Java, 11th Ed. Volumes 1 and 2


Johannes Link

Johannes Link(@johanneslink):およそ25年にわたってプロのソフトウェア開発者として活躍。2001年という早い時期からテスト駆動開発のとりこになり、その関連書籍も執筆している。初期のJUnit 5における中心的コミッターの1人で、jqwikのメイン開発者でもある。