6️⃣

JUnit 6の変更点をKotlin開発者目線で掘り下げます — suspend対応とJSpecifyを中心に

に公開

はじめに

こんにちは、エンジニアの三田(@Eichisanden)です。
JUnit 5から実に8年ぶりのメジャーバージョン、JUnit 6が2025年9月にリリースされました。
本記事ではKotlin開発者にとって興味深い suspend関数のネイティブサポートJSpecifyによるnullability宣言 の2点を中心に掘り下げたいと思います。

JUnit 6の主な変更点

本題に入る前に、JUnit 6全体の変更点を簡単に整理しておきます。
https://docs.junit.org/6.0.0/release-notes.html

  • Java 17 / Kotlin 2.2がベースライン — テストを実行するためのランタイムおよびコンパイル環境として Java 17 / Kotlin 2.2 以上が必須となります
  • バージョン番号の統一 — Platformだけバージョンが1.xでしたが、Platform・Jupiter・Vintageが同一バージョンになり分かりやすくなりました
  • JSpecify annotationsの全面採用 — 全モジュールのAPIにnullability情報が付与されました
  • Kotlin suspend関数のネイティブサポート — テストメソッドでsuspendが直接使用可能になりました
  • CancellationToken API — テスト実行のキャンセルがサポートされました
  • FastCSVへの移行@CsvSource/@CsvFileSourceのCSVパーサーが変更されました
  • JFRサポートの内蔵junit-platform-jfrアーティファクトが廃止され、launcherに統合されました
  • Vintageの非推奨化 — JUnit 4互換レイヤーは引き続き使えるものの、正式に非推奨になりました

JUnit 4→5のときはバージョンアップが大変だったので身構えてしまいますが、破壊的な変更はなさそうです。

suspend関数のネイティブサポート

これまでの課題

JUnit 5でKotlinのCoroutineをテストする場合、テストをsuspendにできないためrunBlockingkotlinx-coroutines-testrunTestで包む必要がありました。

// JUnit 5時代のCoroutineテスト
class UserServiceTest {
    @Test
    fun `ユーザーを取得できる`() = runTest {
        val user = userService.findById(1L)
        assertEquals("Alice", user.name)
    }

    @Test
    fun `存在しないユーザーでnullが返る`() = runTest {
        val user = userService.findById(999L)
        assertNull(user)
    }
}

ちなみに、JUnit 5では @Test suspend fun ... と書いてもテストエンジンはsuspend関数を認識できずテストが実行されないという問題がありました。コンパイルエラーにもランタイムエラーにもならないため、テストが実は動いていなかったというケースもありました。

(IntelliJ IDEAはさすがで警告してくれるのですが、コンパイルは通るので実行することはできてしまいます)

JUnit 6での書き方

JUnit 6ではJupiterエンジンがsuspend関数を直接認識します。テストメソッドだけでなく、@BeforeEach@AfterEachなどのライフサイクルメソッドもsuspend関数にできます。

// JUnit 6:runTestが不要に
class UserServiceTest {
    private lateinit var userService: UserService

    @BeforeEach
    suspend fun setUp() {
        userService = UserService(testDatabase())
        // suspend関数を直接呼べる
        userService.initialize()
    }

    @Test
    suspend fun `ユーザーを取得できる`() {
        val user = userService.findById(1L)
        assertEquals("Alice", user.name)
    }

    @Test
    suspend fun `存在しないユーザーでnullが返る`() {
        val user = userService.findById(999L)
        assertNull(user)
    }

    @AfterEach
    suspend fun tearDown() {
        userService.cleanup()
    }
}

JUnitのコードを読んでみると、Kotlinのsuspend関数かどうかで分岐が入っていてrunBlocking(EmptyCoroutineContext.INSTANCE, ...)で囲って呼び出すようになっていました

// MethodReflectionUtils.invoke()
public static @Nullable Object invoke(Method method, @Nullable Object target, @Nullable Object[] arguments) {
    if (isKotlinSuspendingFunction(method)) {
        return invokeKotlinSuspendingFunction(method, target, arguments);
    }
    // ... 通常のメソッド呼び出し
}

// 実際のコルーチン実行: KotlinFunctionUtils.invokeKotlinSuspendingFunction()
private static <T extends @Nullable Object> T invokeKotlinSuspendingFunction(KFunction<T> function,
    @Nullable Object target, @Nullable Object[] args) throws InterruptedException {
    if (!isAccessible(function)) {
        setAccessible(function, true);
    }
    return runBlocking(EmptyCoroutineContext.INSTANCE, (__, continuation) -> {
        try {
            return callSuspendBy(function, toArgumentMap(target, args, function), continuation);
        }
        catch (Exception e) {
            throw throwAsUncheckedException(getUnderlyingCause(e));
        }
    });
}

これによりrunTestrunBlockingなしに直感的に書くことができるようになりました。
もちろん、runTestにはdelayをスキップしてくれたり様々な利用用途がありますし、囲ったまま残すことも可能ですので無理に修正する必要もありません。

JSpecifyによるnullability宣言

JSpecifyとは

Spring BootもJSpecify対応したり、最近耳にすることは多かったですがこれを機会に調べてみました。
JSpecifyは、JVM言語で使用するための共通のアノテーション型セットを定義し、静的解析と言語の相互運用性を向上させる取り組みを行っているプロジェクトです。
Google・Oracle・JetBrainsなどが参加しており、2024年7月に1.0.0がリリースされました。現時点はnullability解析に焦点を当てるということで提供しているアノテーションは下記の4つです。

  • @Nullable — この型はnullを含みうる
  • @NonNull — この型はnullを含まない
  • @NullMarked — このスコープ内では、アノテーションが付いていない型参照はすべてnon-nullとみなす
  • @NullUnmarked — このスコープ内で、一部だけnullability未指定の状態に戻す

従来、Javaのnullabilityアノテーションは乱立状態でした。JSR 305のjavax.annotation.Nullable、JetBrainsのorg.jetbrains.annotations.Nullable、Androidのandroidx.annotation.Nullable、Springのorg.springframework.lang.Nullableなどが混在し、「importを見たら思っていたのと違う@Nullableだった」みたいなことが良くありました。JSpecifyはこれらを統一する試みです。

JUnit 6でのJSpecify採用

JUnit 6では全モジュールのAPIにJSpecifyアノテーションが付与されています。パッケージレベルで@NullMarkedが宣言されているため、アノテーションが付いていないパラメータや戻り値はすべて非nullとして扱われます。

// JUnit 6のAPI(イメージ)
@NullMarked
package org.junit.jupiter.api;

public interface TestInfo {
    String getDisplayName();           // 非null(@NullMarkedのスコープ内)
}

// nullableであることを明示(@NullMarkedのスコープ内)
static void assertNull(@Nullable Object actual, @Nullable String message) {
    if (actual != null) {
        failNotNull(actual, message);
    }
}

Kotlin開発者への影響

これまでJUnit 5のAPIをKotlinから呼び出す場合、戻り値やパラメータの多くは「プラットフォーム型」(例えばString!)として解釈されていました。プラットフォーム型はnullチェックをコンパイラが強制しないため、実行時にNullPointerExceptionが発生する可能性がありました。
Kotlin 2.1.0以降、KotlinコンパイラはJSpecifyアノテーションを扱えるようになっています。

// JUnit 5 + Kotlin:プラットフォーム型の問題
@Test
fun example(testInfo: TestInfo) {
    // testInfo.displayName は String! (プラットフォーム型)
    // nullチェックなしでアクセスできてしまう
    val name: String = testInfo.displayName  // コンパイル通るが、理論上はNPEの可能性
}

JUnit 6では、JSpecifyアノテーションのおかげでKotlinコンパイラが正確なnullability情報を取得できます。

// JUnit 6 + Kotlin:正確なnull安全性
@Test
fun example(testInfo: TestInfo) {
    val name: String = testInfo.displayName   // OK:非nullが保証されている
    val os: OS? = OS.current()  // @Nullableなので OS? として認識
    // val osName: String = os.name  // コンパイルエラー!
}

プラットフォーム型が消えることで、KotlinからJUnitのAPIを呼ぶ際の型安全性が大幅に向上します。

Spring Boot 4.0ではJUnit 6が採用されている

Spring Boot 4.0ではJUnit 6が採用されており、Spring Framework自身もJSpecifyも全面採用していますのでテストコードからプロダクションコードまで一貫したnullability対応ができるようになりました。
またSpring Boot 4.0はJacksonのバージョンが2から3に上がっているなど修正量が多いため、JUnit 6対応を先行して行うことはSpring Bootバージョンアップ時の作業負荷が減らせるためオススメです

実際に移行してみました

弊社のプロジェクトを実際に移行してみましたが、依存関係のアップデート以外に必要な修正はArgumentsProvider.provideArguments(ExtensionContext)がDeprecatedになった対応のみでした。

    class SearchPatternProvider : ArgumentsProvider {
-        // こちらは廃止された
-        override fun provideArguments(context: ExtensionContext?): Stream<out Arguments>? =
+        // こちらに置き換える
+        override fun provideArguments(parameters: ParameterDeclarations, context: ExtensionContext): Stream<out Arguments> =
            Stream.of(...)

まとめ

JUnit 5→6は穏やかな進化ですし、すでにJava 17 + Kotlin 2.2を使っているプロジェクトであれば、依存関係のバージョンを上げて少しのコード修正でバージョンアップできることが多いのではと思います。
suspend対応とJSpecify対応はKotlin開発者にとってメリットある機能ですのでバージョンアップすることをお勧めします。

株式会社ログラス テックブログ

Discussion