Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その10 )( ログイン画面作成3 )
概要
Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その9 )( ログイン画面作成2 ) の続きです。
- 今回の手順で確認できるのは以下の内容です。
- 検索/一覧画面の Bean Validation がうまく動作していない原因を調査します。
- DbUnit を使用してテスト開始前にデータのバックアップ取得→テストデータへの入替→テスト終了後に元のデータに戻るようにします。
- ログイン画面まわりのテストクラスを書きます。MockMvc を使用して Spring Security を利用した機能をテストする方法を書きます。
ソフトウェア一覧
参考にしたサイト
IntelliJでLombok
http://siosio.hatenablog.com/entry/2013/12/23/000054Getting same hashed value while using BCryptPasswordEncoder
http://stackoverflow.com/questions/26811885/getting-same-hashed-value-while-using-bcryptpasswordencoderDBUnitを使用した結合試験データの積み込み
http://qiita.com/tamurashingo@github/items/e37697796001bb40f0d2データベースのテスト支援ツール DbUnit その2 テストデータ登録編
http://genesis-tdsg.blogspot.jp/2013/07/dbunit_24.htmlJUnit の @Rule で ExternalResource を使ってみる
http://irof.hateblo.jp/entry/20110125/p1jUnit @Rule and Spring Caches
http://www.jayway.com/2014/12/07/junit-rule-spring-caches/- DbUnit の ExternalResource クラスを継承したクラスに DataSource Bean を Autowired する際に参考にしました。
How to Mock the security context in Spring MVC for testing
http://stackoverflow.com/questions/18270973/how-to-mock-the-security-context-in-spring-mvc-for-testingspring-security-test-blog/.../AuthenticationTests.java
https://github.com/rwinch/spring-security-test-blog/blob/master/src/test/java/org/springframework/security/test/web/servlet/showcase/login/AuthenticationTests.java- MockMvc で Spring Security を利用している機能をテストするサンプルです。
spring-test-mvc/.../SessionAttributeAssertionTests.java
https://github.com/spring-projects/spring-test-mvc/blob/master/src/test/java/org/springframework/test/web/server/samples/standalone/resultmatchers/SessionAttributeAssertionTests.java- MockMvc のテストで session のデータをチェックする方法の参考にしました。
意外に知らないDbUnitでCSVを使う方法
http://jyukutyo.hatenablog.com/entry/20080828/1219885803
手順
検索/一覧画面の Bean Validation がうまく動作していない原因を調査する
IntelliJ IDEA 上で 1.0.x-hotfix-beanvalidation ブランチを作成します。
まずは状況の確認からです。Gradle tasks View から bootRun を実行した後、ログインして検索/一覧画面を表示し「Continent」に "aaa" と入力して「検索」ボタンをクリックします。
HTTP 500 が返り「Web サイトはページを表示できません」の画面が表示されます。
ログを見ると、以下のエラーが出力されていました。
org.thymeleaf.exceptions.TemplateProcessingException: Exception evaluating SpringEL expression: "ph.hiddenPrev" (common/pagenation:41)
src/main/java/ksbysample/webapp/basic/web の下の CountryListController.java を見ると Bean Validation エラー時は単に
return "countryList";
しかしていませんでした。model に何もセットしていないので、エラーになるのは当たり前でした。page が null の時は一覧表示部分には何も表示しないことにします。src/main/resources/templates の下の countryList.html をエディタで開き、リンク先の内容 に変更します。
再度検索/一覧画面を表示し「Continent」に "aaa" と入力して「検索」ボタンをクリックします。今度はエラーメッセージが表示され、一覧表示部分は表示されません。
Ctrl+F2 を押して Tomcat を停止します。
commit、GitHub へ Push、1.0.x-hotfix-beanvalidation -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-hotfix-beanvalidation ブランチを削除、をします。
テストクラスを作成する前に現時点の Coverage を取得したらエラーが出たので調査する
テストクラス作成前に Coverage の状況を把握するために、Project View の一番上の階層の ksbysample-webapp-basic を選択した後コンテキストメニューを表示し「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行するとなぜかエラーが発生しました。
Gradle tasks View から build や compileJava を実行した時はエラーは出なかったのですが、「Run 'Tests in 'ksbysample...' with Coverage」ではエラーが出るのは気持ちが悪いので原因を調査します。
メイン画面のメニューに「Build」->「Make Project」というものがあったので、選択して実行するとこれも Coverage と同じエラーが出ました。どうも Gradle の compile とは処理が異なるようです。
CountryListForm.setSize メソッドは引数に long 型の変数を想定しており、それに対して pageable.getPageSize() の戻り値は int でしたので、CountryListForm クラスの page, size のフィールドの型を long → int へ修正してみます。再度「Run 'Tests in 'ksbysample...' with Coverage」を実行しましたが、結果は変わりませんでした。
lombokまわりかと思い、Structure View で CountryListForm クラスを見てみましたが、setSize(int), setPage(int) になっていました。
原因分からず。。。 lombokまわりの想定でいろいろ調べていると、下記の記事の中に「Annotation Processorsを有効にする」という設定があり、自分はこの設定をしていませんでした。
IntelliJでLombok
http://siosio.hatenablog.com/entry/2013/12/23/000054設定してみます。メイン画面のメニューから「File」->「Settings...」を選択して「Settings」ダイアログを表示します。次に検索フィールドに "Annotation" と入力すると、以下の画像の「Enable annotation processing」が表示されたのでチェックした後「OK」ボタンをクリックします。
再度「Run 'Tests in 'ksbysample...' with Coverage」を実行します。今度はエラーが出ずに処理が続行し、最後に All Tests Passed の文字が表示されました。
フィールドの型を修正しているので、commit、GitHub へ Push します。
ログイン画面のテストクラスの作成
IntelliJ IDEA 上で 1.0.x-testlogin ブランチを作成します。
まず DB のデータをテスト用のものに入れ替えられるようにするために DbUnit を入れます。build.gradle をエディタで開き、リンク先の内容 に変更します。変更後、Gradle tasks View の「Refresh Gradle projects」アイコンをクリックして、変更した build.gradle の内容を反映します。
src/test/resources の下に testdata ディレクトリを作成します。
src/test/resources/testdata の下に user テーブルに入れるデータを記述した user.csv を作成します。作成後、リンク先の内容 に変更します。
src/test/resources/testdata の下に user_role テーブルに入れるデータを記述した user_role.csv を作成します。作成後、リンク先の内容 に変更します。
src/test/resources/testdata の下にテストデータの投入順序を決める table-ordering.txt を作成します。作成後、リンク先の内容 に変更します。
src/test/java/ksbysample/webapp/basic/test の下に TestDataResource.java を作成します。作成後、リンク先の内容 に変更します。これは DbUnit の ExternalResource クラスを継承したクラスで、テストメソッドの実行前にテーブルのバックアップを取得→テストデータへの入替を実行し、テストメソッドの実行後にバックアップからのリストアを実行するクラスです。
src/test/java/ksbysample/webapp/basic/web の下の LoginControllerTest.java をエディタで開き、リンク先の内容 に変更します。
Project View の一番上の階層の ksbysample-webapp-basic を選択した後コンテキストメニューを表示し「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行します。All Tests Passed の文字が表示され、LoginController クラスの Coverage が "100% methods, 100% line covered" になります。
commit、GitHub へ Push、1.0.x-testlogin -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-testlogin ブランチを削除、をします。
- メモ書きです。Pull Request をマージする時に "close #25" のようにコメントに書くと対応する Issue に「Merged」アイコン付きのコメントが表示されます。
次回は。。。
- DbUnit の使い方を調べたり、Spring Security を利用している場合のテストの方法を調べるのに思ったより時間がかかりました。ちょっとテストクラスから離れたいので、検索/一覧画面のテストクラス作成がまだですが登録画面を作成することにします。
ソースコード
countryList.html
<div th:if="${page} != null"> <div id="pagenation" th:include="common/pagenation :: pagenation (url='/countryList', page=${page}, ph=${ph})"></div> <table class="table table-condensed table-bordered table-striped"> <tr class="info"> <th>Code</th> <th>Name</th> <th>Continent</th> <th>LocalName</th> </tr> <tr th:each="country : ${page.content}"> <td th:text="${country.code}">ABW</td> <td th:text="${country.name}">Aruba</td> <td th:text="${country.continent}">North America</td> <td th:text="${country.localName}">Aruba</td> </tr> </table> <div id="pagenation" th:include="common/pagenation :: pagenation (url='/countryList', page=${page}, ph=${ph})"></div> <form id="pagenationForm" method="post" action="#" th:action="@{#}" th:object="${countryListForm}"> <input type="hidden" name="code" id="code" th:value="*{code}"/> <input type="hidden" name="name" id="name" th:value="*{name}"/> <input type="hidden" name="continent" id="continent" th:value="*{continent}"/> <input type="hidden" name="localName" id="localName" th:value="*{localName}"/> </form> </div>
- 一覧表示部分全体を
<div th:if="${page} != null"> ... </div>
で囲みます。
build.gradle
dependencies { compile("org.springframework.boot:spring-boot-starter-web:1.2.1.RELEASE") compile("org.springframework.boot:spring-boot-starter-thymeleaf") compile("org.springframework.boot:spring-boot-starter-data-jpa") compile("org.springframework.boot:spring-boot-starter-security") 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") testCompile("org.springframework.security:spring-security-test:4.0.0.M2") testCompile("org.dbunit:dbunit:2.5.0") }
- dependencies タスクに
testCompile("org.springframework.security:spring-security-test:4.0.0.M2")
を追加します。これを入れると Spring-Securityを利用している機能のテストをやりやすくするクラスが入ります。 - dependencies タスクに
testCompile("org.dbunit:dbunit:2.5.0")
を追加します。
user.csv
"id","password","enabled" "tanaka","$2a$10$cGCB7qUKVJezf8/5LOWOBu0cHMQ73lwXJiGgGN2efQLLzTi5VWF4u","1" "suzuki","$2a$10$3X4WSXAzrFxXwgB4f8ZQr.g/QoxLIDxo.fqBvF4xp2BF3NUtkSuXm","0"
- tanaka のパスワードは tarou です。
- suzuki のパスワードは 20140101 です。
user_role.csv
"id","role" "tanaka","USER" "suzuki","USER"
table-ordering.txt
user user_role
TestDataResource.java
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.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 Throwable { IDatabaseConnection conn = null; try { conn = new DatabaseConnection(dataSource.getConnection()); // バックアップを取得する QueryDataSet partialDataSet = new QueryDataSet(conn); partialDataSet.addTable("user"); partialDataSet.addTable("user_role"); backupFile = File.createTempFile("world_backup", "xml"); FlatXmlDataSet.write(partialDataSet, new FileOutputStream(backupFile)); // テストデータに入れ替える IDataSet dataset = new CsvDataSet(new File("src/test/resources/testdata")); DatabaseOperation.CLEAN_INSERT.execute(conn, dataset); } 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 ignored) { } } }
- ExternalResource クラスを継承するこのクラスに Spring Boot で生成する DataSource Bean を Autowired させるために以下のように実装しています。
LoginControllerTest.java
package ksbysample.webapp.basic.web; import ksbysample.webapp.basic.Application; import ksbysample.webapp.basic.test.TestDataResource; import org.junit.Before; 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.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; import javax.servlet.Filter; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.isA; import static org.junit.Assert.assertThat; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) @WebAppConfiguration public class LoginControllerTest { @Autowired private WebApplicationContext context; @Rule @Autowired public TestDataResource testDataResource; @Autowired private Filter springSecurityFilterChain; private MockMvc mvc; @Before public void setUp() throws Exception { this.mvc = MockMvcBuilders.webAppContextSetup(this.context) .addFilter(springSecurityFilterChain) .build(); } @Test public void testIndex() throws Exception { this.mvc.perform(get("/")).andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("login")) .andExpect(xpath("/html/head/title").string("ログイン")); } @Test public void testLogin() throws Exception { // 存在しないユーザでログインを試みる this.mvc.perform(formLogin() .user("id", "test") .password("password", "ptest") ) .andExpect(status().isFound()) .andExpect(redirectedUrl("/")) .andExpect(unauthenticated()) .andExpect(request().sessionAttribute("SPRING_SECURITY_LAST_EXCEPTION", isA(BadCredentialsException.class))); // 使用できないユーザ ( enabled = 0 ) でログインを試みる this.mvc.perform(formLogin() .user("id", "suzuki") .password("password", "20140101") ) .andExpect(status().isFound()) .andExpect(redirectedUrl("/")) .andExpect(unauthenticated()) .andExpect(request().sessionAttribute("SPRING_SECURITY_LAST_EXCEPTION", isA(DisabledException.class))); // ログイン可能なユーザでログインする this.mvc.perform(formLogin() .user("id", "tanaka") .password("password", "tarou") ) .andExpect(status().isFound()) .andExpect(redirectedUrl("/countryList")) .andExpect(authenticated().withUsername("tanaka")); } @Test public void testEncode() throws Exception { MvcResult result = this.mvc.perform(get("/encode?password=ptest")).andExpect(status().isOk()) .andExpect(content().contentType("text/plain;charset=UTF-8")) .andReturn(); String crypt = result.getResponse().getContentAsString(); BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); assertThat(passwordEncoder.matches("ptest", crypt), is(true)); } }
public TestDataResource testDataResource;
を追加します。@Rule
,@Autowired
アノテーションを付けます。private Filter springSecurityFilterChain;
を追加します。- setUp メソッドの処理に
.addFilter(springSecurityFilterChain)
を追加します。これを付けると MockMvc のテスト時に Spring Security が機能するようになります。 - ログインのテストをする testLogin メソッドを追加します。
perform
メソッドに渡す引数はget(...)
やpost(...)
で URL を呼び出したものを渡すのではなくformLogin()
を使用します。- 認証のOK/NGは、
.andExpect(unauthenticated())
あるいは.andExpect(authenticated())
で判定します。 - 認証NGのエラー内容は
.andExpect(request().sessionAttribute("SPRING_SECURITY_LAST_EXCEPTION", isA(BadCredentialsException.class)))
のようにチェックします。セッションに SPRING_SECURITY_LAST_EXCEPTION という名前でエラーの原因のクラスのインスタンスがセットされています。
- testEncode メソッドを追加します。
履歴
2015/02/08
初版発行。