SpockのテストをグルーピングしGradleタスク化、指定されたタスクで条件判定して処理を切り分け

前回の続き。

アサインされたJavaプロジェクトではSpring Bootを使用しているが、ユニットテストではモックが使われていなかった。

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) でWebサーバーを起動し、 TestRestTemplate でControllerクラスのメソッドを実行しているため、テスト実行まで数分かかる。

また、Repositoryに対する個別のテストは書かれておらず、ControllerやServiceから間接的にテストされていた。アクセスするDBは processTestResources にてローカルにH2 Databaseのファイルを作成し、ControllerやServiceのテストクラスのsetupメソッドでデータ削除を実行したりと、実行にも時間がかかる。

こうした場合、速度(Slow/Fast)やテストサイズ(Small/Medium/Large)でテストをグループ化するが、引数を渡して test タスク実行では面倒なので、別途タスク化して実行したい。さらに、追加したタスクが指定された場合、H2 Databaseは不要なので、DBファイル作成処理は実行させないようにしたい。調べてみたらとりあえず実現できたのでメモ。

環境

Groovy v3.0.3, Gradle v6.3。

テストのグルーピング

検索すればたくさん出てくるが、SpockはJUnit4を利用しているため、 @Category によるカテゴリ化テストを利用できる。

Gradleからは test.useJUnit(Closure)includeCategoriesexcludeCategories として対象のクラス名を指定すればいい。

Test - Gradle DSL Version 6.3

@Category には任意のクラスを渡せるため、StringなりObjectなりでもいいが、一応クラスを作っておく。インスタンス生成できないよう、finalかつprivateコンストラクタを持ったクラスを作成。

package com.example

public final class FastTests {
    private FastTests() {
    }
}

@SpringBootTest を付与していない、シンプルなテストクラスに @Category(FastTests.class) として付与しておく。

Gradleタスクとしてカテゴリ化テストを切り出し

以下の記事に方法が全部書いてあった。

mike-neck.hatenadiary.com

gradle fastTest で実行できるよう、 build.gradle にタスク定義を記述。オプションで出力レベルを変更させたくないため、 lifecycle ログの出力設定を追加し、以下のようになった。

task fastTest(type: Test, dependsOn: testClasses) {
  useJUnit {
    includeCategories 'com.example.FastTests'
  }

  testLogging.lifecycle {
    // `events 'started', 'passed', 'skipped'` などを指定すると、テストメソッド単位でログ出力されるため、afterSuiteで出力する
    events 'standard_out'
    exceptionFormat 'full'
    showStandardStreams true
  }

  afterSuite { TestDescriptor desc, TestResult ret ->
    // 除外されたテストも SUCCESS 扱いで afterSuite に渡されるが、その場合は testCount が 0 になる
    if (!ret.testCount) {
      return
    }

    // className が設定されていればクラス単位の結果
    // className が null で parent が `Gradle Test Run :${taskName}` の場合は `Gradle Test Executor ${index}` ごとの結果
    // className が null で parent が null の場合は `Gradle Test Run :${taskName}` の結果、総計となる

    def hasClassName = !!desc.className

    // Test Executorごとの結果は不要
    if (!hasClassName && desc.parent) {
      return
    }

    // クラス名が一意ならパッケージなし、一意でないならパッケージありで className が入る
    def descName = hasClassName ? "${ret.resultType} ${desc.className}"'Results :'

    // Mavenのテスト結果風にメッセージを作成
    def retMessage = [
        'Tests run': ret.testCount,
        Failures: ret.failedTestCount,
        Errors: ret.exceptions.size(),
        Skipped: ret.skippedTestCount
    ].collect {
        "${it.key}: ${it.value}"
    }.join(', ') + (hasClassName ? ", Time elapsed: ${(ret.endTime - ret.startTime) / 1000} sec" : '')

    logger.lifecycle "${descName}\n${retMessage}"
  }
}

これで、 gradle fastTest でログ出力しつつ @Category(FastTests.class) を付与したテストクラスを実行できるようになった。

指定されたタスクによる処理の切り分け

H2 Databaseのファイル作成処理は createTestDB タスクとして記述され、 processTestResourcesdependsOn されている。

fastTest タスクが実行されるのはローカルでのユニットテスト実行に限定できるため、これが指定されたときは依存関係設定をしなければいい。

指定されたタスク名は project.gradle.startParameter.taskNames で取得できる。Javadocによると taskNamesList<String> のため、以下の記述で実現できた。

// `!in` はGroovy v3から
if ('fastTest' !in project.gradle.startParameter.taskNames) {
  processResources.dependsOn createTestDB
}

振り返り

Spring Bootのテストだからとナイーブに @SpringBootTest を使ってしまうと、テストが遅くなりがち。

ローカルで何度も実行することを考えると、Smallテストの実行は30秒以内に終わらせたい。

ただ、テストが遅くなったから修正しようにも、なかなかテストコードのリファクタリングをする時間は取れない。

事前にテスト記述のルールを決めておくなど、ある程度の準備は必要だと思った。

Serviceの結合テストではDIできればいいので @SpringBootTest(webEnvironment = WebEnvironment.NONE) を使う、単体テストとしてモックを使ったテストを記述してSmallテストとして実行できるようにするなど、テスト自体の改善もしないとなぁ。

以下、テスト記述の参考。

備考

Spockのテストのグルーピングは、 SpockConfig.groovy を使えばアノテーションでもできる模様。 SpockConfig.groovy についての知識がなく、起動時のパラメータで切り替える必要がありそうだったので、今回は見送り。