かんがるーさんの日記

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

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その10 )( ログイン画面作成3 )

概要

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その9 )( ログイン画面作成2 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • 検索/一覧画面の Bean Validation がうまく動作していない原因を調査します。
    • DbUnit を使用してテスト開始前にデータのバックアップ取得→テストデータへの入替→テスト終了後に元のデータに戻るようにします。
    • ログイン画面まわりのテストクラスを書きます。MockMvc を使用して Spring Security を利用した機能をテストする方法を書きます。

ソフトウェア一覧

参考にしたサイト

  1. IntelliJでLombok
    http://siosio.hatenablog.com/entry/2013/12/23/000054

  2. Getting same hashed value while using BCryptPasswordEncoder
    http://stackoverflow.com/questions/26811885/getting-same-hashed-value-while-using-bcryptpasswordencoder

  3. DbUnit
    http://www.dbunit.org/

  4. DBUnitを使用した結合試験データの積み込み
    http://qiita.com/tamurashingo@github/items/e37697796001bb40f0d2

  5. データベースのテスト支援ツール DbUnit その2 テストデータ登録編
    http://genesis-tdsg.blogspot.jp/2013/07/dbunit_24.html

  6. JUnit の @Rule で ExternalResource を使ってみる
    http://irof.hateblo.jp/entry/20110125/p1

  7. jUnit @Rule and Spring Caches
    http://www.jayway.com/2014/12/07/junit-rule-spring-caches/

    • DbUnit の ExternalResource クラスを継承したクラスに DataSource Bean を Autowired する際に参考にしました。
  8. 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-testing

  9. spring-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 を利用している機能をテストするサンプルです。
  10. 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 のデータをチェックする方法の参考にしました。
  11. 意外に知らないDbUnitCSVを使う方法
    http://jyukutyo.hatenablog.com/entry/20080828/1219885803

    • DbUnit で登録するテストデータを XML, Excel ではなく CSV に保存する場合の方法が記載されています。

手順

検索/一覧画面の Bean Validation がうまく動作していない原因を調査する

  1. IntelliJ IDEA 上で 1.0.x-hotfix-beanvalidation ブランチを作成します。

  2. まずは状況の確認からです。Gradle tasks View から bootRun を実行した後、ログインして検索/一覧画面を表示し「Continent」に "aaa" と入力して「検索」ボタンをクリックします。

    f:id:ksby:20150203014704p:plain

  3. HTTP 500 が返り「Web サイトはページを表示できません」の画面が表示されます。

    f:id:ksby:20150203014837p:plain

  4. ログを見ると、以下のエラーが出力されていました。

    org.thymeleaf.exceptions.TemplateProcessingException: Exception evaluating SpringEL expression: "ph.hiddenPrev" (common/pagenation:41)

  5. src/main/java/ksbysample/webapp/basic/web の下の CountryListController.java を見ると Bean Validation エラー時は単に return "countryList"; しかしていませんでした。model に何もセットしていないので、エラーになるのは当たり前でした。

  6. page が null の時は一覧表示部分には何も表示しないことにします。src/main/resources/templates の下の countryList.html をエディタで開き、リンク先の内容 に変更します。

  7. 再度検索/一覧画面を表示し「Continent」に "aaa" と入力して「検索」ボタンをクリックします。今度はエラーメッセージが表示され、一覧表示部分は表示されません。

    f:id:ksby:20150203023328p:plain

  8. Ctrl+F2 を押して Tomcat を停止します。

  9. commit、GitHub へ Push、1.0.x-hotfix-beanvalidation -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-hotfix-beanvalidation ブランチを削除、をします。

テストクラスを作成する前に現時点の Coverage を取得したらエラーが出たので調査する

  1. テストクラス作成前に Coverage の状況を把握するために、Project View の一番上の階層の ksbysample-webapp-basic を選択した後コンテキストメニューを表示し「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行するとなぜかエラーが発生しました。

    f:id:ksby:20150204013102p:plain

  2. 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) になっていました。

      f:id:ksby:20150205012539p:plain

    • 原因分からず。。。 lombokまわりの想定でいろいろ調べていると、下記の記事の中に「Annotation Processorsを有効にする」という設定があり、自分はこの設定をしていませんでした。

      IntelliJでLombok
      http://siosio.hatenablog.com/entry/2013/12/23/000054

    • 設定してみます。メイン画面のメニューから「File」->「Settings...」を選択して「Settings」ダイアログを表示します。次に検索フィールドに "Annotation" と入力すると、以下の画像の「Enable annotation processing」が表示されたのでチェックした後「OK」ボタンをクリックします。

      f:id:ksby:20150205013459p:plain

    • 再度「Run 'Tests in 'ksbysample...' with Coverage」を実行します。今度はエラーが出ずに処理が続行し、最後に All Tests Passed の文字が表示されました。

  3. フィールドの型を修正しているので、commit、GitHub へ Push します。

ログイン画面のテストクラスの作成

  1. IntelliJ IDEA 上で 1.0.x-testlogin ブランチを作成します。

  2. まず DB のデータをテスト用のものに入れ替えられるようにするために DbUnit を入れます。build.gradle をエディタで開き、リンク先の内容 に変更します。変更後、Gradle tasks View の「Refresh Gradle projects」アイコンをクリックして、変更した build.gradle の内容を反映します。

  3. src/test/resources の下に testdata ディレクトリを作成します。

  4. src/test/resources/testdata の下に user テーブルに入れるデータを記述した user.csv を作成します。作成後、リンク先の内容 に変更します。

  5. src/test/resources/testdata の下に user_role テーブルに入れるデータを記述した user_role.csv を作成します。作成後、リンク先の内容 に変更します。

  6. src/test/resources/testdata の下にテストデータの投入順序を決める table-ordering.txt を作成します。作成後、リンク先の内容 に変更します。

  7. src/test/java/ksbysample/webapp/basic の下に test パッケージを作成します。

  8. src/test/java/ksbysample/webapp/basic/test の下に TestDataResource.java を作成します。作成後、リンク先の内容 に変更します。これは DbUnit の ExternalResource クラスを継承したクラスで、テストメソッドの実行前にテーブルのバックアップを取得→テストデータへの入替を実行し、テストメソッドの実行後にバックアップからのリストアを実行するクラスです。

  9. src/test/java/ksbysample/webapp/basic/web の下の LoginControllerTest.java をエディタで開き、リンク先の内容 に変更します。

  10. Project View の一番上の階層の ksbysample-webapp-basic を選択した後コンテキストメニューを表示し「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行します。All Tests Passed の文字が表示され、LoginController クラスの Coverage が "100% methods, 100% line covered" になります。

    f:id:ksby:20150208012105p:plain

  11. 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
初版発行。