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系で変更された点を修正する ) の続きです。
- 今回の手順で確認できるのは以下の内容です。
- 以下の記事で Thymeleaf を 3 へバージョンアップしても問題ないか検証して、特に問題はなかったので今回 3 へバージョンアップします。
参照したサイト・書籍
AttoParser
http://www.attoparser.org/jsoup: Java HTML Parser
https://jsoup.org/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 の使い方を参考にしました。
Convert xPath to JSoup query
http://stackoverflow.com/questions/16335820/convert-xpath-to-jsoup-queryGroovyのヒアドキュメント
http://d.hatena.ne.jp/Kazuhira/20130715/1373878511
目次
- 変更の方針
- build.gradle を変更する
- SpELコンパイラを有効にします
- mainparts.html の head タグを th:fragment 化し、head-cssjs.html を削除する(その1)
- ここで一旦動作確認する
xpath()
の代わりに使用できる HTML 5 チェック用のhtml()
ResultMatcher を作成する- 失敗したテストを変更する
- 続きます。。。
手順
変更の方針
- 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 を変更する
build.gradle を リンク先のその1の内容 に変更します。
変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。
更新後、Project Tool Window の External Libraries を見て、ライブラリが更新されていることを確認します。
BOM に記述されたライブラリのバージョンを変更する方法として今回は Dependency management plugin が提供する bomProperty を使用しましたが、それ以外に build.gradle に ext で記述する方法や、gradle.properties を使用する方法があります。詳細は以下の Web ページに記述されています。
- 5.2 Overriding a version using Gradle
http://docs.spring.io/platform/docs/current/reference/html/getting-started-overriding-versions.html#getting-started-overriding-versions-gradle
SpELコンパイラを有効にします
mainparts.html の head タグを th:fragment 化し、head-cssjs.html を削除する(その1)
src/main/resources/templates/common/mainparts.html を リンク先の内容 に変更します。
各画面用 Thymeleaf テンプレートを1ファイルだけ変更します。src/main/resources/templates/admin/library/library.html を リンク先の内容 に変更します。
ここで一旦動作確認する
ここまでの変更で「検索対象図書館登録」画面は表示できるようになっているはずなので、確認してみます。
library_forsearch テーブルをクリアしてから bootRun で Tomcat を起動した後、http://localhost:8080/ にアクセスして tanaka.taro@sample.com / taro でログインすると「検索対象図書館登録」画面が表示されました。
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 タスクでいくつかのテストが失敗しました。
Project Tool Window の src/test から「Run ‘All Tests’ with Coverage」も実行してみると、失敗しているテストで org.xml.sax.SAXParseException; lineNumber: 63; columnNumber: 3; 要素タイプ"link"は、対応する終了タグ"</link>"で終了する必要があります。
というメッセージが出力されていることが分かります。
Thymeleaf 3 にバージョンアップしたので SAX は使われなくなったのでは?と思ってテストのコードを見てみると、xpath を使用してチェックしていました。これが SAX を使用しているようです。
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 を作成することにします。
build.gradle を リンク先のその2の内容 に変更します。
変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。
src/test/java/ksbysample/common/test/matcher/HtmlResultMatchers.java を新規作成し、リンク先の内容 を記述します。
テストを作成します。src/groovy/test/ksbysample/common/test/matcher/HtmlResultMatchersTest.groovy を新規作成し、リンク先の内容 を記述します。
作成したテストを実行し、全てのテストが成功することを確認します。
失敗したテストを変更する
src/test/java/ksbysample/webapp/lending/web/admin/library/AdminLibraryControllerTest.java を リンク先の内容 に変更します。
テストを実行し、成功することを確認します。
続きます。。。
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
初版発行。