Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その16 )( 検索/一覧画面 ( MyBatis-Spring版 ) 作成3 )
概要
Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その15 )( 登録画面作成4 ) の続きです。
今回の手順で確認できるのは以下の内容です。
- 検索/一覧画面 ( MyBatis-Spring版 ) のテストクラスを書きます。
今回は時間がかかりました。テストクラスも前回ある程度書いたしそんなに時間はかからないだろうと思っていたのですが。。。 理由を最後にまとめてみます。
ソフトウェア一覧
参考にしたサイト
近藤嘉雪のプログラミング工房日誌 - MySQLの文字コード関連の設定を調べる方法
http://blog.kondoyoshiyuki.com/2013/01/11/how2know-mysql-character-code/Fight the Future - DbUnitで読み込むファイルの値にnullを設定する
http://jyukutyo.hatenablog.com/entry/20080821/1219294435AssertFalse.java - Spring JIRA
https://jira.spring.io/secure/attachment/13763/AssertFalse.java- テストクラスでネストクラスを使用する場合に
@RunWith(SpringJUnit4ClassRunner.class)
をどこに付加すればよいのか分からなかったので、その時に参照しました。 - このソースでは
@RunWith(Enclosed.class)
が使用されていますが、付加する必要はありません ( 後述参照 )。
- テストクラスでネストクラスを使用する場合に
snakeyaml
https://code.google.com/p/snakeyaml/コンピュータクワガタ - Spring MVC 3.2のSpring MVC Testを触った
http://kuwalab.hatenablog.jp/entry/20130402/p1- 前回に引き続き参考にしました。
やさしいデスマーチ - GradleでEnclosedテストが2回実行されることの対策
http://d.hatena.ne.jp/shuji_w6e/20120808/1344386399- テストクラスをネストクラスで構造化したらテストが2回実行されるようになったので、対策方法を参考にしました。
- ネストクラスにしても
@RunWith(Enclosed.class)
を付ける必要はありませんでした。
Spring Framework Reference Documentation - 8. Spring Expression Language (SpEL)
http://docs.spring.io/spring/docs/current/spring-framework-reference/html/expressions.htmlpage.number
と記述してpage.getNumber()
の戻り値を取得する方法を調べた時に参照しました。- 今回新規に作成している CustomModelResultMatchers クラスに反映しています。
mysql querying a utf charset table for c returns ç
http://stackoverflow.com/questions/10231490/mysql-querying-a-utf-charset-table-for-c-returns-%C3%A7Mysqlテーブルの照合順序を変更する
http://joppot.info/2014/02/03/601
手順
検証前にテーブルの charset を確認したら latin1 でした。。。
world データベースの DEFAULT CHARSET と、country テーブルの DEFAULT CHARSET を調べたところ、データベースの DEFAULT CHARSET は utf8 なのに、なぜか country テーブルの DEFAULT CHARSET は latin1 でした。
mysql> show create database world; +----------+----------------------------------------------------------------+ | Database | Create Database | +----------+----------------------------------------------------------------+ | world | CREATE DATABASE `world` /*!40100 DEFAULT CHARACTER SET utf8 */ | +----------+----------------------------------------------------------------+ 1 row in set (0.00 sec) mysql> show create table country; | country | CREATE TABLE `country` ( `Code` char(3) NOT NULL DEFAULT '', `Name` char(52) NOT NULL DEFAULT '', `Continent` enum('Asia','Europe','North America','Africa','Oceania','Antarctica','South America') NOT NULL DEFAULT 'Asia', `Region` char(26) NOT NULL DEFAULT '', `SurfaceArea` float(10,2) NOT NULL DEFAULT '0.00', `IndepYear` smallint(6) DEFAULT NULL, `Population` int(11) NOT NULL DEFAULT '0', `LifeExpectancy` float(3,1) DEFAULT NULL, `GNP` float(10,2) DEFAULT NULL, `GNPOld` float(10,2) DEFAULT NULL, `LocalName` char(45) NOT NULL DEFAULT '', `GovernmentForm` char(45) NOT NULL DEFAULT '', `HeadOfState` char(60) DEFAULT NULL, `Capital` int(11) DEFAULT NULL, `Code2` char(2) NOT NULL DEFAULT '', PRIMARY KEY (`Code`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1 |
テーブルのカラムの設定も確認してみたら、やっぱり latin1 でした。今どき latin1 の設定を残しておく必要なんてどこにもないと思うのですが。。。
このままでは utf8 のデータを入れられないので、テーブルとカラムの CHARSET を utf8 に変更します。
mysql> alter table country convert to character set utf8; Query OK, 0 rows affected (0.01 sec) Records: 0 Duplicates: 0 Warnings: 0 mysql> show create table country; | country | CREATE TABLE `country` ( `Code` char(3) NOT NULL DEFAULT '', `Name` char(52) NOT NULL DEFAULT '', `Continent` enum('Asia','Europe','North America','Africa','Oceania','Antarctica','South America') NOT NULL DEFAULT 'Asia', `Region` char(26) NOT NULL DEFAULT '', `SurfaceArea` float(10,2) NOT NULL DEFAULT '0.00', `IndepYear` smallint(6) DEFAULT NULL, `Population` int(11) NOT NULL DEFAULT '0', `LifeExpectancy` float(3,1) DEFAULT NULL, `GNP` float(10,2) DEFAULT NULL, `GNPOld` float(10,2) DEFAULT NULL, `LocalName` char(45) NOT NULL DEFAULT '', `GovernmentForm` char(45) NOT NULL DEFAULT '', `HeadOfState` char(60) DEFAULT NULL, `Capital` int(11) DEFAULT NULL, `Code2` char(2) NOT NULL DEFAULT '', PRIMARY KEY (`Code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 | mysql> alter table city convert to character set utf8; Query OK, 0 rows affected (0.01 sec) Records: 0 Duplicates: 0 Warnings: 0 mysql> alter table countrylanguage convert to character set utf8; Query OK, 0 rows affected (0.01 sec) Records: 0 Duplicates: 0 Warnings: 0
カラムの CHARSET も utf8 に変更されました。
MySQL の文字コード関連の設定も cp932 で設定されているものがありますが、DbUnit で接続して utf8 のデータを投入するので utf8 に変更します。
mysql> show variables like 'char%'; +--------------------------+---------------------------------------------------- -----+ | Variable_name | Value | +--------------------------+---------------------------------------------------- -----+ | character_set_client | cp932 | | character_set_connection | cp932 | | character_set_database | utf8 | | character_set_filesystem | binary | | character_set_results | cp932 | | character_set_server | utf8 | | character_set_system | utf8 | | character_sets_dir | C:\Program Files\MySQL\MySQL Server 5.6\share\chars ets\ | +--------------------------+---------------------------------------------------- -----+ 8 rows in set (0.00 sec) mysql> set character_set_client = utf8; Query OK, 0 rows affected (0.00 sec) mysql> set character_set_connection = utf8; Query OK, 0 rows affected (0.00 sec) mysql> set character_set_results = utf8; Query OK, 0 rows affected (0.00 sec) mysql> show variables like 'char%'; +--------------------------+---------------------------------------------------------+ | Variable_name | Value | +--------------------------+---------------------------------------------------------+ | character_set_client | utf8 | | character_set_connection | utf8 | | character_set_database | utf8 | | character_set_filesystem | binary | | character_set_results | utf8 | | character_set_server | utf8 | | character_set_system | utf8 | | character_sets_dir | C:\Program Files\MySQL\MySQL Server 5.6\share\charsets\ | +--------------------------+---------------------------------------------------------+ 8 rows in set (0.00 sec)
テストクラス作成前の準備
IntelliJ IDEA 上で 1.0.x-testcountrylist ブランチを作成します。
前回、登録画面 ( 入力→確認→完了 ) のテストクラスを書きましたが、JUnit を全然理解していなくてかなり苦労したので、以下の書籍を買ってきて読みました。1年前くらいに発行された書籍ですが、JUnit4 のことも十分必要なことが書かれており、分かりやすくていろいろためになりました。
JUnit実践入門 ~体系的に学ぶユニットテストの技法 (WEB+DB PRESS plus)
- 作者: 渡辺修司
- 出版社/メーカー: 技術評論社
- 発売日: 2012/11/21
- メディア: 単行本(ソフトカバー)
- 購入: 14人 クリック: 273回
- この商品を含むブログ (68件) を見る
今回は以下の点に注意してテストクラスを作成してみます。
- 1テスト1メソッドにします。
- テストメソッド名を日本語にします ( 「JUnit実践入門」にお薦めと書かれていたので今回はそれに従ってみます ) 。
- テストクラスの中に
public static class テストグループ名 { ... }
の形式でネストクラスを定義することで、テストケースをグループ化・構造化します。 - DbUnit を利用した DB の初期化処理は時間がかかるので、DBを使用するテストは特定のネストしたテストクラスのみにし、それ以外のネストしたテストクラスでは DbUnit を利用した初期化処理が行われないようにします。
- Form クラスの初期値を前回はクラスの中に直接書きましたが、YAML ファイルに定義して読み込んでセットする方法があるので、それを試してみます。
Form クラスのテストデータを YAML ファイルに定義して読み込むようにします。SnakeYAML というライブラリを使用するので、build.gradle を リンク先の内容 に変更します。
変更後、Gradle tasks View の「Refresh Gradle projects」アイコンをクリックして、変更した build.gradle の内容を反映しようとすると IntelliJ IDEA の画面右上に以下の画像のメッセージが表示されました。
メッセージの中の「Open Repositories List」のリンクをクリックします。
「Settings」ダイアログが表示されます。画面右側の「Indexed Maven Repositories」の中の
http://oss.http://oss.sonatype.org/content/groups/public/
の行を選択後、「Update」ボタンをクリックします。- アップデートは結構時間がかかります。
「Updated」欄に日付が表示されたら「OK」ボタンをクリックします。
再度 Gradle tasks View の「Refresh Gradle projects」アイコンをクリックします。今度は何のメッセージも表示されませんでした。External Libraries にも snakeyaml の 1.16-SNAPSHOT が表示されています。
src/test/resources の下に ksbysample/webapp/basic/web ディレクトリを作成します。作成後、web ディレクトリの下に countryListForm_empty.yaml を新規作成し、リンク先の内容 に変更します。ディレクトリ構成は以下の画像のようになります。
同じディレクトリの下に countryListForm_code.yaml, countryListForm_name.yaml, countryListForm_continent.yaml, countryListForm_localName.yaml のファイルを新規作成し、リンク先の内容 に変更します。
MockMvc の andExpect メソッド内で「Model にセットされている値で "page.number" が 5 であるかチェックする」のように Thymeleaf のテンプレートファイルに記述している形式で値をチェックしたかったのですが、標準ではそのようなクラス・メソッドがないようでしたので、チェック用のクラスを作成します。src/test/java/ksbysample/webapp/basic/test の下に CustomModelResultMatchers.java を新規作成します。作成後、リンク先の内容 に変更します。
"page.number" と記述したら Model にセットされている page インスタンスの getNumber() の値を取得することがやりたかったのですが、最初は全く方法が分からず調べるのに時間がかかりました。調べた手順をメモしておきます。
Thymeleaf のテンプレートファイルでは page.number と記述すると page.getNumber() の値を取得することが出来ているので、まずは Thymeleaf のソースファイル を参照してみましたが、どこで実現しているのか全く分かりませんでした。
Thymeleaf のテンプレートファイルでフィールドが存在しなければ IntelliJ IDEA の画面上に Exception とソースファイルの行番号のログが表示されるだろうと思い、pagenation.html 内の
page.number
と書いているところをpage.numberx
に変更して実行すると以下の画像のログが出力されました。Thymeleaf で処理しているものと思っていたのですが、Spring Framework の SpelExpression というクラスを呼び出しています。Google で "SpelExpression" で検索すると 8. Spring Expression Language (SpEL) のページがヒットしました。その中の「8.3 Expression Evaluation using Spring’s Expression Interface」のソースを見て試してみたところ、インスタンス名の "page" と フィールド名の "number" を別途指定すれば値を取得することに成功しました。
"page.number" の形式で出来るようにしたいので、正規表現でインスタンス名とフィールド名を分割する処理を追加しました。
country テーブルのテストデータの準備
テストの時に country テーブルのデータが常に同じになるようテストデータを準備します。
mysql コマンドで以下のコマンドを入力し、country テーブルのデータを country.csv へ出力します。
mysql> select * from country into outfile "c:/tmp/country.csv" fields terminated by ',' optionally enclosed by '"'; Query OK, 5 rows affected (0.00 sec) mysql>
出力した country.csv をエディタで開き、先頭行にカラム名の行を追加し、\N → [null] へ置換した後 ( NULL が \N と出力されますがこのままでは DbUnit で取り込めないので置換します )、保存します。保存した country.csv を src/test/resources/testdata の下にコピー&ペーストで配置します。リンク先の内容 のような中身のファイルになります。
src/test/resources/testdata の下の table-ordering.txt を リンク先の内容 に変更します。
置換した [null] の文字列の値を null で取り込めるようするために、src/test/java/ksbysample/webapp/basic/test の下の TestDataResource.java を リンク先のその1の内容 に変更します。
「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行し、テストが全て成功することを確認します。
テストは成功したのですが、各テーブルのデータを確認したところ country テーブルのデータが5件しか戻っておらず、user, user_role テーブルのデータは全く元に戻っていませんでした。原因を調査します。
エラーが出ていないのにデータが戻っていないので TestDataResource の after メソッドを確認したところ、Exception を catch して何もしていませんでした。エラーが発生しているはずなのでエラー内容が出力されるようにします。src/test/java/ksbysample/webapp/basic/test の下の TestDataResource.java を リンク先のその2の内容 に変更します。
「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行し、country テーブルのデータを元に戻します。
src/test/java/ksbysample/webapp/basic/test の下の TestDataResource.java を リンク先のその3の内容 に変更します。
テストを実行すると Too much output to process というメッセージが出力されていましたので、src/main/resources の下の logback-develop.xml を リンク先のその1の内容 に変更します。
「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行したところ、ログに
java.lang.IllegalArgumentException: table.column=country.HeadOfState value is empty but must contain a value
が出力されていました。MySQL の country テーブルの定義を確認したところ country.HeadOfState は Nullable = YES の設定になっていますので空のデータでも問題なさそうですが、DbUnit では空のデータを入れられないということでしょうか?
"value is empty but must contain a value" で Google で検索したところ、以下のページがヒットしました。[NULL] と書いて ReplacementDataSet で対応するよう書かれていて一旦クローズされていますが、その後で allowEmptyFields という機能が追加されて 2.5.1 で修正されたとも書かれいます。
#363 Regression: cannot insert an empty string
http://sourceforge.net/p/dbunit/bugs/363/2.5.1 がアップロードされているか jCenter の org.dbunit:dbunit のページ をみたところ、まだ 2.5.0 までしかアップロードされていませんでした。
テストデータを入れる時に ReplacementDataSet クラスを使用して "[null]" という文字列を定義しておけば null がセットされるようにしたので、バックアップを取得する時に null は "[null]" という文字列で出力して、リストア時に "[null]" という文字列ならば null をセットすることが出来ないか試してみます。src/test/java/ksbysample/webapp/basic/test の下の TestDataResource.java を リンク先のその4の内容 に変更します。以下の点も修正しています。
- リストア処理でエラーが発生するとバックアップファイルを削除する処理が呼び出されないことに気づきましたので、修正しました。
- データが元に戻らない原因を調査している時にバックアップファイルが削除されていないことにも気づきましたので、修正しました。
リンク先の SQL を実行し、user, user_role テーブルのデータを元に戻します。
「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行します。今度は user, user_role, country テーブル全てでテスト前のデータに戻りました。
src/main/resources の下の logback-develop.xml を リンク先のその2の内容 に変更します ( 元に戻します )。
検索/一覧画面 ( MyBatis-Spring版 ) のテストクラスの作成
src/test/java/ksbysample/webapp/basic/web の下の CountryListControllerTest.java を リンク先のその1の内容 に変更します。
「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行し、@Ignore アノテーションを付加していないテストが全て成功することを確認します。
commit、GitHub へ Push、1.0.x-testcountrylist -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-testcountrylist ブランチを削除
commit の前に build タスクを実行し、BUILD SUCCESSFUL が表示されることを確認しようとしましたが、以下のログが出力されて BUILD FAILED になりました。原因を調査します。
MySQL Workbench で "SELECT * FROM world.country where LocalName like '%oc%';" を実行すると5件ヒットしますが、2件は "oc" ではなく "oç" でした。
bootRun タスクを実行して Tomcat を起動した後、検索/一覧画面から localname に "oc" を入力して「検索」ボタンをクリックします。こちらも5件ヒットしますが、2件は "oc" ではなく "oç" でした。Tomcat を停止します。
test 時の取得件数を確認します。src/main/resources の下の logback-develop.xml を リンク先のその3の内容 に変更した後、test タスクを実行します。test 時は3件しか取得しておらず、"oç" ではヒットしていませんでした。
「Run 'Tests in 'ksbysample...' with Coverage」や bootRun タスクで実行した時は "oc" で検索した時に "oç" のデータもヒットしますが、test タスクで実行した時は "oç" のデータはヒットしないようです。
調べた結果、以下のことが分かりました。
- Collation は utf8_bin にすれば完全一致した時だけヒットするようになります。
- Collation は create database の時に default 値を設定することが可能です。既に別の Collation で作成されたテーブルのカラムの Collation を変更する時には、個々のカラム毎に変更する必要があります。
IntelliJ IDEA で test タスクの時だけ検索方法が変わる理由は分かりませんでした。個々のカラム毎に設定を変更する必要があるらしいので、今回は LocalName だけ以下の SQL を実行して設定を変更します。
ALTER TABLE country modify column LocalName char(45) character set 'utf8' COLLATE 'utf8_bin' not null;
LocalName の Collation が utf8_bin に変更されました。
再度 build タスクを実行します。ただし今度は LocalName like '%oc%' では2件しかヒットしなくなりますので、最初に src/test/java/ksbysample/webapp/basic/web の下の CountryListControllerTest.java を リンク先のその2の内容 に変更します。変更後、build タスクを実行します。今度は BUILD SUCCESSFUL が表示されました。
「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行し、@Ignore アノテーションを付加していないテストが全て成功することも確認します。
commit、GitHub へ Push、1.0.x-testcountrylist -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-testcountrylist ブランチを削除、をします。
※commit 時に Code Analysis のダイアログが表示されますが、ネストクラスにしているテストクラスでテストメソッドがない親クラスが使用されていないという Warning なので、無視して「Commit」ボタンをクリックします。
考察
product, develop モード以外に unittest のモードを作成して、unittest モードの時は Spring MVC の DEBUG 出力や log4jdbc-log4j2 のログ出力を無効にした方がよいと思いました ( それ以外は develop と同じ設定にします )。ログが出力されなくなるとユニットテストのスピードが顕著に上がります。
MockMvc は便利なのですが、andExpect メソッドで Model や FieldError のデータをチェックする ResultMatcheres クラスが今ひとつで、標準のものだけでは詳細なテストが出来ません。前回は「標準で用意されていないことからそこまでやる必要はないのかもしれません。」と書きましたが、便利な ResultMatcheres クラスを揃えた方がテストがやりやすいし、テストに対する安心感も増すと思います。自分で作成したり、外部のライブラリで何かないか探してみたりしたいと思います。
今回時間がかかった理由とは?
MySQL の文字コード関連の設定が全然分からなくて苦労しました。Example として用意されている world データベースは InnoDB&utf8 で作り直して欲しいです ( 個人的には Collation もデフォルトは utf8_bin にして欲しいです )。
DbUnit が空のデータを入れられない、というのが分からなくて苦労しました。特にバックアップ/リストアの場合に、DbUnit でファイルに出力したデータを DbUnit でそのままでは戻せない、という点に気づくのに時間がかかりました。
テストクラスは先にざっと書いて動くものが出来たのですが、はっきり言って冗長でもっと簡潔に書きたいと思い CustomModelResultMatchers クラスを作っていて時間がかかりました。でも Spring Expression Language (SpEL) は他にも応用できる気がするので、時間がかかっても調べて良かったと思っています。
次回は。。。
検索画面 ( Spring Data JPA 版 ) を作成する前に、以下の対応を進めます。一度でやるとまた時間がかかるかもしれないので、小出しで進めてみるつもりです。
- 登録画面 ( 入力→確認→完了 ) のテストクラスをネストクラスで構造化して、DB のバックアップ/リストアを行うテストが最低限なものになるようにします。
- unittest モードを作成してユニットテストのスピードアップを図ります。
- 以前トランザクションの検証をした時に見つけた、設定ファイルによるトランザクションの設定を反映します。
ソースコード
country.csv
"Code","Name","Continent","Region","SurfaceArea","IndepYear","Population","LifeExpectancy","GNP","GNPOld","LocalName","GovernmentForm","HeadOfState","Capital","Code2" "ABW","Aruba","North America","Caribbean",193.00,[null],103000,78.4,828.00,793.00,"Aruba","Nonmetropolitan Territory of The Netherlands","Beatrix",129,"AW" "AFG","Afghanistan","Asia","Southern and Central Asia",652090.00,1919,22720000,45.9,5976.00,[null],"Afganistan/Afqanestan","Islamic Emirate","Mohammad Omar",1,"AF" "AGO","Angola","Africa","Central Africa",1246700.00,1975,12878000,38.3,6648.00,7984.00,"Angola","Republic","José Eduardo dos Santos",56,"AO" "AIA","Anguilla","North America","Caribbean",96.00,[null],8000,76.1,63.20,[null],"Anguilla","Dependent Territory of the UK","Elisabeth II",62,"AI" "ALB","Albania","Europe","Southern Europe",28748.00,1912,3401200,71.6,3205.00,2500.00,"Shqipëria","Republic","Rexhep Mejdani",34,"AL" (.....以下、省略.....)
table-ordering.txt
user user_role country
- 最後に country を追加します。
build.gradle
buildscript { repositories { jcenter() } dependencies { classpath("org.springframework.boot:spring-boot-gradle-plugin:1.2.0.RELEASE") classpath("org.springframework:springloaded:1.2.1.RELEASE") } } apply plugin: 'java' apply plugin: 'spring-boot' apply plugin: 'idea' jar { baseName = 'ksbysample-webapp-basic' version = '0.0.1-SNAPSHOT' } idea { module { inheritOutputDirs = false outputDir = file("$buildDir/classes/main/") } } repositories { jcenter() maven { url "http://oss.sonatype.org/content/groups/public/" } } dependencies { def springBootVersion = '1.2.2.RELEASE' compile("org.springframework.boot:spring-boot-starter-web:${springBootVersion}") compile("org.springframework.boot:spring-boot-starter-thymeleaf:${springBootVersion}") compile("org.springframework.boot:spring-boot-starter-data-jpa:${springBootVersion}") compile("org.springframework.boot:spring-boot-starter-security:${springBootVersion}") compile("mysql:mysql-connector-java:5.1.34") compile("org.mybatis:mybatis:3.2.8") compile("org.mybatis:mybatis-spring:1.2.2") compile("org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16") compile("org.codehaus.janino:janino:2.7.5") compile("org.apache.commons:commons-lang3:3.3.2") compile("org.projectlombok:lombok:1.14.8") testCompile("org.springframework.boot:spring-boot-starter-test:${springBootVersion}") testCompile("org.springframework.security:spring-security-test:4.0.0.RC2") testCompile("org.dbunit:dbunit:2.5.0") testCompile("org.yaml:snakeyaml:1.16-SNAPSHOT") } bootRun { jvmArgs = ['-Dspring.profiles.active=develop'] } test { jvmArgs = ['-Dspring.profiles.active=develop'] }
- repositories の中に
maven { url "http://oss.sonatype.org/content/groups/public/" }
を追加します。 - dependencies の中に
testCompile("org.yaml:snakeyaml:1.16-SNAPSHOT")
を追加します。
countryListForm_empty.yaml
!!ksbysample.webapp.basic.web.CountryListForm code: name: continent: localName: page: 0 size: 5
countryListForm_code.yaml, countryListForm_name.yaml, countryListForm_continent.yaml, countryListForm_localName.yaml
■countryListForm_code.yaml
!!ksbysample.webapp.basic.web.CountryListForm code: JPN name: continent: localName: page: 0 size: 5
■countryListForm_name.yaml
!!ksbysample.webapp.basic.web.CountryListForm code: name: go continent: localName: page: 1 size: 5
■countryListForm_continent.yaml
!!ksbysample.webapp.basic.web.CountryListForm code: name: continent: Oceania localName: page: 5 size: 5
■countryListForm_localName.yaml
!!ksbysample.webapp.basic.web.CountryListForm code: name: continent: localName: oc page: 0 size: 5
CustomModelResultMatchers.java
package ksbysample.webapp.basic.test; import org.hamcrest.Matcher; import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.result.ModelResultMatchers; import org.springframework.ui.ModelMap; import java.util.regex.Pattern; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; import static org.springframework.test.util.MatcherAssertionErrors.assertThat; public class CustomModelResultMatchers extends ModelResultMatchers { public static CustomModelResultMatchers modelEx() { return new CustomModelResultMatchers(); } @SuppressWarnings("unchecked") public <T> ResultMatcher property(final String nameAndProperty, final Matcher<T> matcher) { return mvcResult -> { // <インスタンス名>.<プロパティ名> ( 例: page.number ) の形式の文字列を // インスタンス名とプロパティ名に分割する Pattern p = Pattern.compile("^(\\S+?)\\.(\\S+)$"); java.util.regex.Matcher m = p.matcher(nameAndProperty); assertThat(m.find(), is(true)); String name = m.group(1); String property = m.group(2); // プロパティの値を取得してチェックする ModelMap modelMap = mvcResult.getModelAndView().getModelMap(); Object object = modelMap.get(name); assertThat(object, is(notNullValue())); EvaluationContext context = new StandardEvaluationContext(object); ExpressionParser parser = new SpelExpressionParser(); Expression exp = parser.parseExpression(property); Object value = exp.getValue(context); assertThat((T) value, matcher); }; } }
- インターフェースは org.springframework.test.web.servlet.result.ModelResultMatchers の
public <T> ResultMatcher attribute(final String name, final Matcher<T> matcher)
メソッドを参考にしました。
TestDataResource.java
■その1
@Override protected void before() throws Throwable { IDatabaseConnection conn = null; try { conn = new DatabaseConnection(dataSource.getConnection()); // バックアップを取得する QueryDataSet partialDataSet = new QueryDataSet(conn); partialDataSet.addTable("user"); partialDataSet.addTable("user_role"); partialDataSet.addTable("country"); backupFile = File.createTempFile("world_backup", "xml"); FlatXmlDataSet.write(partialDataSet, new FileOutputStream(backupFile)); // テストデータに入れ替える IDataSet dataset = new CsvDataSet(new File("src/test/resources/testdata")); ReplacementDataSet replacementDataset = new ReplacementDataSet(dataset); replacementDataset.addReplacementObject("[null]", null); DatabaseOperation.CLEAN_INSERT.execute(conn, replacementDataset); } finally { if (conn != null) conn.close(); } }
ReplacementDataSet replacementDataset = new ReplacementDataSet(dataset);
とreplacementDataset.addReplacementObject("[null]", null);
を追加します。DatabaseOperation.CLEAN_INSERT.execute
の第2引数を replacementDataset に変更します。
■その2
package ksbysample.webapp.basic.test; import org.dbunit.database.DatabaseConnection; import org.dbunit.database.IDatabaseConnection; import org.dbunit.database.QueryDataSet; import org.dbunit.dataset.IDataSet; import org.dbunit.dataset.ReplacementDataSet; import org.dbunit.dataset.csv.CsvDataSet; import org.dbunit.dataset.xml.FlatXmlDataSet; import org.dbunit.dataset.xml.FlatXmlDataSetBuilder; import org.dbunit.operation.DatabaseOperation; import org.junit.rules.ExternalResource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.sql.DataSource; import java.io.File; import java.io.FileOutputStream; @Component public class TestDataResource extends ExternalResource { @Autowired private DataSource dataSource; private File backupFile; @Override protected void before() throws Exception { IDatabaseConnection conn = null; try { conn = new DatabaseConnection(dataSource.getConnection()); // バックアップを取得する QueryDataSet partialDataSet = new QueryDataSet(conn); partialDataSet.addTable("user"); partialDataSet.addTable("user_role"); partialDataSet.addTable("country"); backupFile = File.createTempFile("world_backup", "xml"); FlatXmlDataSet.write(partialDataSet, new FileOutputStream(backupFile)); // テストデータに入れ替える IDataSet dataset = new CsvDataSet(new File("src/test/resources/testdata")); ReplacementDataSet replacementDataset = new ReplacementDataSet(dataset); replacementDataset.addReplacementObject("[null]", null); DatabaseOperation.CLEAN_INSERT.execute(conn, replacementDataset); } finally { if (conn != null) conn.close(); } } @Override protected void after() { try { IDatabaseConnection conn = null; try { conn = new DatabaseConnection(dataSource.getConnection()); // バックアップからリストアする if (backupFile != null) { // IDataSet dataSet = new FlatXmlDataSetBuilder().build(backupFile); // DatabaseOperation.CLEAN_INSERT.execute(conn, dataSet); backupFile.delete(); backupFile = null; } } finally { if (conn != null) conn.close(); } } catch (Exception e) { e.printStackTrace(); } } }
- after メソッド内の
catch (Exception ignored) {}
→catch (Exception e) { e.printStackTrace(); }
に変更してエラー内容が出力されるようにします。 - country テーブルのデータを戻したいので、バックアップからリストアしている2行をコメントアウトして投入した country.csv のテストデータが入ったままになるようにします。
- before メソッドの throws 節を
Throwable
→Exception
に変更します ( この修正は単に個人的な好みです )。
■その3
@Override protected void after() { try { IDatabaseConnection conn = null; try { conn = new DatabaseConnection(dataSource.getConnection()); // バックアップからリストアする if (backupFile != null) { IDataSet dataSet = new FlatXmlDataSetBuilder().build(backupFile); DatabaseOperation.CLEAN_INSERT.execute(conn, dataSet); backupFile.delete(); backupFile = null; } } finally { if (conn != null) conn.close(); } } catch (Exception e) { e.printStackTrace(); } }
■その4
package ksbysample.webapp.basic.test; import org.dbunit.database.DatabaseConnection; import org.dbunit.database.IDatabaseConnection; import org.dbunit.database.QueryDataSet; import org.dbunit.dataset.IDataSet; import org.dbunit.dataset.ReplacementDataSet; import org.dbunit.dataset.csv.CsvDataSet; import org.dbunit.dataset.xml.FlatXmlDataSet; import org.dbunit.dataset.xml.FlatXmlDataSetBuilder; import org.dbunit.operation.DatabaseOperation; import org.junit.rules.ExternalResource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.sql.DataSource; import java.io.File; import java.io.FileOutputStream; import java.nio.file.Files; @Component public class TestDataResource extends ExternalResource { @Autowired private DataSource dataSource; private File backupFile; @Override protected void before() throws Exception { IDatabaseConnection conn = null; try { conn = new DatabaseConnection(dataSource.getConnection()); // バックアップを取得する QueryDataSet partialDataSet = new QueryDataSet(conn); partialDataSet.addTable("user"); partialDataSet.addTable("user_role"); partialDataSet.addTable("country"); ReplacementDataSet replacementDatasetBackup = new ReplacementDataSet(partialDataSet); replacementDatasetBackup.addReplacementObject("", "[null]"); backupFile = File.createTempFile("world_backup", "xml"); try (FileOutputStream fos = new FileOutputStream(backupFile)) { FlatXmlDataSet.write(replacementDatasetBackup, fos); } // テストデータに入れ替える IDataSet dataSet = new CsvDataSet(new File("src/test/resources/testdata")); ReplacementDataSet replacementDataset = new ReplacementDataSet(dataSet); replacementDataset.addReplacementObject("[null]", null); DatabaseOperation.CLEAN_INSERT.execute(conn, replacementDataset); } finally { if (conn != null) conn.close(); } } @Override protected void after() { try { IDatabaseConnection conn = null; try { conn = new DatabaseConnection(dataSource.getConnection()); // バックアップからリストアする if (backupFile != null) { IDataSet dataSet = new FlatXmlDataSetBuilder().build(backupFile); ReplacementDataSet replacementDatasetRestore = new ReplacementDataSet(dataSet); replacementDatasetRestore.addReplacementObject("[null]", null); DatabaseOperation.CLEAN_INSERT.execute(conn, replacementDatasetRestore); } } finally { if (backupFile != null) { Files.delete(backupFile.toPath()); backupFile = null; } try { if (conn != null) conn.close(); } catch (Exception ignored) {} } } catch (Exception e) { e.printStackTrace(); } } }
before メソッド内で
ReplacementDataSet replacementDatasetBackup = new ReplacementDataSet(partialDataSet);
,replacementDatasetBackup.addReplacementObject("", "[null]");
の2行を追加し、その後の FlatXmlDataSet.write の第1引数を replacementDatasetBackup に変更します。バックアップファイルがオープンされたままで、after メソッドで削除できていないことに気づきました。原因は FlatXmlDataSet.write の第2引数に
new FileOutputStream(backupFile)
と書いていて、クローズしていないためでした。FlatXmlDataSet.write の第2引数をnew FileOutputStream(backupFile)
→fos
へ変更し、fos
は try-with-resources 文で生成して自動的にクローズされるようにしました。- before メソッド内の記述が after メソッド内と記述が統一されていなかったことに気づいたので、
IDataSet dataset
→IDataSet dataSet
に変更します。 - after メソッド内で
ReplacementDataSet replacementDatasetRestore = new ReplacementDataSet(dataSet);
,replacementDatasetRestore.addReplacementObject("[null]", null);
の2行を追加し、その後の DatabaseOperation.CLEAN_INSERT.execute の第2引数を replacementDatasetRestore に変更します。 - エラー発生時にバックアップファイルが削除されていないことに気づいたので、バックアップファイルを削除する処理を finally 内に移動します。また削除する方法を
backupFile.delete()
→Files.delete(backupFile.toPath())
へ変更します。削除エラーの時に後者の書き方だと例外が throw されて、エラーの原因が明確に分かるためです。 if (conn != null) conn.close();
をtry { ... } catch (Exception ignored) {}
で囲み、エラー発生時に他の処理がスキップされないようにします。- finally, catch が { と同じ行にあったり次の行にあったりと統一されていなかったので、同じ行に書くよう修正します。
logback-develop.xml
■その1
<?xml version="1.0" encoding="UTF-8"?> <included> <!-- Spring MVC --> <logger name="org.springframework.web" level="ERROR"/> <!-- log4jdbc-log4j2 --> <logger name="jdbc" level="ERROR"/> <!--<logger name="jdbc.sqlonly" level="DEBUG"/>--> <!--<logger name="jdbc.sqltiming" level="INFO"/>--> <!--<logger name="jdbc.audit" level="INFO"/>--> <!--<logger name="jdbc.resultset" level="ERROR"/>--> <!--<logger name="jdbc.resultsettable" level="ERROR"/>--> <!--<logger name="jdbc.connection" level="DEBUG"/>--> </included>
- org.springframework.web の leve を
DEBUG
→ERROR
へ変更します。 - log4jdbc-log4j2 のログ設定に
<logger name="jdbc" level="ERROR"/>
を追加し、その下の個別の設定を全てコメントアウトします。
■その2
<?xml version="1.0" encoding="UTF-8"?> <included> <!-- Spring MVC --> <logger name="org.springframework.web" level="DEBUG"/> <!-- log4jdbc-log4j2 --> <logger name="jdbc.sqlonly" level="DEBUG"/> <logger name="jdbc.sqltiming" level="INFO"/> <logger name="jdbc.audit" level="INFO"/> <logger name="jdbc.resultset" level="ERROR"/> <logger name="jdbc.resultsettable" level="ERROR"/> <logger name="jdbc.connection" level="DEBUG"/> </included>
- 全ての設定を元に戻します。
■その3
<?xml version="1.0" encoding="UTF-8"?> <included> <!-- Spring MVC --> <logger name="org.springframework.web" level="DEBUG"/> <!-- log4jdbc-log4j2 --> <logger name="jdbc.sqlonly" level="DEBUG"/> <logger name="jdbc.sqltiming" level="INFO"/> <logger name="jdbc.audit" level="INFO"/> <!--<logger name="jdbc.resultset" level="ERROR"/>--> <!--<logger name="jdbc.resultsettable" level="ERROR"/>--> <logger name="jdbc.resultset" level="DEBUG"/> <logger name="jdbc.resultsettable" level="DEBUG"/> <logger name="jdbc.connection" level="DEBUG"/> </included>
drop_and_create_user.sql
drop table user; create table user ( id varchar(32) not null , password varchar(128) not null , enabled tinyint not null default 1 , constraint pk_user primary key (id) ); drop table user_role; create table user_role ( id varchar(32) not null , role varchar(32) not null , constraint pk_user_role primary key (id) ); insert into user values('test', '$2a$10$5hPduqwwdl5ZhAeOEKP18eMJj2DdVv8AWaYeU0VlRU2FcXcA.qeSC', 1); insert into user_role values('test', 'USER'); commit;
CountryListControllerTest.java
■その1
package ksbysample.webapp.basic.web; import ksbysample.webapp.basic.Application; import ksbysample.webapp.basic.test.SecurityMockMvcResource; import ksbysample.webapp.basic.test.TestDataResource; import ksbysample.webapp.basic.test.TestHelper; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.SpringApplicationConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.yaml.snakeyaml.Yaml; import static ksbysample.webapp.basic.test.CustomModelResultMatchers.modelEx; import static org.hamcrest.Matchers.is; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; public class CountryListControllerTest { @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) @WebAppConfiguration public static class 非認証時の場合 { @Rule @Autowired public SecurityMockMvcResource secmvc; @Test public void ログイン画面にリダイレクトされる() throws Exception { secmvc.nonauth.perform(get("/countryList")) .andExpect(status().isFound()) .andExpect(redirectedUrl("http://localhost/")); } } public static class 認証時の場合 { @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) @WebAppConfiguration public static class 画面初期表示の場合 { @Rule @Autowired public SecurityMockMvcResource secmvc; @Test public void 検索一覧画面が表示される() throws Exception { secmvc.auth.perform(get("/countryList")) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("countryList")) .andExpect(xpath("/html/head/title").string("検索/一覧画面")); } } @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) @WebAppConfiguration public static class 検索条件は何も入力しないで検索する場合 { @Rule @Autowired public TestDataResource testDataResource; @Rule @Autowired public SecurityMockMvcResource secmvc; // テストデータ private CountryListForm countryListFormEmpty = (CountryListForm) new Yaml().load(getClass().getResourceAsStream("countryListForm_empty.yaml")); @Test public void 検索ボタンをクリックすると1ページ目が表示される() throws Exception { secmvc.auth.perform(TestHelper.postForm("/countryList", this.countryListFormEmpty) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("countryList")) .andExpect(xpath("/html/head/title").string("検索/一覧画面")) .andExpect(modelEx().property("page.number", is(0))) .andExpect(modelEx().property("page.size", is(5))) .andExpect(modelEx().property("page.totalPages", is(48))) .andExpect(modelEx().property("page.numberOfElements", is(5))) .andExpect(modelEx().property("ph.page1PageValue", is(0))) .andExpect(modelEx().property("ph.hiddenPrev", is(true))) .andExpect(modelEx().property("ph.hiddenNext", is(false))); } @Test public void 検索条件はそのままで2ページ目へ() throws Exception { this.countryListFormEmpty.setPage(1); secmvc.auth.perform(TestHelper.postForm("/countryList", this.countryListFormEmpty) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("countryList")) .andExpect(xpath("/html/head/title").string("検索/一覧画面")) .andExpect(modelEx().property("page.number", is(1))) .andExpect(modelEx().property("page.size", is(5))) .andExpect(modelEx().property("page.totalPages", is(48))) .andExpect(modelEx().property("page.numberOfElements", is(5))) .andExpect(modelEx().property("ph.page1PageValue", is(0))) .andExpect(modelEx().property("ph.hiddenPrev", is(false))) .andExpect(modelEx().property("ph.hiddenNext", is(false))); } @Test public void 検索条件はそのままで最終ページへ() throws Exception { this.countryListFormEmpty.setPage(47); secmvc.auth.perform(TestHelper.postForm("/countryList", this.countryListFormEmpty) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("countryList")) .andExpect(xpath("/html/head/title").string("検索/一覧画面")) .andExpect(modelEx().property("page.number", is(47))) .andExpect(modelEx().property("page.size", is(5))) .andExpect(modelEx().property("page.totalPages", is(48))) .andExpect(modelEx().property("page.numberOfElements", is(4))) .andExpect(modelEx().property("ph.page1PageValue", is(43))) .andExpect(modelEx().property("ph.hiddenPrev", is(false))) .andExpect(modelEx().property("ph.hiddenNext", is(true))); } } @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) @WebAppConfiguration public static class 検索条件を入力して検索する場合 { @Rule @Autowired public TestDataResource testDataResource; @Rule @Autowired public SecurityMockMvcResource secmvc; // テストデータ private CountryListForm countryListFormCode = (CountryListForm) new Yaml().load(getClass().getResourceAsStream("countryListForm_code.yaml")); private CountryListForm countryListFormName = (CountryListForm) new Yaml().load(getClass().getResourceAsStream("countryListForm_name.yaml")); private CountryListForm countryListFormContinent = (CountryListForm) new Yaml().load(getClass().getResourceAsStream("countryListForm_continent.yaml")); private CountryListForm countryListFormLocalName = (CountryListForm) new Yaml().load(getClass().getResourceAsStream("countryListForm_localName.yaml")); @Test public void Codeのみ入力して検索ボタンをクリックすると1件ヒットする() throws Exception { secmvc.auth.perform(TestHelper.postForm("/countryList", this.countryListFormCode) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("countryList")) .andExpect(xpath("/html/head/title").string("検索/一覧画面")) .andExpect(modelEx().property("page.number", is(0))) .andExpect(modelEx().property("page.size", is(5))) .andExpect(modelEx().property("page.totalPages", is(1))) .andExpect(modelEx().property("page.numberOfElements", is(1))) .andExpect(modelEx().property("ph.page1PageValue", is(0))) .andExpect(modelEx().property("ph.hiddenPrev", is(true))) .andExpect(modelEx().property("ph.hiddenNext", is(true))); } @Test public void Nameのみ入力して検索ボタンをクリックすると8件ヒットする() throws Exception { secmvc.auth.perform(TestHelper.postForm("/countryList", this.countryListFormName) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("countryList")) .andExpect(xpath("/html/head/title").string("検索/一覧画面")) .andExpect(model().attributeExists("page")) .andExpect(model().attributeExists("ph")) .andExpect(modelEx().property("page.number", is(1))) .andExpect(modelEx().property("page.size", is(5))) .andExpect(modelEx().property("page.totalPages", is(2))) .andExpect(modelEx().property("page.numberOfElements", is(3))) .andExpect(modelEx().property("ph.page1PageValue", is(0))) .andExpect(modelEx().property("ph.hiddenPrev", is(false))) .andExpect(modelEx().property("ph.hiddenNext", is(true))); } @Test public void Continentのみ入力して検索ボタンをクリックすると2件ヒットする() throws Exception { secmvc.auth.perform(TestHelper.postForm("/countryList", this.countryListFormContinent) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("countryList")) .andExpect(xpath("/html/head/title").string("検索/一覧画面")) .andExpect(modelEx().property("page.number", is(5))) .andExpect(modelEx().property("page.size", is(5))) .andExpect(modelEx().property("page.totalPages", is(6))) .andExpect(modelEx().property("page.numberOfElements", is(3))) .andExpect(modelEx().property("ph.page1PageValue", is(1))) .andExpect(modelEx().property("ph.hiddenPrev", is(false))) .andExpect(modelEx().property("ph.hiddenNext", is(true))); } @Test public void LocalNameのみ入力して検索ボタンをクリックすると5件ヒットする() throws Exception { secmvc.auth.perform(TestHelper.postForm("/countryList", this.countryListFormLocalName) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("countryList")) .andExpect(xpath("/html/head/title").string("検索/一覧画面")) .andExpect(modelEx().property("page.number", is(0))) .andExpect(modelEx().property("page.size", is(5))) .andExpect(modelEx().property("page.totalPages", is(1))) .andExpect(modelEx().property("page.numberOfElements", is(5))) .andExpect(modelEx().property("ph.page1PageValue", is(0))) .andExpect(modelEx().property("ph.hiddenPrev", is(true))) .andExpect(modelEx().property("ph.hiddenNext", is(true))); } } } }
■その2
@Test public void LocalNameのみ入力して検索ボタンをクリックすると2件ヒットする() throws Exception { secmvc.auth.perform(TestHelper.postForm("/countryList", this.countryListFormLocalName) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("countryList")) .andExpect(xpath("/html/head/title").string("検索/一覧画面")) .andExpect(modelEx().property("page.number", is(0))) .andExpect(modelEx().property("page.size", is(5))) .andExpect(modelEx().property("page.totalPages", is(1))) .andExpect(modelEx().property("page.numberOfElements", is(2))) .andExpect(modelEx().property("ph.page1PageValue", is(0))) .andExpect(modelEx().property("ph.hiddenPrev", is(true))) .andExpect(modelEx().property("ph.hiddenNext", is(true))); }
- LocalName like '%oc%' にヒットするデータは2件しかないので、それに合わせます。
- メソッド名を
LocalNameのみ入力して検索ボタンをクリックすると5件ヒットする
→LocalNameのみ入力して検索ボタンをクリックすると2件ヒットする
に変更します。 .andExpect(modelEx().property("page.numberOfElements", is(5)))
→.andExpect(modelEx().property("page.numberOfElements", is(2)))
に変更します。
履歴
2015/03/16
初版発行。