【Java版】CI(継続的インテグレーション)ツール導入ガイド:第5回 End to End (e2e) テスト

JavaによるCI導入ガイドの第5回は End to End (e2e) テストを行う。

目次

1. パッケージ導入

Geb を導入してE2E試験を行う。 Selenium ドライバは好きなものを利用できるが、今回は Chrome と Firefox を利用することにする。 今回は、e2e という(gradleの)子プロジェクトを作成し、その中ですでに起動しているアプリケーションサーバに接続する形で試験を行う。

./e2e ディレクトリ内の構成

e2e
├── build.gradle
└── src
    └── test
        ├── groovy
        │   └── IndexPageSpec.groovy
        └── resources
            └── GebConfig.groovy

まず、ビルドスクリプトを用意する。

e2e/build.gradle

buildscript {
    repositories {
        mavenCentral()
    }
}

// Gradle プラグインの定義
plugins {
    id "idea"
    id "groovy"
    id "com.energizedwork.webdriver-binaries" version "1.4"
    id "com.energizedwork.idea-base" version "1.4"
}

ext {
    // 使用するドライバの定義
    // chrome ドライバと Firefox ドライバを利用する
    // dev はGUIのある環境でのテストの設定
    // ci は GUI のない環境で行うための設定(ヘッドレスブラウザのみを用いる)
    drivers = []
    availableDrivers = [
      dev: ["firefox", "chrome", "firefoxHeadless", "chromeHeadless"],
      ci: ["chromeHeadless", "firefoxHeadless"],
    ]

    // 各ライブラリなどのバージョンの設定
    ext {
        groovyVersion = '2.4.15'
        gebVersion = '2.2'
        seleniumVersion = '3.14.0'
        chromeDriverVersion = '2.41'
        geckoDriverVersion = '0.21.0'
    }
}

// 共通設定を読み込む
apply from: '../common.gradle'

// 外部に書き出したヘルパメソッド定義を読み込む
apply from: '../helper.gradle'

repositories {
    mavenCentral()
}

// Java のバージョン指定
sourceCompatibility = 1.10
targetCompatibility = 1.10

// 依存パッケージ
dependencies {
    testCompile "org.gebish:geb-spock:$gebVersion"
    testCompile("org.spockframework:spock-core:1.1-groovy-2.4") {
        exclude group: "org.codehaus.groovy"
    }
    testCompile "org.codehaus.groovy:groovy-all:$groovyVersion"
    testCompile "org.seleniumhq.selenium:selenium-chrome-driver:$seleniumVersion"
    testCompile "org.seleniumhq.selenium:selenium-firefox-driver:$seleniumVersion"
}

// webdriver 設定
webdriverBinaries {
    chromedriver chromeDriverVersion
    geckodriver geckoDriverVersion
}

// -Pci=yes が与えられたらヘッドレスのみのテストの実行を行う
if (hasProperty("ci")) {
    drivers = availableDrivers["ci"]
} else {
    drivers = availableDrivers["dev"]
}

// 各ドライバでのテストのタスク定義
drivers.each { driver ->
    task "${driver}Test"(type: Test) {
        group JavaBasePlugin.VERIFICATION_GROUP

        outputs.upToDateWhen { false }  // Always run tests

        systemProperty "geb.build.reportsDir", reporting.file("geb/$name")
        systemProperty "geb.env", driver
    }
}

// デフォルトのテストタスクは実行しない
test {
    enabled = false
}

// e2e テスト用の e2e:e2eTest タスクを設定する
// test タスクのフェーズでは実行しないことにする
task e2eTest {
    dependsOn drivers.collect { tasks["${it}Test"] }
}

// メモリ設定など
tasks.withType(Test) {
    maxHeapSize = "1g"
    jvmArgs '-XX:MaxMetaspaceSize=128m'
    testLogging {
        exceptionFormat = 'full'
    }
}

tasks.withType(GroovyCompile) {
    groovyOptions.forkOptions.memoryMaximumSize = '256m'
}

// -Pe2eServerUrl で接続先のURLを指定できるようにする
// 指定がなかった場合は common.gradle の設定を使用する
def e2eServerUrl = project.e2eServerDefaultUrl
if (hasProperty('e2eServerUrl')) {
    e2eServerUrl = getProperty('e2eServerUrl')
}

// ドライバタスクに対する依存性の設定など
drivers.each { driver ->
    // Geb の接続先URL設定
    def task = tasks["${driver}Test"]
    task.systemProperty "geb.build.baseUrl", e2eServerUrl

    // テストの開始前にアプリケーションサーバの起動を待機する
    task.doFirst {
      project.waitForStartingHttpServer e2eServerUrl, "/"
    }

    // build タスクに依存させて jar ファイルが生成されているようにする
    task.dependsOn parent.tasks["build"]
}

マルチプロジェクトの設定のために、プロジェクトルートに ./settings.gradle を作成する。

settings.gradle

include ':e2e'

必要な共通設定の項目を追加する。

common.gradle

補助メソッド(サーバ起動待機処理)のためのファイルを追加する。

helper.gradle

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath "org.codehaus.groovy.modules.http-builder:http-builder:0.6"
    }
}

// HTTP サーバの起動を待つ
def waitForStartingHttpServer(final String server, final String path) {
    final http = new groovyx.net.http.HTTPBuilder(server)
    final cnt = project.e2eServerStartupWaitCount
    final wait = project.e2eServerStartupWaitTime
    def success = false
    for(final i in 0..<cnt) {
        try {
            logger.info "sleep $wait ms..."
            sleep wait
            logger.info "(re)try to connect ${server}..."
            http.get(path: path)
            success = true
            logger.info "successed to connect server!"
            break
        } catch(final org.apache.http.client.clientprotocolexception e) {
            logger.info "failed to connect server. message: ${e.message}"
            continue
        } catch(final org.apache.http.nohttpresponseexception e) {
            logger.info "failed to connect server. message: ${e.message}"
            continue
        } catch(final exception e) {
            throw new runtimeexception(e)
        }
    }
    if (!success) {
        throw new gradleexception("unable to check server startup: ${server}.")
    }
}

ext {
    waitforstartinghttpserver = this.&waitforstartinghttpserver
}

2. テスト実行

まず、Geb の設定ファイルを設置する。 主にドライバの定義などを行っている。

e2e/src/test/resources/GebConfig.groovy

import org.openqa.selenium.chrome.ChromeDriver
import org.openqa.selenium.chrome.ChromeOptions
import org.openqa.selenium.firefox.FirefoxBinary
import org.openqa.selenium.firefox.FirefoxDriver
import org.openqa.selenium.firefox.FirefoxOptions


waiting {
  timeout = 2
}

environments {
  
  // Chrome GUI ブラウザテスト設定
  chrome {
    driver = {
      ChromeOptions o = new ChromeOptions()
      // この2つのオプションは Ubuntu 上でのエラー回避
      o.addArguments("--no-sandbox");
      o.addArguments("--disable-dev-shm-usage");
      new ChromeDriver(o)
    }
  }

  // Chrome ヘッドレスブラウザテスト設定
  chromeHeadless {
    driver = {
      ChromeOptions o = new ChromeOptions()
      o.addArguments('headless')
      o.addArguments("--no-sandbox");
      o.addArguments("--disable-dev-shm-usage");
      new ChromeDriver(o)
    }
  }
  
  // Firefox GUI ブラウザテスト設定
  firefox {
    atCheckWaiting = 1

    driver = { new FirefoxDriver() }
  }

  
  // Firefox ヘッドレスブラウザテスト設定
  firefoxHeadless {
    atCheckWaiting = 1

    driver = {
      FirefoxBinary firefoxBinary = new FirefoxBinary()
      firefoxBinary.addCommandLineOptions("--headless")
      FirefoxOptions firefoxOptions = new FirefoxOptions()
      firefoxOptions.setBinary(firefoxBinary)
      new FirefoxDriver(firefoxOptions)
    }
  }
}

次に、テストコードを追加する。

src/test/groovy/IndexPageSpec.groovy

import geb.Module
import geb.Page
import geb.spock.GebSpec
import java.util.UUID
import spock.lang.Unroll


// 投稿フォーム用モジュール
class FormModule extends Module { 

  static content = { 
    // 送信フォーム
    form { $(".message-form form") }

    // メッセージ入力ボックス
    messageInput { form.$("input", name: "message") }

    // 送信ボタン
    submitButton(to: IndexPage) { form.$("button", type: "submit") }
  }

  // テキストボックスにメッセージを入力する
  void setMessage(message) {
    messageInput = message
  }

  // メッセージを送信する
  void submit() {
    submitButton.click()
  }
}

// メッセージ表示用モジュール
class MessageViewModule extends Module {

  static content = {
    view { $("span") }

    // メッセージに表示されたテキスト
    message { view.text() }

    // メッセージのテキストカラー
    color { view.css("color") }
  }
}

// メッセージ一覧用モジュール
class MessageListModule extends Module {

  static content = {
    messages { $(".messages-list ul li").moduleList(MessageViewModule) }
  }
}

// インデックス画面オブジェクト
class IndexPage extends Page {

  static url = "/"

  static at = { title == "一行掲示板" }

  static content = {
    form { module FormModule }
    messageList { module MessageListModule }
  }
}

// インデックス画面 spec
class IndexPageSpec extends GebSpec {

  def message

  def setup() {
    def id = UUID.randomUUID()
    // メッセージを spec 毎にランダムで生成する
    message = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ${id}"
  }

  def "コマンドが無いメッセージを投稿すると、一番先頭に黒色で表示される"() {
    given:
    // インデックスページに移動する
    to IndexPage

    when:
    // メッセージ入力テキストボックスにメッセージを入力する
    form.setMessage(message)
    // 投稿ボタンを押す
    form.submit()

    then:
    // インデックスページに遷移する
    at IndexPage
    // 投稿一覧の先頭のメッセージは今投稿したメッセージである
    messageList.messages[0].message == message
    // 文字色を確認する
    // css("color") は ブラウザによって rgb/rgba を返す模様
    messageList.messages[0].color =~ /rgba?\(0, 0, 0(, 1)?\)/
  }

  @Unroll
  def "#command コマンドが先頭についたメッセージを投稿すると、#colorReg で表示される"() {
    given:
    to IndexPage

    when:
    form.setMessage("${command}${message}")
    form.submit()

    then:
    at IndexPage
    messageList.messages[0].message == message
    messageList.messages[0].color =~ colorReg

    where:
    command  || colorReg
    "#red"   || /rgba?\(201, 58, 64(, 1)?\)/
    "#green" || /rgba?\(86, 167, 100(, 1)?\)/
    "#blue"  || /rgba?\(0, 116, 191(, 1)?\)/
  }

  @Unroll
  def "コマンド(#command)の後ろの空白は無視される"() {
    given:
    to IndexPage
    
    when:
    form.setMessage("${command}     ${message}")
    form.submit()
    
    then:
    at IndexPage
    messageList.messages[0].message == message

    where:
    command  || _
    "#red"   || _
    "#green" || _
    "#blue"  || _
  }

  @Unroll
  def "コマンド(#command)の前に空白があるとコマンドとして認識されない"() {
    given:
    to IndexPage

    when:
    form.setMessage(" ${command}${message}")
    form.submit()

    then:
    at IndexPage
    // geb からノードのテキストを受け取るときに空白はトリムされる模様
    messageList.messages[0].message == "${command}${message}"

    where:
    command  || _
    "#red"   || _
    "#green" || _
    "#blue"  || _
  }

  def "空のメッセージは受け付けない(表示されない)"() {
    given:
    to IndexPage

    when:
    form.setMessage(message)
    form.submit()
    form.setMessage("")
    form.submit()

    then:
    at IndexPage
    messageList.messages[0].message == message
  }

  def "タグのみのメッセージは受け付けない(表示されない)"() {
    given:
    to IndexPage

    when:
    form.setMessage(message)
    form.submit()
    form.setMessage("${command}  ")
    form.submit()

    then:
    at IndexPage
    messageList.messages[0].message == message

    where:
    command  || _
    "#red"   || _
    "#green" || _
    "#blue"  || _
  }
}

Geb は JQuery ライクの API で DOM にアクセスするが、各要素へのアクセスをモジュールで抽象化する構成になっている。 そのため、テストコードからは実際の DOM 構造が隠蔽され、見通しの良いテストコードを書くことができる。

ブラウザを実際に操作して行うテストであるため、Javascript の実行確認なども行うことができる。 今回のケースでは文字色を確認しているが、これは実際にレンダリングされた色を取得している。

テストを実行してみよう。 まず./gradlew bootRunでアプリケーションを起動し、別のターミナルから ./gradlew e2e:e2eTest を実行する。 実行するマシンには Chrome と Firefox がインストールされている必要がある。 GUI ブラウザでのテストのときには、実際にブラウザが立ち上がってテストが行われる様子が確認できるだろう。

今回の実装では行っていないが、データベースへの接続の設定を適切に与えて、データのクリア処理を行えるようにする必要もあるだろう。

実行結果は以下のようになる。

...

IndexPageSpec > 空のメッセージは受け付けない(表示されない) FAILED
    geb.error.UnexpectedPageException: At checker page verification failed for page IndexPage
        at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
        at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
        at geb.navigator.NonEmptyNavigator.click(NonEmptyNavigator.groovy:461)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at geb.navigator.NonEmptyNavigator.click(NonEmptyNavigator.groovy:438)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at geb.content.TemplateDerivedPageContent.click(TemplateDerivedPageContent.groovy:80)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at FormModule.submit(IndexPageSpec.groovy:29)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at geb.content.TemplateDerivedPageContent.methodMissing(TemplateDerivedPageContent.groovy:95)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at IndexPageSpec.空のメッセージは受け付けない(表示されない)(IndexPageSpec.groovy:164)

        Caused by:
        Assertion failed:

        title == "一行掲示板"
        |     |
        ""    false
            at IndexPage._clinit__closure1(IndexPageSpec.groovy:60)
            at IndexPage._clinit__closure1(IndexPageSpec.groovy)
            at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
            at geb.Page.verifyThisPageAtOnly(Page.groovy:245)
            at geb.Page.getAtVerificationResult(Page.groovy:223)
            at geb.Page.verifyAt(Page.groovy:194)
            at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
            at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
            at geb.Browser.methodMissing(Browser.groovy:217)
            at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
            at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
            at geb.navigator.NonEmptyNavigator.click(NonEmptyNavigator.groovy:450)
            ... 13 more

IndexPageSpec > タグのみのメッセージは受け付けない(表示されない) FAILED
    Condition not satisfied:

    messageList.messages[0].message == message
    |           |       |   |       |  |
    |           |       |   ""      |  Lorem ipsum dolor sit amet, consectetur adipiscing elit. e66b1afe-57f8-4350-b628-db0a94ad9439
    |           |       |           false
    |           |       |           93 differences (0% similarity)
    |           |       |           (---------------------------------------------------------------------------------------------)
    |           |       |           (Lorem ipsum dolor sit amet, consectetur adipiscing elit. e66b1afe-57f8-4350-b628-db0a94ad9439)
    |           |       MessageViewModule
    |           [MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule]
    IndexPage -> messageList: MessageListModule
        at IndexPageSpec.タグのみのメッセージは受け付けない(表示されない)(IndexPageSpec.groovy:183)

    Condition not satisfied:

    messageList.messages[0].message == message
    |           |       |   |       |  |
    |           |       |   ""      |  Lorem ipsum dolor sit amet, consectetur adipiscing elit. 65ec5759-2939-471a-8cc0-25f2fdbbed92
    |           |       |           false
    |           |       |           93 differences (0% similarity)
    |           |       |           (---------------------------------------------------------------------------------------------)
    |           |       |           (Lorem ipsum dolor sit amet, consectetur adipiscing elit. 65ec5759-2939-471a-8cc0-25f2fdbbed92)
    |           |       MessageViewModule
    |           [MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule]
    IndexPage -> messageList: MessageListModule
        at IndexPageSpec.タグのみのメッセージは受け付けない(表示されない)(IndexPageSpec.groovy:183)

    Condition not satisfied:

    messageList.messages[0].message == message
    |           |       |   |       |  |
    |           |       |   ""      |  Lorem ipsum dolor sit amet, consectetur adipiscing elit. 86237fc8-ab63-4afa-b9a9-5329165a969a
    |           |       |           false
    |           |       |           93 differences (0% similarity)
    |           |       |           (---------------------------------------------------------------------------------------------)
    |           |       |           (Lorem ipsum dolor sit amet, consectetur adipiscing elit. 86237fc8-ab63-4afa-b9a9-5329165a969a)
    |           |       MessageViewModule
    |           [MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule, MessageViewModule]
    IndexPage -> messageList: MessageListModule
        at IndexPageSpec.タグのみのメッセージは受け付けない(表示されない)(IndexPageSpec.groovy:183)

12 tests completed, 2 failed

> Task :e2e:chromeHeadlessTest FAILED

FAILURE: Build failed with an exception.

...

空のメッセージの処理が失敗していることがわかる。そもそもバリデーションを実装していなかった。何ということだ。 急いでバリデーションを実装しよう。

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

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

diff --git a/src/main/java/com/example/messageboard/MessageBoardController.java b/src/main/java/com/example/messageboard/MessageBoardController.java
index 87db1c4..a318a8f 100644
--- a/src/main/java/com/example/messageboard/MessageBoardController.java
+++ b/src/main/java/com/example/messageboard/MessageBoardController.java
@@ -4,6 +4,7 @@ import java.time.ZonedDateTime;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.BindingResult;
+import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.ModelAttribute;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestMethod;
@@ -49,10 +50,7 @@ public class MessageBoardController {
   public ModelAndView index(
       @ModelAttribute("messageForm") final MessageForm messageForm,
       final ModelAndView mav) {
-    mav.setViewName("index");
-    mav.addObject(
-        "messages", this.messagesRepository.findAllOrderByCreatedAtDesc());
-    return mav;
+    return this.showIndex(mav);
   }
  
   /**
@@ -70,10 +68,25 @@ public class MessageBoardController {
   @RequestMapping(value = "/", method = RequestMethod.POST)
   @Transactional(readOnly = false)
   public ModelAndView create(
-      @ModelAttribute("messageForm") final MessageForm messageForm,
+      @ModelAttribute("messageForm") @Validated final MessageForm messageForm,
+      final BindingResult result,
       final ModelAndView mav) {
-    var message = this.messageMappingService.map(messageForm, ZonedDateTime.now());
-    this.messagesRepository.saveAndFlush(message);
-    return new ModelAndView("redirect:/");
+    if (!result.hasErrors()) {
+      var message = this.messageMappingService.map(messageForm, ZonedDateTime.now());
+      this.messagesRepository.saveAndFlush(message);
+      return new ModelAndView("redirect:/");
+    } else {
+      return this.showIndex(mav);
+    }
+  }
+
+  /**
+   * 一覧表示画面のレンダリングを行う.
+   */
+  protected ModelAndView showIndex(final ModelAndView mav) {
+    mav.setViewName("index");
+    mav.addObject(
+        "messages", this.messagesRepository.findAllOrderByCreatedAtDesc());
+    return mav;
   }
 }

src/main/resources/templates/index.html

これで空のメッセージとコマンドだけのメッセージのときはエラー表示を行うようになった。 バリデーションのユニットテストを追加しよう。

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

package com.example.messageboard
 
import spock.lang.Specification
import spock.lang.Unroll
import javax.validation.Validation
 
 
class MessageFormSpec extends Specification {
  
  def validator = Validation.buildDefaultValidatorFactory().getValidator()

  @Unroll
  def "message にブランク\"#message\"を渡せない" () {
    when:
    def messageFrom = new MessageForm()
    messageFrom.message = message
    def violationSet = validator.validate(messageFrom)

    then:
    violationSet.size() == 1

    where:
    message || _
    ""      || _
    "   "   || _
  }

  @Unroll
  def "message にコマンドだけの文字列\"#message\"を渡せない" () {
    when:
    def messageFrom = new MessageForm()
    messageFrom.message = message
    def violationSet = validator.validate(messageFrom)

    then:
    violationSet.size() == 1

    where:
    message    || _
    "#red"     || _
    "#red  "   || _
    "#green"   || _
    "#green  " || _
    "#blue"    || _
    "#blue  "  || _
  }
}

更に、エラー時のリクエスト処理のテストも追加する。

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

まずユニットテストと各種解析を確認して、再び e2e テストを実施する。今度は通過する。

次回

第6回 CI設定


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

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