kintone のテストを JUnit 5 に移行した話

こんにちは、kintone 開発チームの @hikoma です。kintone のテストを JUnit 4 から JUnit 5 に移行した話を公開したいと思います。

背景

2017 年に JUnit 5 がリリースされてから約 4 年半、みなさんは既に JUnit 5 を利用していることかと思います。

kintone では JUnit 5 への移行がなかなか進みませんでした。テストのボリュームがそれなりにあり(Java の単体テストが約 6500、REST API のテストが約 4000、Selenium のテストが約 3000)、E2E テストで並列実行やリトライのために JUnit 4 の仕組みを利用していたので、目に見える問題が起きていない状況では優先度も上がりませんでした。

しかし、このような状況ではテストの改善に着手しにくく、持続的な開発のリスクも感じていたため、何度目かの移行にチャレンジしました。

方針

基本的には様々なマイグレーションガイドに従って、粛々とアノテーションを書き換え、TestRule を Extension に書き換えることで JUnit 5 に移行できます。

Maven Surefire Plugin を使っている場合は JUnit 4 と JUnit 5 のテストを同時に動かすことも可能なので、部分的に移行することも可能です。

しかし、後述するように E2E テストでは並列実行の仕組みが大きく変わるため部分的な移行が難しく、単体テストに関しても正規表現での置換で対応できる部分が多かったので部分的な移行のメリットはそこまでありませんでした。部分的に移行すると JUnit 4 と JUnit 5 が混在することによって認知負荷が上がってしまうデメリットもあるので、部分的な移行は選択しませんでした。

そういうわけで、コンフリクトが起きても大丈夫なように、置換用のスクリプトを用意して、適宜コミットを作り直しながら移行を進めていきました。

正規表現での置換

JUnit 4 と JUnit 5 では以下の対応で置き換えしました。

JUnit 4 JUnit 5
org.junit.Test org.junit.jupiter.api.Test
org.junit.Before org.junit.jupiter.api.BeforeEach
org.junit.After org.junit.jupiter.api.AfterEach
org.junit.BeforeClass org.junit.jupiter.api.BeforeAll
org.junit.AfterClass org.junit.jupiter.api.AfterAll
org.junit.Ignore org.junit.jupiter.api.Disabled
org.junit.Assume.* org.junit.jupiter.api.Assumptions.*
Theory ParameterizedTest
Enclosed Nested
SpringJUnit4ClassRunner SpringExtension
MockitoJUnitRunner MockitoExtension

具体的には以下のようなコードで置換します。

REGEX1='s/import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;/import org.junit.jupiter.api.extension.ExtendWith;import org.springframework.test.context.junit.jupiter.SpringExtension;/g'
REGEX2='s/\@RunWith\(SpringJUnit4ClassRunner.class\)/\@ExtendWith(SpringExtension.class)/g'

grep -r -l "import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;" src/test/ \
  | xargs perl -i -npe "$REGEX1; $REGEX2;"

置換後は spotless-maven-plugin を使ってコードのフォーマットと不要なインポートを削除しています。基本的にはこの方式で置換できましたが、Theory と Enclosed は単純な置き換えではできない部分も出てきました。

Theory

kintone では @DataPoints をフィールドで利用していましたが、ParameterizedTest ではフィールドをデータソースに利用できません。本来はメソッドに置き換えると良いのですが、ボリュームが多かったので、以下のように fixtureProvider メソッドを追加するように正規表現で置換することで対応しました。

// JUnit 4
    @DataPoints
    public static final Fixture[] fixtures = { ... };

    @Theory
    public void test(Fixture f) {
// JUnit 5
    public static Fixture[] fixtureProvider() {
        return fixtures;
    }

    public static final Fixture[] fixtures = { ... };

    @ParameterizedTest
    @MethodSource("fixtureProvider")
    public void test(Fixture f) {

Enclosed

Enclosed ではネストしたクラスが static でしたが、Nested ではネストしたクラスを static にはできません。

ネストしたクラスの static を外すだけであれば正規表現での置換で対応可能でしたが、@BeforeAll@MethodSource など本来 static である必要があるものをネストしたクラスで使っている場合には、ネストしたクラスに @TestInstance(Lifecycle.PER_CLASS) を付与する必要があります。

さすがに正規表現で @TestInstance(Lifecycle.PER_CLASS) に対応することは難しそうだったので、手動で修正を行い、コミットを作り直す時は cherry-pick して対応しました。

E2E テストの移行

kintone の E2E テストには、Selenium を使ったブラウザテストと、REST API のテストがあります。E2E テストは時間がかかるので、kintone を複数台起動して並列でテストを実行するようにしています。また、E2E テストは不安定なため、失敗時にはリトライする仕組みも用意しています。

並列実行

JUnit 4 では Maven Surefire Plugin の機能(parallel=classes)で並列実行していましたが、JUnit 5 では Parallel Execution 機能を使って並列実行できます。ただし、現時点で最新の Maven Surefire Plugin 3.0.0-M6 では、テスト結果の XML が壊れてしまうので(SUREFIRE-1643)使う際には注意が必要です。

また、Maven Surefire Plugin には統計情報を使って実行順序を並び替える機能がありましたが(runOrder=balanced)、JUnit 5 には対応していないため実装しています。AfterAllCallback で実行時間をログに出力する Extension を実装し、次回の実行時にログを見てクラスを並び替える ClassOrderer を実装することで実現できました。

リトライ

JUnit 4 では TestRule を使って Statement の evaluate を try-catch で囲んでリトライする簡単な実装でしたが、JUnit 5 の Extension ではイベントハンドラを登録するだけで、任意のタイミングでの実行の制御はできなくなっています。

JUnit 5 でリトライを実現するために以下の選択肢を検討しました。

kintone では環境によってリトライ回数を変えたり、トータルのリトライ回数を制限したり、不安定なテストを見つけるためにリトライ数を計測したり、いくつか独自の対応が必要だったので Extension を実装しました。

仕組み的には他のライブラリと同様で、TestTemplate の仕組みを使って実装しています。TestTemplate を使う場合は、他の TestTemplate を使う Extension と相互に連携できない制限があります。例えば ParameterizedTest をリトライするには、ParameterizedTest の機能とリトライの機能を両方持った Extension を実装する必要があります。kintone の E2E ではそこまで ParameterizedTest を使っていなかったので、追加の Extension を実装せずに ParameterizedTest を手動で展開する対応をしました。

マージ!

他にも細かい修正は必要でしたが、上記の対応で一通り移行の目処が立ちました。

ここまではバックログのタスクとしてではなく、個人の探求時間を使って一人で進めてこられましたが、実際にマージするにはコードレビューをしてもらう必要があります。いきなり巨大なプルリクエストのレビューを依頼しても、マージされるまでには時間がかかると思ったので、まずは JUnit 5 移行の勉強会を開きました。

勉強会では JUnit 5 の基本的な使い方の紹介(進藤遼さんのJJUG CCC 2018 Spring の発表を利用させてもらいました)と、今まで書いた内容をもう少し具体的に紹介して、今までの書き方とこれからの書き方を比べながら紹介していきました。

勉強会を開くことで、マージ後に開発者がスムーズに移行できるようになるだけでなく、プルリクエストのレビューに協力してくれるメンバーも3人見つけることができました。勉強会のおかげで、具体的な変更点を理解してもらえたので、レビューもやりやすくなったと思います。全員のスケジュールが合う時間で2〜3時間くらいのコードレビューの会を実施し、再度新しい仕組みの共有、スクリプトとコードのレビュー、不具合や対応漏れの修正を行い、3回目くらいにはマージ可能な状態になりました。移行当日には、他のチームメンバーにマージを控えてもらった状態でコミットを作り直し、3 月に無事にマージができました。

おわりに

この記事では JUnit 4 から JUnit 5 への移行を紹介しました。まだ JUnit 4 を利用している環境がどのくらい残っているのか分かりませんが、何かのお役に立てれば幸いです。

JUnit 5 に移行したことで、テストの改善にも心置きなく手をつけられそうです。この記事ではフロントエンドのテストには触れませんでしたが、現在フロントエンドリアーキテクチャプロジェクトが進んでいて、フロントエンドの単体テストも書きやすくなっていくことかと思います。今後はテストピラミッドの考え方を参考にして E2E ではなく適切なレイヤーでテストを書けるように整備していきたいですね。