JUnitのテストケースを並行で実行する

JUnitのテストケースを並行で実行したメモです。

JUnitのテストケースを並行で実行してみました。

JUnitで複数の組み合わせのパターンをテストするテストケースがある場合、
組み合わせが大量の場合だとかなり時間がかかってしまうので、並行で実行出来るようにしたいです。

組み合わせのパターンのテストの場合、パラメータ化テストで実行も出来ますが、並行では出来ないです。
JUnitでパラメータ化テストを実施する - abcdefg.....


TestNGというテスティングフレームワークを使用すると、threadPoolSizeを指定することで
マルチスレッドでテスト出来るようです。

@Test(threadPoolSize = 3)

JUnitでも工夫すれば出来そうなので、試してみました。
Assertjを使用してますが、Hamcrestでも一緒です。

対象のテストケース

対象のテストケースです。
Thread.sleep()で重い処理を想定してます。
本来は何か処理をしてbooleanを返しますが、簡単のために常にtrueを返すようにしています。

private boolean targetTest(String country, String language) {
    try {
        // dummy heavy task
        Thread.sleep(50L);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("test: country=" + country + " lang=" + language);

    return true;
}
Serialにテスト

下記のように複数の組み合わせのパラメータのパターンをテストしようとすると、
組み合わせが膨大になり非常に時間がかかってしまいます。
そしてテストエラーのパターンがあった所で停止してしまい、残りのパターンでの結果は分かりません。

private static final List<String> countries = Arrays.asList(
        "JP", "IS", "IE", "AZ", "AF", "US", "VI", "AS", "AE", "DZ",
        "AR", "AW", "AL", "AM", "AI", "AO", "AG", "AD", "YE", "GB", "IO",
        "VG", "IL", "IT", "IQ", "IR", "IN", "ID", "WF", "UG", "UA", "UZ");

private static final List<String> languages = Arrays.asList(
        "ar", "an", "hy", "as", "av", "ae", "ay", "az", "ba", "bm",
        "eu", "be", "bn", "bh", "bi", "bs", "br", "bg", "my", "ca", "ch",
        "ce", "zh", "zh-CN", "zh-TW", "cu", "cv", "kw", "co", "cr", "cs",
        "da", "dv", "nl", "dz", "en", "en-US", "en-GB", "en-CA", "en-AU");

@Test
public void serialTest() {
    countries.forEach(country -> {
        languages.forEach(lang -> {
            // heavy test
            assertThat(targetTest(country, lang)).isTrue();
        });
    });
}
Executorで並行にテスト

Executorで単純に並行にテストすると、エラーとなったケースが検知できません。

@Test
public void useExecutorTest() throws InterruptedException {
    ExecutorService executorService = Executors.newFixedThreadPool(10);

    List<Callable<Void>> tasks = new ArrayList<>();
    try {
        countries.forEach(country -> {
            languages.forEach(lang -> {
                Callable<Void> task = () -> {
                    assertThat(targetTest(country, lang)).isTrue();
                    return null;
                };
                tasks.add(task);
            });
        });
        executorService.invokeAll(tasks);
    } finally {
        executorService.shutdown();
    }
}
並行テスト用メソッドでテスト

下記のようなstaticメソッドを用意。
引数は下記の通り。

  • targetTest テスト対象のメソッド
  • pairs テスト対象メソッドの引数と戻り値(期待値)のペアのリスト
  • threadPoolSize 並行でテストするスレッド数
  • maxTimeoutSeconds テスト実行のタイムアウト(時間がかかる場合にfailにするため)

引数の組み合わせを実行していき、期待値と比較していきます。
途中でテストエラーとなったパターンと、例外が発生したパターンをListに保持します。

CountDownLatchを使用して、全パターンのテストが実行したかを確認します。
最終的にエラーや例外が発生してかをチェックしています。

public static <T, U> void testConcurrent(final Function<T, U> targetTest, final List<ArgsExpectedPair<T, U>> pairs,
                                   final int threadPoolSize, final int maxTimeoutSeconds) throws InterruptedException {
    // failed test cases
    final List<ArgsExpectedPair<T, U>> testFails = Collections.synchronizedList(new ArrayList<>());
    // failed with an exception test cases
    final List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<Throwable>());
    
    final ExecutorService threadPool = Executors.newFixedThreadPool(threadPoolSize);
    try {
        final CountDownLatch allDone = new CountDownLatch(pairs.size());
        for (final ArgsExpectedPair<T, U> pair : pairs) {
            T args = pair.getArgs();
            U expected = pair.getExpected();
            Runnable task = () -> {
                U result = targetTest.apply(args);
                if (!expected.equals(result)) {
                    testFails.add(pair);
                }
            };

            threadPool.submit(() -> {
                try {
                    task.run();
                } catch (final Throwable e) {
                    exceptions.add(e);
                } finally {
                    allDone.countDown();
                }
            });
        }

        assertThat(allDone.await(maxTimeoutSeconds, TimeUnit.SECONDS))
                .as(" timeout! More than " + maxTimeoutSeconds + "seconds.").isTrue();
    } finally {
        threadPool.shutdownNow();
    }
    assertThat(testFails).as("test failed.").isEmpty();
    assertThat(exceptions).as("failed with exception(s)." + exceptions).isEmpty();
}

引数と結果のペアを保持するためのクラスを用意。

@Data
@AllArgsConstructor
public static class ArgsExpectedPair<A, B> {
    private A args;
    private B expected;
}

引数をラップするクラスを用意。

@Data
@AllArgsConstructor
public static class Locale {
    private String country;
    private String language;
}

テストするパターンの組み合わせ分、引数と結果のペアのリストを作成して、
対象のテストをFunctionインターフェースでラップします。

引数と結果のペア、対象テスト、スレッド数、タイムアウトを指定してテストを実行。

@Test
public void concurrentTest() throws Exception {
    List<ArgsExpectedPair<Locale, Boolean>> pairs = new ArrayList<>();
    countries.forEach(country -> {
        languages.forEach(lang -> {
            Locale locale = new Locale(country, lang);
            pairs.add(new ArgsExpectedPair<>(locale, true));
        });
    });

    Function<Locale, Boolean> targetTestWrapper = (locale) -> {
        return targetTest(locale.country, locale.getLanguage());
    };

    testConcurrent(targetTestWrapper, pairs, 10, 10);
}

実行結果。
テストがパスします。

test: country=JP lang=ar
test: country=JP lang=an
test: country=JP lang=ba
test: country=JP lang=hy
test: country=JP lang=ae
test: country=JP lang=ay
          :
          :
          :
test: country=UZ lang=dv
test: country=UZ lang=dz
test: country=UZ lang=en
test: country=UZ lang=en-GB
test: country=UZ lang=en-CA
test: country=UZ lang=en-AU


テストエラーとなるパターンがある場合。
テストがfailとなり、エラーとなったパターンが出力されます。

test: country=JP lang=an
test: country=JP lang=ar
test: country=JP lang=ba
test: country=JP lang=ae
test: country=JP lang=av
test: country=JP lang=as
          :
          :
          :
test: country=UZ lang=nl
test: country=UZ lang=en-GB
test: country=UZ lang=en-US
test: country=UZ lang=en-CA
test: country=UZ lang=en-AU
test: country=UZ lang=en

java.lang.AssertionError: [test failed.]
Expecting empty but was:<[MultipleThreadsTest.ArgsExpectedPair(args=MultipleThreadsTest.Locale(country=JP, language=be), expected=true),
    MultipleThreadsTest.ArgsExpectedPair(args=MultipleThreadsTest.Locale(country=AI, language=bs), expected=true),
    MultipleThreadsTest.ArgsExpectedPair(args=MultipleThreadsTest.Locale(country=IQ, language=en), expected=true)]>


テストで例外が発生するパターンがある場合。
テストがfailとなり、例外が出力されます。

test: country=JP lang=av
test: country=JP lang=ar
test: country=JP lang=hy
test: country=JP lang=an
test: country=JP lang=as
test: country=JP lang=az
          :
          :
          :
test: country=UZ lang=en
test: country=UZ lang=en-CA
test: country=UZ lang=en-US
test: country=UZ lang=dz
test: country=UZ lang=en-GB
test: country=UZ lang=en-AU

java.lang.AssertionError: [failed with exception(s).[java.lang.RuntimeException]]
Expecting empty but was:<[java.lang.RuntimeException]>


指定したタイムアウトをオーバーした場合。
テストがfailとなります。

test: country=JP lang=ar
test: country=JP lang=as
test: country=JP lang=av
test: country=JP lang=hy
test: country=JP lang=an
test: country=JP lang=az
test: country=JP lang=ay
test: country=JP lang=bm
          :
          :
          :
org.junit.ComparisonFailure: [ timeout! More than 10seconds.] 
Expected :true
Actual   :false

終わり。

サンプルはあげておきました。
github.com



参考
https://github.com/junit-team/junit4/wiki/multithreaded-code-and-concurrency