ContractS開発者ブログ

契約マネジメントシステム「ContractS CLM」の開発者ブログです。株式会社HolmesはContractS株式会社に社名変更しました。

@SpringBootTestを使ったSpockテストが遅いので、Gradleでテストをカテゴリ分けして起動を高速化する

Holmesでエンジニアをしている山本です。

以前の記事でも言及があったように、現在はユニットテストの起動および実行に非常に時間がかかっています。

最もコード量が多いプロジェクトで、ユニットテストの起動に約2分、実行には数十分かかります。

理由としては、Spring Bootを使用したJavaプロジェクトにて、ControllerやServiceに対して @SpringBootTest を付与したテストを行っているためです。

中でもControllerに対しては WebEnvironment.RANDOM_PORT を指定した結合テストとして記述されているため、 gradle test --tests <className> などでControllerのテストを除外しないと、Webサーバーや組み込みDBの起動が行われ、テスト起動および実行に時間がかかってしまいます。

完了まで数十分では、変更の都度すべてのテストをローカルで実行というわけにはいかず、変更したクラスに対応するテストのみ修正する、という運用になりがちです。結果、CIから実行されたテストの失敗が、度々起こりました。

単体レベルのテストは、簡単に実行して30秒以内には結果が出てほしいので、結合レベルのテストを除外してテストしたいと考え、SpockとGradleで実現する方法を調査しました。

テストのカテゴリ分け

テストサイズの考え方を参考に、Smallテストを導入したいと思います。

Spockのテストのカテゴリ分けについては、JUnit@Category がそのまま使用できます。

任意のクラスをカテゴリ分けに使用できますが、マーカーとしてわかりやすいよう、テストサイズに対応したクラスを作成します。

package com.holmescloud

final class SmallTests {

    private SmallTests() {
    }

}

POJOEnum、依存クラスをモック化したServiceなど、Springに依存しないテストクラスに対して、 @Category(SmallTests.class) を付与しておきます。

smallTestタスクの作成

続いて、Gradleのタスクとして実行できるよう、build.gradleに以下のタスク定義を追加します。

task smallTest(type: Test, dependsOn: testClasses) {
    useJUnit {
        includeCategories 'com.holmescloud.SmallTests'
    }
}

これで gradle smallTest を実行すると、@Category(SmallTests.class) を付与したテストクラスのみが実行されます。

ログ出力の追加

このままでは実行経過がわかりにくいため、 afterSuite にて lifecycle レベルのログ出力を追加します。

除外したテストも afterSuite に渡されますが、それらは第2引数 TestResultTestResult#getTestCount が0となるため除外しています。

また、クラス単位、Test Executor単位、タスク単位の結果が渡されますが、Test Executor単位の結果は不要と思い、除外しています。

Test Executorの場合、第1引数の TestDescriptor のうち、 TestDescriptor#getClassName がnull、かつ TestDescriptor#getParent が存在するため、それで判定します。

それ以外は成功件数とテスト件数、経過時間と表示名を出力するよう、タスク定義に追記します。

task smallTest(type: Test, dependsOn: testClasses) {
    useJUnit {
        includeCategories 'com.holmescloud.SmallTests'
    }

    afterSuite { desc, ret ->
        if (!ret.testCount || (!desc.className && desc.parent)) {
            return
        }

        def retSec = (ret.endTime - ret.startTime) / 1000
        def retCount = "${ret.successfulTestCount}/${ret.testCount}"
        def retMsg = "[${ret.resultType}] ${retCount}, ${retSec}s"
        logger.lifecycle "${retMsg}: ${desc.displayName}"
    }
}

一度コンパイルした状態で、 gradle cleanCompileTestGroovy smallTest し、テストクラスのコンパイルおよびカテゴリ分けしたテストの実行時間を確認しました。以下のように、クラスごとの結果とタスクの総計が出力されます。

> Task :smallTest
[SUCCESS] 14/14, 0.981s: <className>
[SUCCESS] 13/13, 0.055s: <className>
[SUCCESS] 8/8, 0.041s: <className>
[SUCCESS] 35/35, 5.539s: Gradle Test Run :smallTest

BUILD SUCCESSFUL in 29s

smallTestタスクにおける経過時間の総計と各クラスごとの合計の差は、カテゴリ分けされていないテストの読み飛ばし時間です。

対象となるテストクラス数が少ないこともありますが、Webサーバーの起動が行われないため、タスクの実行時間を5.5秒まで短縮できました。テストクラスのコンパイルを含めても約30秒と、許容できる時間に収まったかと思います。

今後の課題

ひとまずテストをカテゴリ分けし、選択的に実行可能となりましたが、全てのテストを実行したときの遅さは解消されていません。

個人的には以下のように順を追って、少しずつ解消していければと考えています。

  1. Serviceについては、 @SpringBootTest は使わず、依存クラスをモックとしたシンプルな単体テストを記述し、Smallテスト扱いとする
  2. Controllerについては、 @SpringBootTest は使わず、 @WebMvcTest を利用し、依存するServiceなどは @MockBean などでモック化したテストを記述、Mediumテスト扱いとする
  3. Repositoryについては、組み込みDBを用いたテストを記述し、Mediumテスト扱いとする

また、Spring Bootアプリケーションのテストに関する私の知識はv1.5ごろのものです。v2になって以降、より良いテスト方法があるかもしれないので、適宜調査していきたいと思います。

最後に

Holmesではバックエンドだけでなく、フロントエンドでもテスト記述を推進しております。

エンジニア・デザイナーを募集しておりますので、ご興味がある方はこちらからご連絡ください。

lab.holmescloud.com

lab.holmescloud.com

参考資料

testing.googleblog.com

www.slideshare.net

qiita.com

qiita.com