Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その20 )( 登録画面作成5 )
概要
Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その19 )( 設定ファイルでトランザクションを設定する ) の続きです。
- 今回の手順で確認できるのは以下の内容です。
- 登録画面 ( 入力→確認→完了 ) のテストクラスをネストクラスでグループ化して、DB のバックアップ/リストアを行うテストが最低限なものになるようにする。
ソフトウェア一覧
参考にしたサイト
手順
登録画面 ( 入力→確認→完了 ) のテストクラスの変更
Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その15 )( 登録画面作成4 ) で作成した登録画面 ( 入力→確認→完了 ) のテストクラスを以下の点を考慮して作り直します。
IntelliJ IDEA 上で 1.0.x-testcountryinput2 ブランチを作成します。
src/test/resources/ksbysample/webapp/basic/web の下に countryForm_empty.yaml, countryForm_sizedigitscheck.yaml, countryForm_success.yaml, countryForm_validateerror1.yaml, countryForm_validateerror2.yaml を新規作成します。作成後、リンク先の内容 に変更します。
src/test/java/ksbysample/webapp/basic/web の下の CountryControllerTest.java を リンク先の内容 に変更します。
「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行し、テストが全て成功することを確認します。この時テストは全て成功したのですが、変更前は 30秒くらいで終了していたものが 1分かかるようになってしまいました。。。
以下のことを試してみましたが、全く状況が変わりません。
- clean タスクを実行する。
- clean タスク → build タスクを実行する。
※この後「Run 'Tests in 'ksbysample...' with Coverage」を実行すると、なぜかテストが全部失敗します。もう1回 clean タスクを実行したら成功するようになりました。 - IntelliJ IDEA を再起動する。
ネストクラスでグループ化したり、テストメソッドが増えると時間がかかるのか?と疑いましたが、Google でいろいろ検索してもそのような情報は見つからなかったので、もう少し調べてみることにしました。
「Run 'Tests in 'ksbysample...' with Coverage」を実行すると Configuration の JUnit のツリーの下にアイテムが次々と追加されることを思い出し、一回クリアしてみることにしました。メインメニューから「Run」-「Edit Configurations...」を選択します。
「Run/Debug Configurations」ダイアログが表示されますので、以下の画像の Gradle と JUnit のツリーの下のアイテムを全部削除します。削除後「OK」ボタンをクリックしてダイアログを閉じます。
再度「Run 'Tests in 'ksbysample...' with Coverage」を実行すると、今度は 30秒程度に戻りました。テストが遅くなる場合には1度 Configurations の Gradle, JUnit のアイテムを削除するとよいようです ( なぜ直るのか全く分かりませんが ) 。
メモ
今回テストクラスを書き直した時に見つけたことを書き残しておきます。
clean タスク → build タスクの後に「Run 'Tests in 'ksbysample...' with Coverage」を実行するとテストが成功しない
上で書きましたが、clean タスク → build タスクの後に「Run 'Tests in 'ksbysample...' with Coverage」を実行すると Caused by: java.io.FileNotFoundException: class path resource [constant.properties] cannot be opened because it does not exist
のエラーが出てテストが全て失敗します。
もう1度 clean タスクを実行すると直ります。clean タスク実行後、build タスクは実行せずに「Run 'Tests in 'ksbysample...' with Coverage」を実行すると今度は全てのテストが成功します。
@RunWith(SpringJUnit4ClassRunner.class)
を付加したネストクラスのテストのみ実行する方法
ネストクラスにして一番上位のクラスから @RunWith(SpringJUnit4ClassRunner.class)
アノテーションを削除すると、Project View のテストクラスのコンテキストメニューに「Run '...' with Coverage」メニューが表示されません。
特定のクラスのテストだけを実行したい場合どうやればよいのだろう?と思って調べていたら、ネストクラスのクラス名かその上のアノテーションのあたりにカーソルを移動してコンテキストメニューを表示すると「Run '.....' with Coverage」のメニューが表示されて、@RunWith(SpringJUnit4ClassRunner.class)
アノテーションが付加されたネストクラスのテストだけ実行することが出来ることが分かりました。
特定のテストメソッドのみ実行することが出来ることも判明。
テストはまとめて実行するものと思い込んでいました。小さい単位で実行して確認することが出来るので便利ですね。
またテストクラスあるいはテストメソッドにカーソルを移動後、Ctrl+Shift+F10 を押してもテストが実行できます。「Choose configuration type to run」メニューが表示されますので「JUnit」を選択します ( 「Gradle」を選択するとエラーログが出てテストが成功しませんでした )。1度選択すると次に Ctrl+Shift+F10 を押した時にはメニューが表示されずにテストが実行されます。
間違って「Gradle」を選択した場合には、メインメニューから「Run」-「Edit Configurations...」を選択して「Run/Debug Configurations」ダイアログを表示後、Gradle と JUnit のツリーの下のアイテムを削除すれば再び「Choose configuration type to run」メニューが表示されます。
commit、GitHub へ Push、1.0.x-testcountryinput2 -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-testcountryinput2 ブランチを削除
commit の前に build タスクを実行し、BUILD SUCCESSFUL が表示されることを確認します。
commit、GitHub へ Push、1.0.x-testcountryinput2 -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-testcountryinput2 ブランチを削除、をします。
※commit 時に Code Analysis のダイアログが表示されますが、
@RunWith(SpringJUnit4ClassRunner.class)
アノテーションを付加していないクラス・ネストクラスに対する使用されていないことへの警告なので、無視して「Commit」ボタンをクリックします。
考察
- 画面のパターン ( 登録画面, 検索画面等 ) 毎のテストクラス内のネストクラスのテンプレートを用意しておくとテストクラスが書きやすい気がします。
- Form クラスから YAML ファイルを生成してくれるツールが欲しい。バリデーションを考慮してテストデータ付きで生成までしてくれると便利かなと思いましたが、どうすれば実現できるのか分かりません。。。
次回は。。。
- clean タスク → build タスク実行後に「Run '.....' with Coverage」を実行するとエラーになるのですが、「Build」-「Make Project」(Ctrl+F9) のコンパイルと、Gradle の build タスクのコンパイルは結果が異なるのでしょうか? 気になるので調べてみます。
- その後に検索画面 ( Spring Data JPA 版 ) を作成します。
ソースコード
countryForm_empty.yaml, countryForm_sizedigitscheck.yaml, countryForm_success.yaml, countryForm_validateerror1.yaml, countryForm_validateerror2.yaml
■countryForm_empty.yaml
!!ksbysample.webapp.basic.web.CountryForm code: name: continent: region: surfaceArea: population: localName: governmentForm: code2:
■countryForm_sizedigitscheck.yaml
!!ksbysample.webapp.basic.web.CountryForm code: xxxx name: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx continent: test region: xxxxxxxxxxxxxxxxxxxxxxxxxxx surfaceArea: 12345678.000 population: 123456789012 localName: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx governmentForm: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx code2: xxx
■countryForm_success.yaml
!!ksbysample.webapp.basic.web.CountryForm code: xxx name: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx continent: Asia region: Eastern Asia surfaceArea: 12345678.00 population: 2147483647 localName: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx governmentForm: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx code2: xx
■countryForm_validateerror1.yaml
!!ksbysample.webapp.basic.web.CountryForm code: JP2 name: Japan continent: Europe region: Eastern Asia surfaceArea: 1 population: 2 localName: Nippon governmentForm: test code2: JP
■countryForm_validateerror2.yaml
!!ksbysample.webapp.basic.web.CountryForm code: JP2 name: Japan continent: Asia region: Southern Europe surfaceArea: 1 population: 2 localName: Nippon governmentForm: test code2: JP
CountryControllerTest.java
package ksbysample.webapp.basic.web; import ksbysample.webapp.basic.Application; import ksbysample.webapp.basic.domain.Country; import ksbysample.webapp.basic.service.CountryRepository; 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.springframework.test.web.servlet.MvcResult; import org.yaml.snakeyaml.Yaml; import static ksbysample.webapp.basic.test.CustomFlashAttributeResultMatchers.flashEx; import static ksbysample.webapp.basic.test.FieldErrorsMatchers.fieldErrors; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; import static org.junit.Assert.assertThat; 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.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; public class CountryControllerTest { @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("/country/input")) .andExpect(status().isFound()) .andExpect(redirectedUrl("http://localhost/")); } @Test public void 確認画面の表示は403エラーが返る() throws Exception { // 非認証時は403エラー(Forbidden)が返ることを確認する secmvc.nonauth.perform(post("/country/confirm")) .andExpect(status().isForbidden()); } @Test public void 確認画面の戻るボタン押下時の処理は403エラーが返る() throws Exception { // 非認証時は403エラー(Forbidden)が返ることを確認する secmvc.nonauth.perform(post("/country/input/back")) .andExpect(status().isForbidden()); } @Test public void 確認画面の登録ボタン押下時の処理は403エラーが返る() throws Exception { // 非認証時は403エラー(Forbidden)が返ることを確認する secmvc.nonauth.perform(post("/country/update")) .andExpect(status().isForbidden()); } @Test public void 完了画面の表示は403エラーが返る() throws Exception { // 非認証時は403エラー(Forbidden)が返ることを確認する secmvc.nonauth.perform(post("/country/complete")) .andExpect(status().isForbidden()); } } public static class 認証時の場合 { @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) @WebAppConfiguration public static class CSRFトークンがない場合のテスト { @Rule @Autowired public SecurityMockMvcResource secmvc; // テストデータ private CountryForm countryFormSuccess = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_success.yaml")); @Test public void 確認画面はCSRFトークンがない場合403エラーが返る() throws Exception { // データは問題なくても CSRFトークンがない場合には403エラー(Forbidden)が返ることを確認する secmvc.auth.perform(TestHelper.postForm("/country/confirm", this.countryFormSuccess)) .andExpect(status().isForbidden()); } @Test public void 登録ボタン押下時の処理はCSRFトークンがない場合403エラーが返る() throws Exception { // データは問題なくても CSRFトークンがない場合には403エラー(Forbidden)が返ることを確認する secmvc.auth.perform(TestHelper.postForm("/country/update", this.countryFormSuccess)) .andExpect(status().isForbidden()); } } public static class 入力画面のテスト { @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) @WebAppConfiguration public static class DBを使用しない処理のテスト { @Rule @Autowired public SecurityMockMvcResource secmvc; // テストデータ private CountryForm countryFormEmpty = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_empty.yaml")); private CountryForm countryFormSizeDigitsCheck = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_sizedigitscheck.yaml")); private CountryForm countryFormValidateError1 = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_validateerror1.yaml")); private CountryForm countryFormValidateError2 = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_validateerror2.yaml")); @Test public void 入力画面を表示する() throws Exception { // 認証時は登録画面(入力)が表示されることを確認する secmvc.auth.perform(get("/country/input")) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("country/input")); } @Test public void データ未入力時には入力チェックエラーが発生する() throws Exception { // NotBlank/NotNullの入力チェックのテスト MvcResult result = secmvc.auth.perform(TestHelper.postForm("/country/confirm", this.countryFormEmpty) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("country/input")) .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(入力)")) .andExpect(model().hasErrors()) .andExpect(model().errorCount(11)) .andExpect(fieldErrors().hasFieldError("countryForm", "code", "NotBlank")) .andExpect(fieldErrors().hasFieldError("countryForm", "name", "NotBlank")) .andExpect(fieldErrors().hasFieldError("countryForm", "continent", "NotBlank")) .andExpect(fieldErrors().hasFieldError("countryForm", "continent", "Pattern")) .andExpect(fieldErrors().hasFieldError("countryForm", "region", "NotBlank")) .andExpect(fieldErrors().hasFieldError("countryForm", "surfaceArea", "NotNull")) .andExpect(fieldErrors().hasFieldError("countryForm", "population", "NotNull")) .andExpect(fieldErrors().hasFieldError("countryForm", "localName", "NotBlank")) .andExpect(fieldErrors().hasFieldError("countryForm", "governmentForm", "NotBlank")) .andExpect(fieldErrors().hasFieldError("countryForm", "code2", "NotBlank")) .andExpect(fieldErrors().hasFieldError("countryForm", "code2", "countryForm.code2.equalCode")) .andReturn(); // // 発生しているfield errorを全て出力するには以下のようにする // ModelAndView mav = result.getModelAndView(); // BindingResult br = (BindingResult) mav.getModel().get(BindingResult.MODEL_KEY_PREFIX + "countryForm"); // List<FieldError> listFE = br.getFieldErrors(); // for (FieldError fe : listFE) { // System.out.println("★★★ " + fe.getField() + " : " + fe.getCode() + " : " + fe.getDefaultMessage()); // } } @Test public void 文字数桁数オーバー時には入力チェックエラーが発生する() throws Exception { // Size/Pattern/Digitsの入力チェックのテスト secmvc.auth.perform(TestHelper.postForm("/country/confirm", this.countryFormSizeDigitsCheck) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(model().hasErrors()) .andExpect(model().errorCount(9)) .andExpect(fieldErrors().hasFieldError("countryForm", "code", "Size")) .andExpect(fieldErrors().hasFieldError("countryForm", "name", "Size")) .andExpect(fieldErrors().hasFieldError("countryForm", "continent", "Pattern")) .andExpect(fieldErrors().hasFieldError("countryForm", "region", "Size")) .andExpect(fieldErrors().hasFieldError("countryForm", "surfaceArea", "Digits")) .andExpect(fieldErrors().hasFieldError("countryForm", "population", "Digits")) .andExpect(fieldErrors().hasFieldError("countryForm", "localName", "Size")) .andExpect(fieldErrors().hasFieldError("countryForm", "governmentForm", "Size")) .andExpect(fieldErrors().hasFieldError("countryForm", "code2", "Size")) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("country/input")) .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(入力)")); } @Test public void countryForm_continent_notAsiaの入力チェックエラーのテスト() throws Exception { // countryForm.continent.notAsia の入力チェックのテスト secmvc.auth.perform(TestHelper.postForm("/country/confirm", this.countryFormValidateError1) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(model().hasErrors()) .andExpect(model().errorCount(1)) .andExpect(fieldErrors().hasFieldError("countryForm", "continent", "countryForm.continent.notAsia")) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("country/input")) .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(入力)")); } @Test public void countryForm_region_notAsiaPatternの入力チェックエラーのテスト() throws Exception { // countryForm.region.notAsiaPattern の入力チェックのテスト secmvc.auth.perform(TestHelper.postForm("/country/confirm", this.countryFormValidateError2) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(model().hasErrors()) .andExpect(model().errorCount(1)) .andExpect(fieldErrors().hasFieldError("countryForm", "region", "countryForm.region.notAsiaPattern")) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("country/input")) .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(入力)")); } } } public static class 確認画面のテスト { @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) @WebAppConfiguration public static class DBを使用しない処理のテスト { @Rule @Autowired public SecurityMockMvcResource secmvc; // テストデータ private CountryForm countryFormSuccess = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_success.yaml")); private CountryForm countryFormSizeDigitsCheck = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_sizedigitscheck.yaml")); @Test public void 確認画面を表示する() throws Exception { // データが問題なくCSRFトークンもある場合には登録画面(確認)が表示されることを確認する secmvc.auth.perform(TestHelper.postForm("/country/confirm", this.countryFormSuccess) .with(csrf()) ) .andExpect(status().isOk()) .andExpect(model().hasNoErrors()) .andExpect(model().errorCount(0)) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("country/confirm")) .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(確認)")); } @Test public void 戻るボタンを押すと入力画面へ戻る() throws Exception { // 認証時はフォームのデータがFlashスコープにセットされて、登録画面(入力)へリダイレクトされることを確認する secmvc.auth.perform(TestHelper.postForm("/country/input/back", this.countryFormSuccess) .with(csrf()) ) .andExpect(status().isFound()) .andExpect(redirectedUrl("/country/input")) .andExpect(flashEx().attributes("countryForm", this.countryFormSuccess)); } @Test public void データに入力チェックエラーがある場合400エラーが返る() throws Exception { // CSRFトークンがあってもデータに問題がある場合には400エラー(Bad Request)が返ることを確認する secmvc.auth.perform(TestHelper.postForm("/country/update", this.countryFormSizeDigitsCheck) .with(csrf()) ) .andExpect(status().isBadRequest()); } } @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) @WebAppConfiguration public static class DBを使用する処理のテスト { @Rule @Autowired public TestDataResource testDataResource; @Rule @Autowired public SecurityMockMvcResource secmvc; @Autowired private CountryRepository countryRepository; // テストデータ private CountryForm countryFormSuccess = (CountryForm) new Yaml().load(getClass().getResourceAsStream("countryForm_success.yaml")); @Test public void データが問題なければDBにデータが登録され完了画面へリダイレクトされる() throws Exception { // データが問題なくCSRFトークンもある場合にはDBにデータが登録され、登録画面(完了)へリダイレクトされることを確認する secmvc.auth.perform(TestHelper.postForm("/country/update", this.countryFormSuccess) .with(csrf()) ) .andExpect(status().isFound()) .andExpect(redirectedUrl("/country/complete")) .andExpect(model().hasNoErrors()) .andExpect(model().errorCount(0)); Country country = countryRepository.findOne("xxx"); assertThat(country, is(notNullValue())); TestHelper.assertEntityByForm(country, this.countryFormSuccess); } } } public static class 完了画面のテスト { @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) @WebAppConfiguration public static class DBを使用しない処理のテスト { @Rule @Autowired public SecurityMockMvcResource secmvc; @Test public void 完了画面を表示する() throws Exception { secmvc.auth.perform(get("/country/complete")) .andExpect(status().isOk()) .andExpect(content().contentType("text/html;charset=UTF-8")) .andExpect(view().name("country/complete")) .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(完了)")); } } } } }
- グループ化する際には以下のようなことを考えました。
- 一番上位のグループでは非認証時と認証時の2グループに分けます。
- 非認証時は各URL毎のテストメソッドを作成し、リダイレクトあるいはエラーになることを確認します。
- 認証時は画面毎(入力、確認、完了)のネストクラスを作成します。ただしCSRF対策のテストだけは別のネストクラスにしました。正常系と異常系でネストクラスを分けた方が分かりやすい気がします。
- 各画面毎のネストクラスでは、DB を使用しない場合と使用する場合の2つのネストクラスを作成しています。DB を使用する場合のネストクラスのみ
public TestDataResource testDataResource;
を定義します。 - 入力画面のネストクラスでは、初期表示と入力チェックのテストメソッドを作成しています。
- 確認画面のネストクラスでは、初期表示と各ボタンクリック時のテストメソッドを作成しています。
- 完了画面のネストクラスでは、初期表示のテストメソッドを作成しています。
履歴
2015/03/21
初版発行。