かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot 1.4.x の Web アプリを 1.5.x へバージョンアップする ( その5 )( Thymeleaf を 2.1.5 → 3.0.6 へバージョンアップする )

概要

記事一覧はこちらです。

Spring Boot 1.4.x の Web アプリを 1.5.x へバージョンアップする ( その4 )( 1.4系 → 1.5系で変更された点を修正する ) の続きです。

参照したサイト・書籍

  1. AttoParser
    http://www.attoparser.org/

  2. jsoup: Java HTML Parser
    https://jsoup.org/

  3. sagan/sagan-site/src/it/java/saganx/AuthenticationTests.java https://github.com/spring-io/sagan/blob/master/sagan-site/src/it/java/saganx/AuthenticationTests.java

    • Jsoup の使い方を参考にしました。
  4. Convert xPath to JSoup query
    http://stackoverflow.com/questions/16335820/convert-xpath-to-jsoup-query

  5. Groovyのヒアドキュメント
    http://d.hatena.ne.jp/Kazuhira/20130715/1373878511

目次

  1. 変更の方針
  2. build.gradle を変更する
  3. SpELコンパイラを有効にします
  4. mainparts.html の head タグを th:fragment 化し、head-cssjs.html を削除する(その1)
  5. ここで一旦動作確認する
  6. xpath() の代わりに使用できる HTML 5 チェック用の html() ResultMatcher を作成する
  7. 失敗したテストを変更する
  8. 続きます。。。

手順

変更の方針

  • Thymeleaf は 2.1.5 → 3.0.6 へバージョンアップします。Thymeleaf の関連ライブラリもバージョンアップします。
  • SpELコンパイラを有効にします。
  • mainparts.html の head タグに th:fragment を追記します。
  • head-cssjs.html の内容を mainparts.html の head タグ内にコピーし、head-cssjs.html は削除します。
  • mainparts.html の head タグ内の meta, link タグの末尾の “/” は削除します。
  • Fragment Expressions, The No-Operation token の機能を利用します。各画面用 Thymeleaf テンプレートの head タグを mainparts.html の head タグを使用するように変更し、各画面用 Thymeleaf テンプレートには画面固有のタグのみ記述して、そのタグが mainparts.html の head タグに反映されるようにします。
  • LibraryHelper#getSelectedLibrary では図書館が選択されている時だけ “選択中:…” の文字を返すようにし、"※図書館が選択されていません" の文字列は mainparts.html の head タグ内で The No-Operation token の機能を利用して表示するようにします。
  • 全ての各画面用 Thymeleaf テンプレートに <!--/* @thymesVar id="..." type="..." */--> が記述されていないので、form タグの上に追加します。

build.gradle を変更する

  1. build.gradle を リンク先のその1の内容 に変更します。

  2. 変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

    更新後、Project Tool Window の External Libraries を見て、ライブラリが更新されていることを確認します。

    f:id:ksby:20170521110748p:plain

BOM に記述されたライブラリのバージョンを変更する方法として今回は Dependency management plugin が提供する bomProperty を使用しましたが、それ以外に build.gradle に ext で記述する方法や、gradle.properties を使用する方法があります。詳細は以下の Web ページに記述されています。

SpELコンパイラを有効にします

  1. src/main/java/ksbysample/webapp/lending/config/WebMvcConfig.java を新規作成し、リンク先の内容 を記述します。

mainparts.html の head タグを th:fragment 化し、head-cssjs.html を削除する(その1)

  1. src/main/resources/templates/common/mainparts.html を リンク先の内容 に変更します。

  2. 各画面用 Thymeleaf テンプレートを1ファイルだけ変更します。src/main/resources/templates/admin/library/library.html を リンク先の内容 に変更します。

ここで一旦動作確認する

ここまでの変更で「検索対象図書館登録」画面は表示できるようになっているはずなので、確認してみます。

library_forsearch テーブルをクリアしてから bootRun で Tomcat を起動した後、http://localhost:8080/ にアクセスして tanaka.taro@sample.com / taro でログインすると「検索対象図書館登録」画面が表示されました。

f:id:ksby:20170521151522p:plain

HTML の <head> ... </head> タグは以下のように出力されており想定通りです。

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>検索対象図書館登録</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css">
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css">
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css">
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css">
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css">

    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
    <script src="/js/html5shiv.min.js"></script>
    <script src="/js/respond.min.js"></script>
    <![endif]-->

    <!-- ここに各htmlで定義された link タグが追加される -->
    

    <style type="text/css">
        <!--
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        .content-wrapper {
            background-color: #fffafa;
        }
        .noselected-library {
            color: #ff8679 !important;
            font-size: 100%;
            font-weight: 700;
        }
        .selected-library {
            color: #ffffff !important;
            font-size: 100%;
            font-weight: 700;
        }
        .table>tbody>tr>td
        , .table>tbody>tr>th
        , .table>tfoot>tr>td
        , .table>tfoot>tr>th
        , .table>thead>tr>td
        , .table>thead>tr>th {
            padding: 5px;
            font-size: 90%;
        }
        -->
    </style>

    <!-- ここに各htmlで定義された style タグが追加される -->
    
</head>

Tomcat を停止します。

次に clean タスク → Rebuild Project → build タスクを実行してみます。

build タスクでいくつかのテストが失敗しました。

f:id:ksby:20170521152945p:plain

Project Tool Window の src/test から「Run ‘All Tests’ with Coverage」も実行してみると、失敗しているテストで org.xml.sax.SAXParseException; lineNumber: 63; columnNumber: 3; 要素タイプ"link"は、対応する終了タグ"</link>"で終了する必要があります。 というメッセージが出力されていることが分かります。

f:id:ksby:20170521153512p:plain

Thymeleaf 3 にバージョンアップしたので SAX は使われなくなったのでは?と思ってテストのコードを見てみると、xpath を使用してチェックしていました。これが SAX を使用しているようです。

f:id:ksby:20170521154043p:plain

xpath() の代わりに使用できる HTML 5 チェック用の html() ResultMatcher を作成する

Thymeleaf 3 に合わせて HTML を XML 形式にしないように変更すると xpath() ResultMatcher が使用できなくなりました。HTML 5 をチェック可能な ResultMatcher を作成することにします。

Thymeleaf 3 ten-minute migration guide に書かれているリンク先の Fragment Expressions を見ると、Thymeleaf 3 で使用されている Parser が AttoParser というライブラリであることが分かります。

AttoParser のドキュメントや Thymeleaf の GitHub を見てみたのですが、ちょっと簡単に使えるライブラリではないように見えます。今回は jQuery と同じような CSS の指定で HTML の要素を取得することができる Jsoup を使用して ResultMatcher を作成することにします。

  1. build.gradle を リンク先のその2の内容 に変更します。

  2. 変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

  3. src/test/java/ksbysample/common/test/matcher/HtmlResultMatchers.java を新規作成し、リンク先の内容 を記述します。

  4. テストを作成します。src/groovy/test/ksbysample/common/test/matcher/HtmlResultMatchersTest.groovy を新規作成し、リンク先の内容 を記述します。

  5. 作成したテストを実行し、全てのテストが成功することを確認します。

    f:id:ksby:20170523101841p:plain f:id:ksby:20170523101952p:plain

失敗したテストを変更する

  1. src/test/java/ksbysample/webapp/lending/web/admin/library/AdminLibraryControllerTest.javaリンク先の内容 に変更します。

  2. テストを実行し、成功することを確認します。

    f:id:ksby:20170523160644p:plain

続きます。。。

1度調査しているのですぐに終わると思っていましたが、以外なところでつまずきました。一旦ここで区切ります。

ソースコード

build.gradle

■その1

dependencyManagement {
    imports {
        mavenBom("io.spring.platform:platform-bom:Brussels-SR2") {
            bomProperty 'guava.version', '21.0'
            bomProperty 'thymeleaf.version', '3.0.6.RELEASE'
            bomProperty 'thymeleaf-extras-springsecurity4.version', '3.0.2.RELEASE'
            bomProperty 'thymeleaf-layout-dialect.version', '2.2.2'
            bomProperty 'thymeleaf-extras-data-attribute.version', '2.0.1'
            bomProperty 'thymeleaf-extras-java8time.version', '3.0.0.RELEASE'
        }
    }
}
  • dependencyManagement に以下の記述を追加します。
    • bomProperty 'thymeleaf.version', '3.0.6.RELEASE'
    • bomProperty 'thymeleaf-extras-springsecurity4.version', '3.0.2.RELEASE'
    • bomProperty 'thymeleaf-layout-dialect.version', '2.2.2'
    • bomProperty 'thymeleaf-extras-data-attribute.version', '2.0.1'
    • bomProperty 'thymeleaf-extras-java8time.version', '3.0.0.RELEASE'

■その2

dependencies {
    ..........

    // dependency-management-plugin によりバージョン番号が自動で設定されるもの
    // Appendix A. Dependency versions ( http://docs.spring.io/platform/docs/current/reference/htmlsingle/#appendix-dependency-versions ) 参照
    ..........

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    ..........
    testCompile("com.jayway.jsonpath:json-path:2.2.0")
    testCompile("org.jsoup:jsoup:1.10.2")
    testCompile("cglib:cglib-nodep:3.2.5")
    testCompile("org.spockframework:spock-core:${spockVersion}") {
    ..........
}
  • dependencies に以下の2行を追加します。cglib:cglib-nodep は Spock で Mock を使用するために必要となるライブラリです。指定しないとテスト実行時に org.spockframework.mock.CannotCreateMockException: Cannot create mock for class org.springframework.mock.web.MockHttpServletResponse. Mocking of non-interface types requires a code generation library. Please put byte-buddy-1.6.4 or cglib-nodep-3.2 or higher on the class path. というメッセージが出ます。
    • testCompile("org.jsoup:jsoup:1.10.2")
    • testCompile("cglib:cglib-nodep:3.2.5")

WebMvcConfig.java

package ksbysample.webapp.lending.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.thymeleaf.spring4.SpringTemplateEngine;

@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {

    /**
     * Thymeleaf 3 のパフォーマンスを向上させるために SpEL コンパイラを有効にする
     *
     * @param templateEngine {@link SpringTemplateEngine} オブジェクト
     */
    @Autowired
    public void configureThymeleafSpringTemplateEngine(SpringTemplateEngine templateEngine) {
        templateEngine.setEnableSpringELCompiler(true);
    }

}

common/mainparts.html

<head th:fragment="head(title, links, style)">
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title th:replace="${title} ?: _">検索対象図書館登録</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css">
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css">
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css">
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css">
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css">

    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
    <script src="/js/html5shiv.min.js"></script>
    <script src="/js/respond.min.js"></script>
    <![endif]-->

    <!-- ここに各htmlで定義された link タグが追加される -->
    <th:block th:replace="${links} ?: _"/>

    <style type="text/css">
        <!--
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        .content-wrapper {
            background-color: #fffafa;
        }
        .noselected-library {
            color: #ff8679 !important;
            font-size: 100%;
            font-weight: 700;
        }
        .selected-library {
            color: #ffffff !important;
            font-size: 100%;
            font-weight: 700;
        }
        .table>tbody>tr>td
        , .table>tbody>tr>th
        , .table>tfoot>tr>td
        , .table>tfoot>tr>th
        , .table>thead>tr>td
        , .table>thead>tr>th {
            padding: 5px;
            font-size: 90%;
        }
        -->
    </style>

    <!-- ここに各htmlで定義された style タグが追加される -->
    <th:block th:replace="${style} ?: _"/>
</head>
  • head タグに th:fragment="head(title, links, style)" を追加します。
  • title タグに th:replace="${title} ?: _" を追加します。
  • <!-- Bootstrap 3.3.4 --> から </head> の前までの部分に src/main/resources/templates/common/head-cssjs.html の内容をコピーします。
  • <th:block th:replace="${links} ?: _"/> を追加します。
  • <th:block th:replace="${style} ?: _"/> を追加します。
  • meta, link タグの末尾の “/” を削除します。

admin/library/library.html

<head th:replace="~{common/mainparts :: head(~{::title}, ~{::link}, ~{::style})}">
    <title>検索対象図書館登録</title>
</head>
  • head タグに th:replace="~{common/mainparts :: head(~{::title}, ~{::link}, ~{::style})}" を追加します。
  • <head> ... </head> の中には <title>検索対象図書館登録</title> だけ記述します。

HtmlResultMatchers.java

package ksbysample.common.test.matcher;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultMatcher;

import java.io.UnsupportedEncodingException;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.util.AssertionErrors.assertTrue;

/**
 * Jsoup ( https://jsoup.org/ ) を利用して HTML のチェックをする
 * {@link org.springframework.test.web.servlet.ResultActions#andExpect(ResultMatcher)} で使用するためのクラス
 * <p>
 * <pre>{@code
 *      mockMvc.perform(get("/"))
 *          .andExpect(status().isOk())
 *          .andExpect(html("#id").text("ここに期待する文字列を記載する"));
 *          .andExpect(html("div > div:eq(1) > a:eq(1)").count(1));
 *          .andExpect(html(".css-selector").exists());
 * }</pre>
 */
public class HtmlResultMatchers {

    private String cssQuery;

    public HtmlResultMatchers(String cssQuery) {
        this.cssQuery = cssQuery;
    }

    /**
     * {@link HtmlResultMatchers} オブジェクト生成用の static メソッド
     *
     * @param cssQuery {@link org.jsoup.nodes.Element#select(String)} に指定する CSS セレクタ
     * @return {@link HtmlResultMatchers}
     */
    public static HtmlResultMatchers html(String cssQuery) {
        return new HtmlResultMatchers(cssQuery);
    }

    /**
     * HTML 内の cssQuery で指定された部分の文字列を抽出し、引数で渡された文字列と同じかチェックする
     *
     * @param expectedText 文字列の期待値
     * @return {@link ResultMatcher}
     */
    public ResultMatcher text(final String expectedText) {
        return mvcResult -> assertThat(selectFirst(mvcResult).text(), is(expectedText));
    }

    /**
     * HTML 内の cssQuery で指定された要素数を取得し、引数で渡された数と同じかチェックする
     *
     * @param expectedCount 要素数の期待値
     * @return {@link ResultMatcher}
     */
    public ResultMatcher count(final int expectedCount) {
        return mvcResult -> assertThat(select(mvcResult).size(), is(expectedCount));
    }

    /**
     * HTML 内の cssQuery で指定された要素が存在するかチェックする
     *
     * @return {@link ResultMatcher}
     */
    public ResultMatcher exists() {
        return mvcResult -> assertTrue("cssQuery '" + this.cssQuery + "' does not exist"
                , select(mvcResult).size() != 0);
    }

    /**
     * HTML 内の cssQuery で指定された要素が存在しないかチェックする
     *
     * @return {@link ResultMatcher}
     */
    public ResultMatcher notExists() {
        return mvcResult -> assertTrue("cssQuery '" + this.cssQuery + "' exists"
                , select(mvcResult).size() == 0);
    }

    private Document parseHtml(MvcResult mvcResult) throws UnsupportedEncodingException {
        return Jsoup.parse(mvcResult.getResponse().getContentAsString());
    }

    private Elements select(MvcResult mvcResult) throws UnsupportedEncodingException {
        return parseHtml(mvcResult).select(this.cssQuery);
    }

    private Element selectFirst(MvcResult mvcResult) throws UnsupportedEncodingException {
        Element element = select(mvcResult).first();
        if (element == null) {
            throw new AssertionError(
                    String.format("指定された cssQuery の Element は存在しません ( cssQuery = %s )", this.cssQuery));
        }
        return element;
    }

}

HtmlResultMatchersTest.groovy

package ksbysample.common.test.matcher

import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.test.web.servlet.DefaultMvcResult
import spock.lang.Specification
import spock.lang.Unroll

class HtmlResultMatchersTest extends Specification {

    def TEST_HTML = '''\
        <html>
            <body>
                <div id="title">メニュー一覧</div>
                <div>
                    <ul class="menu">
                        <li class="item">メニュー1</li>
                        <li class="item">メニュー2</li>
                    </ul>
                </div>
            </body>
        </html>
    '''

    def mvcResult

    def setup() {
        def mockRequest = new MockHttpServletRequest()
        def mockResponse = Mock(MockHttpServletResponse)
        mockResponse.getContentAsString() >> TEST_HTML
        this.mvcResult = new DefaultMvcResult(mockRequest, mockResponse)
    }

    @Unroll
    def "html(#cssQuery).text(#text)_要素がありテキストが一致する場合"() {
        expect:
        def htmlResultMatchers = HtmlResultMatchers.html(cssQuery)
        def resultMatcher = htmlResultMatchers.text(text)
        resultMatcher.match(this.mvcResult)

        where:
        cssQuery                || text
        "#title"                || "メニュー一覧"
        "ul.menu li.item:eq(0)" || "メニュー1"
    }

    def "html().text()_要素はあるがテキストが一致しない場合 AssertionError が throw される"() {
        when:
        def htmlResultMatchers = HtmlResultMatchers.html("#title")
        def resultMatcher = htmlResultMatchers.text("メニュー")
        resultMatcher.match(this.mvcResult)

        then:
        AssertionError e = thrown()
        e.getMessage() contains ""
    }

    def "html().text()_要素がない場合 AssertionError が throw される"() {
        when:
        def htmlResultMatchers = HtmlResultMatchers.html("#titlex")
        def resultMatcher = htmlResultMatchers.text("メニュー一覧")
        resultMatcher.match(this.mvcResult)

        then:
        AssertionError e = thrown()
        e.getMessage() contains "指定された cssQuery の Element は存在しません ( cssQuery = #titlex )"
    }

    @Unroll
    def "html(#cssQuery).count(#count)"() {
        expect:
        def htmlResultMatchers = HtmlResultMatchers.html(cssQuery)
        def resultMatcher = htmlResultMatchers.count(count)
        resultMatcher.match(this.mvcResult)

        where:
        cssQuery     || count
        "#title"     || 1
        "#titlex"    || 0
        "ul.menu li" || 2
    }

    def "html().exists()_要素がある場合 AssertionError は throw されない"() {
        when:
        def htmlResultMatchers = HtmlResultMatchers.html("#title")
        def resultMatcher = htmlResultMatchers.exists()
        resultMatcher.match(this.mvcResult)

        then:
        notThrown(AssertionError)
    }

    def "html().exists()_要素がない場合 AssertionError が throw される"() {
        when:
        def htmlResultMatchers = HtmlResultMatchers.html("#titlex")
        def resultMatcher = htmlResultMatchers.exists()
        resultMatcher.match(this.mvcResult)

        then:
        AssertionError e = thrown()
        e.getMessage() contains "cssQuery '#titlex' does not exist"
    }

    def "html().notExists()_要素がない場合 AssertionError は throw されない"() {
        when:
        def htmlResultMatchers = HtmlResultMatchers.html("#titlex")
        def resultMatcher = htmlResultMatchers.notExists()
        resultMatcher.match(this.mvcResult)

        then:
        notThrown(AssertionError)
    }

    def "html().notExists()_要素がある場合 AssertionError は throw される"() {
        when:
        def htmlResultMatchers = HtmlResultMatchers.html("#title")
        def resultMatcher = htmlResultMatchers.notExists()
        resultMatcher.match(this.mvcResult)

        then:
        AssertionError e = thrown()
        e.getMessage() contains "cssQuery '#title' exists"
    }

}

AdminLibraryControllerTest.java

        @Test
        public void 管理権限を持つユーザは検索対象図書館登録画面を表示できる_図書館未選択時() throws Exception {
            mvc.authTanakaTaro.perform(get("/admin/library"))
                    .andExpect(status().isOk())
                    .andExpect(content().contentType("text/html;charset=UTF-8"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(html("p.navbar-text.noselected-library").text("※図書館が選択されていません"));
        }

        @Test
        @TestData("web/admin/library/testdata/001")
        public void 管理権限を持つユーザは検索対象図書館登録画面を表示できる_図書館選択時() throws Exception {
            mvc.authTanakaTaro.perform(get("/admin/library"))
                    .andExpect(status().isOk())
                    .andExpect(content().contentType("text/html;charset=UTF-8"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(html("p.navbar-text.selected-library").text("選択中:図書館サンプル"));
        }
  • 管理権限を持つユーザは検索対象図書館登録画面を表示できる_図書館未選択時() テストメソッドの .andExpect(xpath("//p[@class='navbar-text noselected-library']").string("※図書館が選択されていません"));.andExpect(html("p.navbar-text.noselected-library").text("※図書館が選択されていません")); に変更します。
  • 管理権限を持つユーザは検索対象図書館登録画面を表示できる_図書館選択時() テストメソッドの .andExpect(xpath("//p[@class='navbar-text selected-library']").string("選択中:図書館サンプル"));.andExpect(html("p.navbar-text.selected-library").text("選択中:図書館サンプル")); に変更します。

履歴

2017/05/23
初版発行。