JUnit 5.7によるテストパラメータ化: @EnumSourceの深掘り

パラメータ化されたテストは、開発者がさまざまな入力値でコードを効率的にテストすることができます。”JUnitテスト“の分野では、経験豊富なユーザーも長い間、これらのテストの実装の複雑さに取り組んできました。しかし、JUnit 5.7のリリースにより、新しい時代のテストパラメータ化が始まり、開発者に対してファーストクラスのサポートと強化された機能が提供されるようになりました。JUnit 5.7がパラメータ化されたテストにどのような魅力的な可能性をもたらすかを探ってみましょう!

JUnit 5.7ドキュメントからのパラメータ化サンプル

ドキュメントからのいくつかの例を見てみましょう:

Java

 

@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
    assertTrue(StringUtils.isPalindrome(candidate));
}

@ParameterizedTest
@CsvSource({
    "apple,         1",
    "banana,        2",
    "'lemon, lime', 0xF1",
    "strawberry,    700_000"
})
void testWithCsvSource(String fruit, int rank) {
    assertNotNull(fruit);
    assertNotEquals(0, rank);
}

@ParameterizedTest
@MethodSource("stringIntAndListProvider")
void testWithMultiArgMethodSource(String str, int num, List<String> list) {
    assertEquals(5, str.length());
    assertTrue(num >=1 && num <=2);
    assertEquals(2, list.size());
}

static Stream<Arguments> stringIntAndListProvider() {
    return Stream.of(
        arguments("apple", 1, Arrays.asList("a", "b")),
        arguments("lemon", 2, Arrays.asList("x", "y"))
    );
}

@ParameterizedTestアノテーションは、パラメータをどこから取得するかを説明する複数の提供されたソースアノテーションのいずれか伴わなければなりません。パラメータのソースはしばしば「データプロバイダー」と呼ばれます。

I will not dive into their detailed description here: the JUnit user guide does it better than I could, but allow me to share several observations:

  • @ValueSourceは、単一のパラメータ値を提供するだけに制限されています。言い換えれば、テストメソッドは1つの引数以上を持つことはできず、”使用できるタイプも制限されています“。
  • 複数の引数を渡すことは、@CsvSourceによって多少対応されています。各文字列をレコードにパースし、その後引数の字段ごとに渡します。しかし、長い文字列や多くの引数があると読みにくくなることがあります。使用できるタイプもまた制限されています — その詳細については後述します。
  • 実際の値をアノテーションで宣言するソースは、コンパイル時定数に制限されています(Javaアノテーションの制限であり、JUnitではありません)。
  • @MethodSourceおよび@ArgumentsSourceは、メソッド引数として渡される(型のない)n-タプルのストリーム/コレクションを提供します。n-タプルのシーケンスを表現するためにさまざまな実際の型がサポートされていますが、メソッドの引数リストに適合する保証はありません。この種のソースは、追加のメソッドやクラスを必要としますが、テストデータをどこでどのように取得するかには制限がありません。

ご覧の通り、利用可能なソースの種類は、シンプルなもの(使いやすく機能に制限があります)から、動作するために更多信息を必要とする非常に柔軟なものまでさまざまです。

  • 余談 — これは一般的に良い設計の兆候です:基本的な機能には少しのコードが必要であり、より高度なユースケースを可能にするために追加の複雑さを加えることが正当化されます。

この仮定のシンプルから柔軟な連続に合わないように見えるのは、@EnumSourceです。2つの値を持つ4つのパラメーターセットの非平凡な例を見てください。

  • 注 — @EnumSourceは、エnumの値を単一のテストメソッドパラメータとして渡しますが、概念的には、テストはエnumのフィールドによってパラメータ化されており、パラメータの数に制限はありません。
Java

 

    enum Direction {
        UP(0, '^'),
        RIGHT(90, '>'),
        DOWN(180, 'v'),
        LEFT(270, '<');

        private final int degrees;
        private final char ch;

        Direction(int degrees, char ch) {
            this.degrees = degrees;
            this.ch = ch;
        }
    }

    @ParameterizedTest
    @EnumSource
    void direction(Direction dir) {
        assertEquals(0, dir.degrees % 90);
        assertFalse(Character.isWhitespace(dir.ch));
        
        int orientation = player.getOrientation();
        player.turn(dir);
        assertEquals((orientation + dir.degrees) % 360, player.getOrientation());
    }

考えただけです:ハードコードされた値のリストは柔軟性を大幅に制限します(外部や生成されたデータがないため)、enumを宣言するために必要な追加コードの量は、例えば@CsvSourceよりも非常に verbose な選択となります。

しかし、これはただの第一印象です。Javaのenumの本当の力を活用することで、どれほど洗練されたものになるか見てみましょう。

  • 余談:この記事は、製品コードの一部としてのenumの検証には触れていません。もちろん、それらは検証方法を選ぶどうであれ宣言する必要がありました。代わりに、どのタイミングでどのようにしてテストデータを表現するか、enumの形で焦点を当てています。

いつ使うべきか

enumが他の選択肢よりもより良いパフォーマンスを発揮する状況があります:

各テストごとに複数のパラメータ

単一のパラメータだけで十分な場合は、@ValueSourceを超えて複雑にする必要はありません。しかし、複数のパラメータが必要になるたびに -— 例えば、入力と期待結果 -— @CsvSource, @MethodSource/@ArgumentsSource または @EnumSourceに頼らなければなりません。

ある意味、enumは任意のデータフィールドを「持ち込む」ことができます。

したがって、将来テストメソッドのパラメータを追加する必要が生じた場合、既存のenumにフィールドを追加するだけで、テストメソッドのシグネチャを触ることなく済みます。これはデータプロバイダを複数のテストで再利用する際に非常に価値のあるものになります。

他のソースでは、enumがデフォルトで持つ柔軟性を持たせるために、ArgumentsAccessorまたはArgumentsAggregatorを使用する必要があります。

型安全性

Java開発者にとって、これは非常に重要です。

CSV(ファイルまたはリテラル)から読み取ったパラメータ、@MethodSourceまたは@ArgumentsSourceは、パラメータの数とその型がシグネチャに一致するというコンパイル時の保証を提供しません。

もちろん、JUnitは実行時に不満を述べますが、IDEからのコードアシスタンスは期待できません。

前述のとおり、同じパラメータを複数のテストで再利用する場合にもこれは積み重なります。型安全なアプローチを取ることで、将来パラメータセットを拡張する際に非常に有益です。

カスタムタイプ

これは主にテキストベースのソース、例えばCSVからデータを読み取るものに対する利点です — テキストにエンコードされた値はJavaの型に変換する必要があります。

CSVレコードからインスタンス化するカスタムクラスがあれば、ArgumentsAggregatorを使用してそれを行うことができます。しかし、データの宣言は依然として型安全ではありません — メソッドシグネチャと宣言されたデータの不一致は、引数を「集約」する際に実行時に現れます。また、アグリゲータクラスを宣言することは、パラメータ化を動作させるために必要な追加のサポートコードを追加します。私たちは、余計なコードを避けるために@CsvSource@EnumSourceよりも好んで使用してきました。

ドキュメント化可能

enumソースには、パラメータセット(enumインスタンス)とそれらが含むすべてのパラメータ(enumフィールド)の両方に対するJavaシンボルがあります。これにより、より自然な形式でドキュメントを附加するための簡単な場所、JavaDoc.

ドキュメントを他の場所に配置することはできませんが、それが記述するものから遠く離れて配置されるため、見つけるのが難しくなり、古くなりやすくなります。

さらに。

今:Enumは、クラスです。

多くの若手開発者がJavaのenumがどれほど強力であるかをまだ認識していないように感じます。

他のプログラミング言語では、実際にはglorified constantsに過ぎませんが、Javaでは、完全なクラスの利点の多くを持つフライウェイトデザインパターンの便利な小さな実装です。

なぜ、それは良いことでしょうか?

テストフィクスチャ関連の動作

他のクラス同様、enumにもメソッドを追加できます。

enumのテストパラメータがテスト間で再利用される場合、同じデータで少し異なる方法でテストされるため、これは便利です。パラメータを効率的に扱うためには、 знач的なコピー&ペーストをせずに、those testsの間でヘルパーコードを共有する必要があります。

これは、ヘルパークラスと数個の静的メソッドで「解決」することではありません。

  • 追記:この設計は機能欲しさという問題に苦しんでいます。テストメソッド−またはさらに悪いことに、ヘルパークラスのメソッド−は、データをenumオブジェクトから引き出して、そのデータに対するアクションを実行する必要があります。

これは手続き型プログラミングでの(唯一の)方法ですが、オブジェクト指向の世界ではもっと良い方法があります。

“ヘルパー”メソッドをenum宣言そのものに直接記述することで、データがある場所にコードを移動させます。または、OOPの言葉で言うと、ヘルパーメソッドはenumとして実装されたテストフィクスチャの”振る舞い”になります。これにより、コードがより自然的になります(インスタンス上の意味のあるメソッドを呼ぶのは、データを回す静的メソッドよりも優れています)し、enumパラメータをテストケース間で再利用するのもより簡単になります。

継承

Enumsは(デフォルトの)メソッドを持つインターフェースを実装できます。適切に使用すると、複数のデータプロバイダー−複数のenum間で振る舞いを共有するために利用できます。

すぐに思い浮かぶ例としては、ポジティブテストとネガティブテストのための別個のenumがあります。それらが似たようなテストフィクスチャを表している場合、共有する振る舞いがある可能性が高いです。

話は安い

これを仮想的なソースコードファイルコンバータのテストスイートで説明しましょう。Python 2から3への変換を行うものに似ています。

信頼性のある包括的なツールが何をしているかを本当に確信するためには、言語のさまざまな側面を示す大量の入力ファイルを用意し、変換結果を比較するための一致するファイルを探すことになるでしょう。それ以外に、問題のある入力に対してユーザーにどのような警告/エラーが表示されるかを確認する必要があります。

これは多数のサンプルを確認するためにパラメータ化されたテストに自然に適していますが、データがやや複雑なため、シンプルなJUnitパラメータソースにはあまり合いません。

以下を参照してください:

Java

 

    enum Conversion {
        CLEAN("imports-correct.2.py", "imports-correct.3.py", Set.of()),
        WARNINGS("problematic.2.py", "problematic.3.py", Set.of(
                "Using module 'xyz' that is deprecated"
        )),
        SYNTAX_ERROR("syntax-error.py", new RuntimeException("Syntax error on line 17"));
        // 多くの他のもの...

        @Nonnull
        final String inFile;
        @CheckForNull
        final String expectedOutput;
        @CheckForNull
        final Exception expectedException;
        @Nonnull
        final Set expectedWarnings;

        Conversion(@Nonnull String inFile, @Nonnull String expectedOutput, @NotNull Set expectedWarnings) {
            this(inFile, expectedOutput, null, expectedWarnings);
        }

        Conversion(@Nonnull String inFile, @Nonnull Exception expectedException) {
            this(inFile, null, expectedException, Set.of());
        }

        Conversion(@Nonnull String inFile, String expectedOutput, Exception expectedException, @Nonnull Set expectedWarnings) {
            this.inFile = inFile;
            this.expectedOutput = expectedOutput;
            this.expectedException = expectedException;
            this.expectedWarnings = expectedWarnings;
        }

        public File getV2File() { ... }

        public File getV3File() { ... }
    }

    @ParameterizedTest
    @EnumSource
    void upgrade(Conversion con) {

        try {
            File actual = convert(con.getV2File());
            if (con.expectedException != null) {
                fail("No exception thrown when one was expected", con.expectedException);
            }
            assertEquals(con.expectedWarnings, getLoggedWarnings());
            new FileAssert(actual).isEqualTo(con.getV3File());
        } catch (Exception ex) {
            assertTypeAndMessageEquals(con.expectedException, ex);
        }
    }

列挙型の使用はデータの複雑さに制限をかけません。ご覧の通り、列挙型にはいくつかの便利なコンストラクタを定義できるので、新しいパラメータセットを宣言するのはきれいでスムーズです。これにより、長い引数リストを使用する必要がなくなり、多くの「空」の値(null、空文字列、またはコレクション)で埋められた引数リストが発生することを防ぎます。7番目の引数が実際に何を示すのかわからないnullの1つであることを考えます。

列挙型が複雑な型(SetRuntimeException)を使用することを許可し、制限や魔法の変換なしで使用できることに注目してください。このようなデータを渡すことも完全に型安全です。

さて、あなたが何を思っているかはわかっています。これは非常に言葉が多すぎます。しかし、ある程度まではそうです。現実的には、確認するデータサンプルが多くあるため、ボイラープレートコードの量は比較的少なくなります。

また、関連するテストを同じ列挙型とそのヘルパーメソッドを利用して書く方法もご覧ください:

Java

 

    @ParameterizedTest
    @EnumSource
    // 既にアップグレードされたファイルを再アップグレードしても、常に正常にパスし、変更は行われず、警告も表示されません。
    void upgradeFromV3toV3AlwaysPasses(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV3File());
    }

    @ParameterizedTest
    @EnumSource
    // アップグレード手順で作成されたファイルをダウングレードすると、警告なしで常に正常にパスするExpected。
    void downgrade(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV2File());
    }

さらに話を続けます。

概念的には、@EnumSourceは個々のテストシナリオに対して複雑でマシンリーダブルな説明を作成するように促し、データプロバイダとテストフィクスチャの境界をぼやかします。

各データセットをJavaシンボル(enum要素)として表現する利点の1つは、それらを個別に使用できることです。データプロバイダ/パラメータ化されたテストの外で完全に使用できます。合理的な名前があり、データと動作が自己完結しているため、読みやすくて整理の良いテストに寄与します。

Java

 

@Test
void warnWhenNoEventsReported() throws Exception {
    FixtureXmls.Invalid events = FixtureXmls.Invalid.NO_EVENTS_REPORTED;
    
    // read()はすべてのFixtureXmlsで共有されるヘルパーメソッドです。
    try (InputStream is = events.read()) {
        EventList el = consume(is);
        assertEquals(Set.of(...), el.getWarnings());
    }
}

今、@EnumSourceは最も頻繁に使用される引数ソースの1つではありません。しませんそして、それは良いことです。過度に使用すると良いことはありませんが、適切な状況では、すべての機能を最大限に活かす方法を知っておくと便利です。

Source:
https://dzone.com/articles/junit5-parameterized-tests-with-enumsource