【Java版】CI(継続的インテグレーション)ツール導入ガイド:第2回 Spock によるユニットテストとjacocoによるカバレッジ計測

前回は「CIツール導入ガイド 第1回 導入と概要」の解説をした。今回はCIによるユニットテストの導入と解説を行う。

1. ツール紹介

1.1 Spock

Spock は、自動テストを行うためのテストフレームワークである。 同様のツールには、古くからの有名なテストツールである JUnit 等が挙げられる。

自動テスト中の一つのテストで行われる一般的な処理は、

  • 前処理(必要なら)
  • テストデータ準備(必要なら)
  • テスト対象の処理(メソッド)を実行
  • 結果の検証
  • 後処理(必要なら)

と進んでいく。この処理を順に愚直に記述してしまうと、何をテストしているのかわかりにくいソースができてしまう。 テストコードの可読性を上げるには、テストそのものに重きが置かれた記述であることが重要である。比較的新しいテストフレームワークでは、DSL 等を用いた宣言的な記述で何をどうやってテストしようとしているのかをコードで表現できるように工夫されている。 テストコードを読むことによって仕様がわかるのであれば、テストコードそのものを仕様とみなすことも可能である。

Spock は、そのようなことを目的としたフレームワークであり、’仕様(specification)’フレームワークと言われている。 Groovy 言語(JVM 上で動くスクリプト言語) による、 RSpec のようなわかりやすい DSL で記述を行う。 そのため Groovy をある程度学ぶ必要はあるが、それを補って余りある使いやすさで高く評価されている。

また、Spock の特徴として、データ駆動テストが挙げられる。 データとテストのロジックを分けることができれば、同じテストロジックを繰り返し記述する必要もなく、テストを見通しやすくなる。 Spock はデータ駆動なテストを簡潔に記述できる。もちろんデータを外部に定義することもできるが、データテーブル記法により簡単に、わかりやすくデータをソースに埋め込むこともできる。

下記にサンプルからテストケースを一つ抜粋する。

  @Unroll
  def "map() は投稿メッセージ本文 \"#messageSource\" の先頭が有効なコマンドの時,コマンド文字列を command カラムに格納して返す"() {
    given:
    def messageForm = new MessageForm()
    messageForm.setMessage(messageSource)

    when:
    def result = service.map(messageForm, ZonedDateTime.now())

    then:
    result.command == expectedCommand

    where:
    messageSource           || expectedCommand
    "#red Lorem ipsum"      || "#red"
    "#reddolor sit amet"    || "#red"
    "#green Lorem ipsum"    || "#green"
    "#greendolor sit amet"  || "#green"
    "#blue Lorem ipsum"     || "#blue"
    "#bluedolor sit amet"   || "#blue"
  }

まず、テストケースの名称に任意の文字列を指定することができる。これによって、テスト結果の出力がとても見やすいものになる。

given: ブロックでは、テスト対象のオブジェクトの初期化を行っている。

when: ブロックでは、テスト対象となるメソッドの実行を行っている。

then: ブロックでは、処理結果の検証を行う。基本的には各行が true を返せばテストは通過する。

where: ブロックはテーブルでのデータの定義である。 groovy は messageSource に右側の列の値を、expectedCommand に左側の列の値を当てはめて、(ヘッダを除いた)行数分このテストケースを反復実行する。 テストデータの記述方法はテストを書くときの悩みのタネの一つだが、多くの場合はそれほど多くのケースのデータを揃える必要はなく、データの件数が多くない場合はこのテーブル記法は非常に有効であることが見て取れるだろう。

具体的な例は下記のサンプルコードを参照していただきたい。

1.2 Jacoco

Jacoco は、テストが対象ソースコードをどの程度網羅しているかを計測する、Java 言語のカバレッジツールである。

Jacoco はステートメントカバレッジとブランチカバレッジを取得できる。 つまりすべての命令文が実行されるか、またすべての条件分岐が網羅されているかを計測し、網羅率と網羅されていない箇所を報告してくれる。 カバレッジが100%であることがテストの完全性を保証するものではないし、どうしても100%にしなければならないというものでもないが、テストの重要な指標として用いることができる。

下記サンプルでは実際にカバレッジを計測し、結果を見て対処を行っているので参考にしていただきたい。

2. パッケージ導入

build.gradle に必要な依存性を追加する。 更に、あとで使うので gradle に渡すプロパティでテスト用データベース設定を上書きできるように処理を追加しておく。

build.gradle

3. テストコード追加

まず MessageMappingService クラスのテストを用意する。 MessageMappingService は、特別なライブラリに特に依存していないクラスなので、フレームワークに依存しないテストになる。

src/test/groovy/com/example/messageboard/MessageMappingServiceSpec.groovy

package com.example.messageboard

import java.time.ZonedDateTime
import spock.lang.Specification
import spock.lang.Unroll


class MessageMappingServiceSpec extends Specification {
  final def service = new MessageMappingService()

  @Unroll
  def "map() は投稿メッセージ本文 \"#messageSource\" の先頭が有効なコマンドの時,コマンド文字列を command カラムに格納して返す"() {
    given:
    def messageForm = new MessageForm()
    messageForm.setMessage(messageSource)

    when:
    def result = service.map(messageForm, ZonedDateTime.now())

    then:
    result.command == expectedCommand

    where:
    messageSource           || expectedCommand
    "#red Lorem ipsum"      || "#red"
    "#reddolor sit amet"    || "#red"
    "#green Lorem ipsum"    || "#green"
    "#greendolor sit amet"  || "#green"
    "#blue Lorem ipsum"     || "#blue"
    "#bluedolor sit amet"   || "#blue"
  }

  @Unroll
  def "map() は投稿メッセージ本文 \"#messageSource\" の先頭が有効なコマンドの時,コマンド文字列と続く空白を除いた値を message カラムに格納して返す"() {
    given:
    def messageForm = new MessageForm()
    messageForm.setMessage(messageSource)

    when:
    def result = service.map(messageForm, ZonedDateTime.now())

    then:
    result.message == expectedCommand

    where:
    messageSource           || expectedCommand
    "#red Lorem ipsum"      || "Lorem ipsum"
    "#reddolor sit amet"    || "dolor sit amet"
    "#green Lorem ipsum"    || "Lorem ipsum"
    "#greendolor sit amet"  || "dolor sit amet"
    "#blue Lorem ipsum"     || "Lorem ipsum"
    "#bluedolor sit amet"   || "dolor sit amet"
  }

  @Unroll
  def "map() は投稿メッセージ本文 \"#messageSource\" の先頭が有効なコマンドではない時, command カラムは null を格納して返す"() {
    given:
    def messageForm = new MessageForm()
    messageForm.setMessage(messageSource)

    when:
    def result = service.map(messageForm, ZonedDateTime.now())

    then:
    result.command == expectedCommand

    where:
    messageSource           || expectedCommand
    "Lorem ipsum"           || null
    "#yellow dolor sit amet"|| null
    " #green Lorem ipsum"   || null
    "greendolor sit amet"   || null
  }

  @Unroll
  def "map() は投稿メッセージ本文 \"#messageSource\" の先頭が有効なコマンドではない時, そのまま message カラムに格納して返す"() {
    given:
    def messageForm = new MessageForm()
    messageForm.setMessage(messageSource)

    when:
    def result = service.map(messageForm, ZonedDateTime.now())

    then:
    result.message == expectedCommand

    where:
    messageSource           || expectedCommand
    "Lorem ipsum"           || "Lorem ipsum"
    "#yellow dolor sit amet"|| "#yellow dolor sit amet"
    " #green Lorem ipsum"   || " #green Lorem ipsum"
    "greendolor sit amet"   || "greendolor sit amet"
  }
}

このように Spock は Groovy 言語によって完結かつ直感的にテストを記述することができる。 given ブロックで初期条件を、whenブロックでテスト対象のコードを、thenブロックでテストの評価コードを記述する。 また、@Unrollwhere ブロックによって、テストデータをわかりやすくソースコードに列挙できる。

ここで ./gradlew test でユニットテストを実行しよう。以下のようにテストは失敗する。

> Task :test FAILED
Picked up _JAVA_OPTIONS: -Dfile.encoding=UTF-8
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by org.codehaus.groovy.reflection.CachedClass (file:/Users/yuki/.gradle/caches/modules-2/files-2.1/org.codehaus.groovy/groovy-all/2.4.15/423a17aeb2f64bc6f76e8e44265a548bec80fd42/groovy-all-2.4.15.jar) to method java.lang.Object.finalize()
WARNING: Please consider reporting this to the maintainers of org.codehaus.groovy.reflection.CachedClass
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release

com.example.messageboard.MessageMappingServiceSpec > map() は投稿メッセージ本文 "#blue Lorem ipsum" の先頭が有効なコマンドの時,コマンド文字列を command カラムに格納して返す FAILED
    org.spockframework.runtime.SpockComparisonFailure at MessageMappingServiceSpec.groovy:21

com.example.messageboard.MessageMappingServiceSpec > map() は投稿メッセージ本文 "#bluedolor sit amet" の先頭が有効なコマンドの時,コマンド文字列を command カラムに格納して返す FAILED
    org.spockframework.runtime.SpockComparisonFailure at MessageMappingServiceSpec.groovy:21

com.example.messageboard.MessageMappingServiceSpec > map() は投稿メッセージ本文 "#blue Lorem ipsum" の先頭が有効なコマンドの時,コマンド文字列と続く空白を除いた値を message カラムに格納して返す FAILED
    org.spockframework.runtime.SpockComparisonFailure at MessageMappingServiceSpec.groovy:43

com.example.messageboard.MessageMappingServiceSpec > map() は投稿メッセージ本文 "#bluedolor sit amet" の先頭が有効なコマンドの時,コマンド文字列と続く空白を除いた値を message カラムに格納して返す FAILED
    org.spockframework.runtime.SpockComparisonFailure at MessageMappingServiceSpec.groovy:43

20 tests completed, 4 failed

FAILURE: Build failed with an exception.

ソースを見ると typo していることがわかる。修正しよう。

src/main/java/com/example/messageboard/MessageMappingService.java

これでテストは成功する。

続けて、MessagesRepository クラスをテストしよう。

src/test/groovy/com/example/messageboard/MessagesRepositorySpec.groovy

package com.example.messageboard
 
import groovy.sql.Sql
import java.sql.Timestamp
import java.time.ZonedDateTime
import javax.sql.DataSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy
import org.springframework.test.context.transaction.BeforeTransaction
import org.springframework.transaction.annotation.Transactional
import spock.lang.Shared
import spock.lang.Specification
 
 
@SpringBootTest
class MessagesRepositorySpec extends Specification {
 
  @Autowired
  DataSource dataSource
 
  @Shared
  Sql sql
 
  @Autowired
  MessagesRepository messageRespository
 
  static Timestamp toTs(final ZonedDateTime zdt) {
    Timestamp.from(zdt.toInstant())
  }
 
  @BeforeTransaction
  def initSql() {
    if (sql) return
    // TransactionAwareDataSourceProxy でラップするとconnectionを共有するので
    // 自動でのロールバックが groovy.sql.Sql の操作に対しても有効になる
    sql = Sql.newInstance(new TransactionAwareDataSourceProxy(dataSource))
  }
 
  // @Transactional を設定すると、spec を抜けたあとにロールバックされる
  @Transactional
  def "findAllOrderByCreatedAtDesc() は作成日付降順ですべてのデータを取得する"() {
    given:
    def now = ZonedDateTime.now()
    [
      [ message: "message2", command: "#red",   created_at: toTs(now.plusDays(1)) ],
      [ message: "message1", command: "#green", created_at: toTs(now) ],
      [ message: "message3", command: "#blue",  created_at: toTs(now.plusDays(2)) ],
    ].each { sql.dataSet("messages").add(it) }
 
    when:
    def results = messageRespository.findAllOrderByCreatedAtDesc()
 
    then:
    results.size == 3
    results[0].message == "message3"
    results[0].command == "#blue"
    results[0].createdAt.compareTo(now.plusDays(2)) == 0
    results[1].message == "message2"
    results[1].command == "#red"
    results[1].createdAt.compareTo(now.plusDays(1)) == 0
    results[2].message == "message1"
    results[2].command == "#green"
    results[2].createdAt.compareTo(now) == 0
  }
 
  @Transactional
  def "saveAndFlush() で正しくデータを保存できる"() {
    given:
    def now = ZonedDateTime.now()
    def message = new Message()
    message.message = "Lorem ipsum dolor sit amet"
    message.command = "#red"
    message.createdAt = now
 
    when:
    messageRespository.saveAndFlush(message)
    def id = message.id
    def count = sql.firstRow("SELECT COUNT(*) AS cnt FROM messages").cnt
    def result = sql.firstRow("SELECT * FROM messages WHERE id=?", id)
 
    then:
    count == 1
    result.message == "Lorem ipsum dolor sit amet"
    result.command == "#red"
    result.created_at == toTs(now)
  }
}

ここからはフレームワークに依存するテストコードになる。 基本的には、Bean でデータベースに対して照会・変更操作を行い、groovy の Sql クラスを使って検証している。 こちらも直感的に記述できていることがわかるだろう。

このテストを実行するためにはテスト用のデータベース設定を容易したほうが良いろう。 以下のファイルを追加しよう。

src/test/resources/application.properties

# テストデータベース接続設定
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/messageboard_test
spring.datasource.username=messageboard
spring.datasource.password=password

前章を参考に messageboard_test テーブルを用意すればテストを実行できる。

次に MessageBoardController をテストする。

src/test/groovy/com/example/messageboard/MessageBoardControllerSpec.groovy

package com.example.messageboard
 
import java.time.ZonedDateTime
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.web.servlet.MockMvc
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import spock.lang.Specification
import spock.lang.Unroll
 
 
@SpringBootTest
@AutoConfigureMockMvc
class MessageBoardControllerSpec extends Specification {
 
  def messagesRepositoryMock = Mock(MessagesRepository)
 
  def messageMappingServiceMock = Mock(MessageMappingService)
 
  def messageBoardController = new MessageBoardController(
    messagesRepositoryMock, messageMappingServiceMock)
 
  def mockMvc = MockMvcBuilders.standaloneSetup(messageBoardController).build()
 
  def "POST / は正しいデータを受け取ったときに、データを登録して / にリダイレクトする"() {
    given:
    def message = new Message()
    message.message = "testmessage"
    message.command = "#red"
 
    when:
    def responce = mockMvc.perform(post("/").param("message", "#redtestmessage"))
 
    then:
    1 * messageMappingServiceMock.map({it.message == "#redtestmessage"}, _) >> message
    1 * messagesRepositoryMock.saveAndFlush({
      it.message == "testmessage"
      it.command == "#red"
    })
    responce.andExpect(redirectedUrl("/"))
  }
}

ここでは、注入されているサービスをモックに置き換えてリクエストに対する動作をテストしている。 Spock の Mock クラスは非常にわかりやすい記述ができる。 ビューが生成したレスポンスを確認することもできるが、今回はその部分は e2e テストに委ねることにする。

もう一度 ./gradlew test を実行してテストが通過することを確認する。

4. カバレッジ

Jacoco を使ってカバレッジの計測を行う。

まず、ビルドの設定を追加する。

build.gradle

ユニットテストの実行後に、./gradlew jacocoTestReport を実行して、計測を行う。

./build/jacocoHtml/index.html から結果を確認することができる。 MessageBoardController.index, MessageMappingService#parseMessage, Application.main, Message.getId に未達のコードが有ることがわかる。


Jacoco レポート1


Jacoco レポート2


Jacoco レポート3


Jacoco レポート4


Jacoco レポート5


Jacoco レポート6

まず Application.main, Message.getId は、main メソッドと getter なので気にしなくても良い。Application クラスは計測対象から除外してしまおう。

build.gradle


Jacoco レポート7

Message, MessageForm クラスも計測対象から除外してもよいが、Lombok などを使って getter, setter, コンストラクタのコード自体をなくしてしまうという方法もある。(Jacoco は Lombok 生成コードを計測しない機能を持っている) 今回は単純に無視してしまうことにする。

MessageMappingService#parseMessage は到達してはいけないコードである。これも無視してしまおう。(ただし、実装方法の検討余地はある)

残った MessageBoardController.index はテスト漏れである。テストを追加しよう。

src/test/groovy/com/example/messageboard/MessageBoardControllerSpec.groovy


Jacoco レポート8
これで、無視することにしたもの以外の部分についてテストが走っていることが確認できた。

次回

第3回 Checkstyle によるコードスタイル検証


社内サーバにリモートリポジトリを作るのも一つですが、「開発にまつわる面倒事」をこの際全部、tracpath(トラックパス)に任せてみませんか?
バージョン管理サービス・プロジェクト管理サービスの「tracpath(トラックパス)」では、
ユーザー5名、リポジトリ数3つまで、無料で利用可能です。

さっそく実務でも使って見ましょう。
自らも開発を行う会社が作ったからこそ、開発チームの「作る情熱」を支える、やるべきことに集中出来るサービスになっています。
エンタープライズ利用が前提のASPサービスなので、セキュリティも強固です。