リリース間近 JUnit5 に備えよう


f:id:Naotsugu:20161005233943p:plain

はじめに

JUnit5 のリリースが近づいています。現在は M2 で M3 の作業が進んでいます。

今のところの予定は以下のようになってます。

  • 2016/10/21 M3 リリース
  • 2016/11/30 M4 リリース
  • 2016/12/30 M5 リリース

JUnit4 とは(中身は)全く別ものです。が普通に使う分には特に今までと同じ感覚で使えます。

Java8 以降をサポートという潔い割り切りになってます。

Version 5.0.0-M2 のユーザガイドからかいつまんでみます。

Gradle の設定

プラグインがあるのでこれを使います。

現在は Gradle プラグインのリポジトリには公開されていないので、buildscript にて依存を追加する必要があります。

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'org.junit.platform:junit-platform-gradle-plugin:1.0.0-M2'
    }
}

apply plugin: 'org.junit.platform.gradle.plugin'

テストのコンパイルとランタイムの依存を以下のように追加します。

dependencies {
    testCompile("org.junit.jupiter:junit-jupiter-api:5.0.0-M2")
    testRuntime("org.junit.jupiter:junit-jupiter-engine:5.0.0-M2")
}

太陽系の第5惑星の jupiter をとりあえず入れとけばいいです(少し前は gen5 でしたね)。

上記により依存関係は以下のようになります。

$ ./gradlew dependencies
testCompile - Dependencies for source set 'test'.
\--- org.junit.jupiter:junit-jupiter-api:5.0.0-M2
     +--- org.opentest4j:opentest4j:1.0.0-M1
     \--- org.junit.platform:junit-platform-commons:1.0.0-M2

testRuntime - Runtime dependencies for source set 'test'.
+--- org.junit.jupiter:junit-jupiter-api:5.0.0-M2
|    +--- org.opentest4j:opentest4j:1.0.0-M1
|    \--- org.junit.platform:junit-platform-commons:1.0.0-M2
\--- org.junit.jupiter:junit-jupiter-engine:5.0.0-M2
     +--- org.junit.platform:junit-platform-engine:1.0.0-M2
     |    +--- org.junit.platform:junit-platform-commons:1.0.0-M2
     |    \--- org.opentest4j:opentest4j:1.0.0-M1
     \--- org.junit.jupiter:junit-jupiter-api:5.0.0-M2 (*)

opentest4j は、Open Test Alliance for the JVM ということで様々なテストツールやIDEに最低限の共通基盤を提供するものです。中見るとわかりますが、共通で使う例外クラスが幾つか入ってます(JUnit5 チームによるものです)。

build.gradle 全体としては以下のようになります。

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'org.junit.platform:junit-platform-gradle-plugin:1.0.0-M2'
    }
}

apply plugin: 'java'
apply plugin: 'org.junit.platform.gradle.plugin'

[compileJava, compileTestJava]*.options*.encoding = 'UTF-8'
sourceCompatibility = targetCompatibility = '1.8'

repositories {
    jcenter()
}

dependencies {
    testCompile("org.junit.jupiter:junit-jupiter-api:5.0.0-M2")
    testRuntime("org.junit.jupiter:junit-jupiter-engine:5.0.0-M2")
}

テストの実行

テストを書いてみましょう。org.junit.jupiter.api.Test アノテーションを使います。

package example;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class JUnit5Example1Test {

    @Test
    void firstTest() {
        assertEquals(3, 1 + 2);
    }
}

JUnit5 ではテストクラスやテストメソッドは public にする必要がなくなりました。 @Test は interface の default メソッドにも付けることもできます(こちらのissueが実現した)。

junitPlatformTest タスクにてテストが実行されます。 プラグインにより test で実行されます。

$ ./gradlew test
 ・・・
:junitPlatformTest
10 02, 2016 11:02:21 午後 org.junit.platform.launcher.core.ServiceLoaderTestEngineRegistry loadTestEngines
情報: Discovered TestEngines with IDs: [junit-jupiter]

Test run finished after 125 ms
[         1 tests found      ]
[         0 tests skipped    ]
[         1 tests started    ]
[         0 tests aborted    ]
[         1 tests successful ]
[         0 tests failed     ]
[         0 containers failed]

テスト失敗時には以下のような出力が得られます。

Failures (1):
  JUnit Jupiter:JUnit5Example1Test:firstTest()
    JavaMethodSource [javaClass = 'example.JUnit5Example1Test', javaMethodName = 'firstTest', javaMethodParameterTypes = '']
    => org.opentest4j.AssertionFailedError: expected: <2> but was: <3>

Test run finished after 440 ms
[         1 tests found      ]
[         0 tests skipped    ]
[         1 tests started    ]
[         0 tests aborted    ]
[         0 tests successful ]
[         1 tests failed     ]
[         0 containers failed]

opentest4j の提供する opentest4j.AssertionFailedError が出てますね。

ちなみに IntellJ だとこんな感じで expected が明確になってナイスです。

f:id:Naotsugu:20161004002411p:plain

アサーション

JUnit5 ではアサーションの提供は最低限なもののみ提供されています。 org.junit.jupiter.api.Assertions に基本的な static メソッドが定義されています。

おなじみの assertEquals

@Test
void standardAssertions() {
    assertEquals(2, 2);
    assertEquals(4, 4, "The optional assertion message is now the last parameter.");
    assertTrue(2 == 2, () -> "Assertion messages can be lazily evaluated -- "
            + "to avoid constructing complex messages unnecessarily.");
}

その他もだいたいラムダが渡せるようになっています(遅延評価されるだけであまり実益ないですが)。

アサーションをグルーピングする assertAll

@Test
void groupedAssertions() {
    assertAll("address",
        () -> assertEquals("John", address.getFirstName()),
        () -> assertEquals("User", address.getLastName())
    );
}

assertAll ではアサーションが失敗した場合でも、すべてのアサーションが評価されます。 1つのテストメソッドに複数のアサーションを書く場合にはこれを使いましょう ということです。

例外は expectThrows で捕捉できます。

@Test
void exceptionTesting() {
    Throwable exception = expectThrows(IllegalArgumentException.class, () -> {
        throw new IllegalArgumentException("a message");
    });
    assertEquals("a message", exception.getMessage());
}

このあたりはJUnit4から大きく改善されてますね。

まとめると以下のようなものがあります。JUnit4 でもだいたい同じですね。

アサーションメソッド 説明
fail 必ず失敗
assertTrue() trueか
assertFalse() falseか
assertEquals() 同値(equals)かどうか
assertNotEquals() 同値でない(not equals)かどうか
assertNull() null かどうか
assertNotNull() null でないか
assertArrayEquals() 配列の要素が順序通りに同値か
assertSame() オブジェクトの参照が同じか
assertNotSame() オブジェクトの参照が同じではないか
assertAll() 複数のアサーションをまとめる
assertThrows() 発生した例外を捕捉して返却する

Matcher

JUnit5 では Matcher がフレームワークから切り離され、好きな関連ライブラリを使ってねという立ち位置に変わっています。今だと AssertJ ですかね。

build.gradle の依存を以下のように変更します。

dependencies {
    testCompile 'org.junit.jupiter:junit-jupiter-api:5.0.0-M2'
    testCompile 'org.assertj:assertj-core:3.5.2'
    testRuntime 'org.junit.jupiter:junit-jupiter-engine:5.0.0-M2'
}

テストはこんな感じでFluentに書けます。

package example;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class JUnit5Example1Test {

    @Test
    void firstTest() {
        assertThat(2 + 1).isEqualTo(3);
        assertThat(7).isLessThan(8).isLessThanOrEqualTo(7);
    }
}

もちろん Hamcrest や Google Truth など他のライブラリも普通に使えますので org.junit.jupiter.api.Assertions をそのまま使うケースは少ないでしょう。

ライフサイクル

アノテーションは変わってますが、見ればわかると思います。

import org.junit.jupiter.api.*;

import static org.junit.jupiter.api.Assertions.fail;

class StandardTests {

    @BeforeAll static void initAll() { }

    @BeforeEach void init() { }

    @Test void succeedingTest() { }

    @Test void failingTest() { fail("a failing test"); }

    @Test @Disabled("comment") void skippedTest() { }

    @AfterEach void tearDown() { }

    @AfterAll static void tearDownAll() { }
}

注意点としては @Ignore@Disabled に変わっているとこぐらいでしょうか。

Display Names

表示名を付けられるようになっています。

package example;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

@DisplayName("テストケースの例")
class JUnit5Example1Test {

    @Test
    @DisplayName("テストメソッド スペースも含められる")
    void testWithDisplayNameContainingSpaces() {
    }
}

IDE でこの通り。

f:id:Naotsugu:20161004005823p:plain

@Tag

テストクラスやテストメソッドに @Tag でタグ付けできます。

package example;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

@Tag("model")
public class TaggingDemo {

    @Test @Tag("fast")
    void testingTaxCalculation() { }

    @Test @Tag("slow")
    void testingTaxCalculationSlow() { }
}

このようにタグを付け、build.gradle に JUnit5 の設定で対象とするタグを指定します。

junitPlatform {
    tags { include 'model', 'fast' }
}

Gradle JUnit プラグインが対象のタグがついてるテストを実行してくれ、この場合は 'model' により2つのテストが実行されます。

exclude で遅いテストを除外すると、

junitPlatform {
    tags {
        include 'model', 'fast'
        exclude 'slow'
    }
}

@Tag("fast") が付いてる方だけが実行されます。

自身でアノテーション作ってもよいです。

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.junit.jupiter.api.Tag;

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Tag("fast")
public @interface Fast {
}

Nested Tests

@Nested でテストをネストして構造化できます。

class TestingNestDemo {

    @Test
    void outer() { }

    @Nested
    class Inner {
        @Test
        void testInner() { }
    }
}

コンストラクタとテストメソッドへの DI

JUnit5 ではテストメソッドに引数を取ることができます。 テストメソッドなどに TestInfoTestReporter が指定されていた場合、標準で提供されている TestInfoParameterResolverTestReporterParameterResolver により引数が解決されて DI されます。

TestInfo からはテストの情報を得ることができます。

@Test
@DisplayName("TEST 1")
@Tag("my tag")
void test1(TestInfo testInfo) {
    assertEquals("TEST 1", testInfo.getDisplayName());
    assertTrue(testInfo.getTags().contains("my tag"));
}

TestReporter ではテスト結果として任意出力を追加できます。

@Test
void reportSingleValue(TestReporter testReporter) {
    testReporter.publishEntry("a key", "a value");
}

これだけだとあまりうれしくないですが、独自で ParameterResolver を作ることで真価が発揮されます。

実際にはこちらを見ればよいですが、以下のような ParameterResolver を実装した Extension を作成し、

public class MockitoExtension implements TestInstancePostProcessor, ParameterResolver {

    @Override
    public void postProcessTestInstance(Object testInstance, ExtensionContext context) {
        MockitoAnnotations.initMocks(testInstance);
    }

    @Override
    public boolean supports(ParameterContext parameterContext, ExtensionContext extensionContext) {
        return parameterContext.getParameter().isAnnotationPresent(Mock.class);
    }

    @Override
    public Object resolve(ParameterContext parameterContext, ExtensionContext extensionContext) {
        return getMock(parameterContext.getParameter(), extensionContext);
    }

    private Object getMock(Parameter parameter, ExtensionContext extensionContext) {
        // 省略   モックを返す
    }
}

@ExtendWith で指定すれば任意オブジェクトがDIできるようになります。

@ExtendWith(MockitoExtension.class)
class MyMockitoTest {

    @BeforeEach
    void init(@Mock Person person) {
        when(person.getName()).thenReturn("Dilbert");
    }

    @Test
    void simpleTestWithInjectedMock(@Mock Person person) {
        assertEquals("Dilbert", person.getName());
    }
}

@ExtendWith で複数の拡張を盛り込むときは、横に並べても

@ExtendWith({ FooExtension.class, BarExtension.class })
class MyTest { }

縦にならべても大丈夫です。

@ExtendWith(FooExtension.class)
@ExtendWith(BarExtension.class)
class MyTest { }

Assumptions

org.junit.jupiter.Assumptions に static メソッドでまとめられています。JUnit4とだいたい同じです。

テストの前提条件を記述します。

    @Test
    void testOnlyOnCiServer() {
        assumeTrue("CI".equals(System.getenv("ENV")));
        // CIサーバの場合以降のテストを実行
    }

    @Test
    void testInAllEnvironments() {
        assumingThat("CI".equals(System.getenv("ENV")),
            () -> {
                // CI サーバでのみ実行
                assertEquals(2, 2);
            });

        // 全ての環境で実行
        assertEquals("a string", "a string");
    }

注意点はラムダを渡せるようになった点ぐらいです。

Dynamic Tests

@TestFactory アノテーションを付けたメソッドから org.junit.jupiter.api.DynamicTest リストやストリーム返すようにすることで、動的に作成したテストができます。

@TestFactory
Iterable<DynamicTest> dynamicTestsFromIterable() {
    return Arrays.asList(
        dynamicTest("dynamic test 1", () -> assertTrue(true)),
        dynamicTest("dynamic test 2", () -> assertEquals(4, 2 * 2))
    );
}

dynamicTest() にラムダ渡して DynamicTest を作成します。

Stream で返したり。

@TestFactory
Stream<DynamicTest> dynamicTestsFromIntStream() {
    return IntStream.iterate(0, n -> n + 2).limit(10).mapToObj(
            n -> dynamicTest("test" + n, () -> assertTrue(n % 2 == 0)));
}

f:id:Naotsugu:20161005232521p:plain

多くの入力値に対してテストする場合や外部ファイルから動的にテスト作りたいときなどに使うようですが、普段使いはあまりないかもしれません。

ライフサイクルコールバック

普段使いはしないと思いますが、テスト実行時のライフサイクルで以下のコールバックが定義されています。

  • BeforeAllCallback
    • BeforeEachCallback
      • BeforeTestExecutionCallback
      • AfterTestExecutionCallback
    • AfterEachCallback
  • AfterAllCallback

こんな感じでコールバックを受ける Extension を用意して、

public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    private static final Logger LOG = Logger.getLogger(TimingExtension.class.getName());

    @Override
    public void beforeTestExecution(TestExtensionContext context) throws Exception {
        getStore(context).put(context.getTestMethod().get(), System.currentTimeMillis());
    }

    @Override
    public void afterTestExecution(TestExtensionContext context) throws Exception {
        Method testMethod = context.getTestMethod().get();
        long start = getStore(context).remove(testMethod, long.class);
        long duration = System.currentTimeMillis() - start;

        LOG.info(() -> String.format("Method [%s] took %s ms.", testMethod.getName(), duration));
    }

    private Store getStore(TestExtensionContext context) {
        return context.getStore(Namespace.create(getClass(), context));
    }
}

@ExtendWith で指定すれば、

@ExtendWith(TimingExtension.class)
class TimingExtensionTests {

    @Test
    void sleep20ms() throws Exception {
        Thread.sleep(20);
    }
}

INFO: Method [sleep20ms] took 24 ms. のように経過時間をログ出力したりといったことができます。

あとは TestExecutionExceptionHandler で Extension つくれたり。

public class IgnoreIOExceptionExtension implements TestExecutionExceptionHandler {

    @Override
    public void handleTestExecutionException(TestExtensionContext context, Throwable throwable)
            throws Throwable {

        if (throwable instanceof IOException) {
            return;
        }
        throw throwable;
    }
}

以上駆け足で見てきました。今までと同じような感じで使っていけそうですね。