かんがるーさんの日記

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

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その29 )( Geb をインストールする )

概要

記事一覧はこちらです。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その28 )( Spring Boot を 1.5.4 → 1.5.7 へ、error-prone を 2.0.15 → 2.1.1 へバージョンアップする ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • Geb + Spock でテストを作成するために、まずは Geb をインストールして使えるようにします。

参照したサイト・書籍

  1. Geb - Very Groovy Browser Automation
    http://www.gebish.org/

  2. Web画面自動テストフレームワークGeb」の紹介
    http://lab.astamuse.co.jp/entry/geb_test_01

  3. Geb with spock
    https://www.slideshare.net/MonikaGurram/geb-with-spock-24710923

  4. mozilla/geckodriver
    https://github.com/mozilla/geckodriver

  5. Gradle – How to exclude some tests
    https://www.mkyong.com/gradle/gradle-how-to-exclude-some-tests/

  6. Getting Started With Gradle: Integration Testing
    https://www.petrikainulainen.net/programming/gradle/getting-started-with-gradle-integration-testing/

  7. Spring Boot and Gradle: Separating tests
    https://moelholm.com/2016/10/22/spring-boot-separating-tests/

目次

  1. 方針
  2. Geb に必要なモジュールをインストールする
  3. GebConfig.groovy を作成する
  4. テスト用のパッケージを作成する
  5. 入力画面1の Page Objects を作成する
  6. 簡単なテストを作成する
  7. geckodriver をインストールする
  8. gebTest タスクを作成する
  9. 動作確認
  10. IntelliJ IDEA 上からテストを実行できるようにする

手順

方針

  • テストは Geb + Spock の組み合わせで作成します。geb-core ではなく geb-spock を指定してインストールします。
  • ブラウザは Firefox を利用します。今回テストを作成している時にインストールしている Firefox のバージョンは 56.0.2 (64ビット) です。
  • Geb は現時点で最新の 2.0-rc-1、selenium は 3.6.0 をインストールします。
  • Geb のテストは src/test/groovy/geb の下に作成します。src の下に main, test とは別にディレクトリは作成しません。
src/test/groovy
├ geb
│ ├ gebspec
│ └ page
└ ksbysample
   └ webapp
      └ bootnpmgeb
  • gradle の test タスクでは Geb のテストは実行されないようにし、Geb のテストだけを実行する gebTest タスクを作成します。

Geb に必要なモジュールをインストールする

Geb + Spock でテストを作成するために必要なモジュールをインストールします。1.5. Installation & usage を参考に、build.gradle の dependencies に以下の3行を追加します。

dependencies {
    ..........

    // for Geb + Spock
    testCompile("org.gebish:geb-spock:2.0-rc-1")
    testCompile("org.seleniumhq.selenium:selenium-firefox-driver:3.6.0")
    testCompile("org.seleniumhq.selenium:selenium-support:3.6.0")
}

変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

GebConfig.groovy を作成する

共通の設定を記述するための GebConfig.groovy を src/test/resources の下に作成します。ファイルを作成した後、7. Configuration を参考に以下のように記述します。

driver = "firefox"
baseUrl = "http://localhost:8080"
waiting {
    timeout = 15
}

テスト用のパッケージを作成する

src/test/groovy の下に geb パッケージを作成し、geb の下に gebspec, page パッケージを作成します。

f:id:ksby:20171027005122p:plain

入力画面1の Page Objects を作成する

src/test/groovy/geb/page の下に inquiry パッケージを作成した後、inquiry の下に InquiryInput01Page.groovy を作成して以下の内容を記述します。

package geb.page.inquiry

import geb.Page

class InquiryInput01Page extends Page {

    static url = "/inquiry/input/01"
    static at = { title == "入力フォーム - 入力画面1" }

}

簡単なテストを作成する

動作確認のために簡単なテストを作成します。src/test/groovy/geb/gebspec の下に SimpleTestSpec.groovy を作成して以下の内容を記述します。

package geb.gebspec

import geb.page.inquiry.InquiryInput01Page
import geb.spock.GebSpec

class SimpleTestSpec extends GebSpec {

    def "動作確認用"() {
        expect: "入力画面1へアクセスする"
        to InquiryInput01Page
        waitFor { at InquiryInput01Page }
    }

}

geckodriver をインストールする

Web画面自動テストフレームワーク「Geb」の紹介 の記事によると Firefox の 47.0以降 から geckodriver を gradle でのダウンロードとは別にインストールする必要があるとのことですので、ダウンロード&インストールします。

https://github.com/mozilla/geckodriver/releases を見ると最新バージョンは v0.19.0 です。下にある geckodriver-v0.19.0-win64.zip のリンクをクリックしてダウンロードします。

ダウンロードした geckodriver-v0.19.0-win64.zip を解凍すると geckodriver.exe が出来ますので、C:\geckodriver\0.19.0 の下に配置します。

f:id:ksby:20171027011630p:plain

gebTest タスクを作成する

build.gradle に gebTest タスクを追加します。また test タスクで ksbysample パッケージ配下のテストが実行されないようにします。

test {
    jvmArgs = ['-Dspring.profiles.active=unittest']
    exclude "geb/**"
}

task gebTest(type: Test) {
    jvmArgs = [
            '-Dspring.profiles.active=unittest'
            , '-Dwebdriver.gecko.driver=C:/geckodriver/0.19.0/geckodriver.exe'
    ]
    exclude "ksbysample/**"
}
  • test タスクに exclude "geb/**" を追加します。
  • gebTest タスクを追加します。

変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして反映します。

動作確認

Tomcat を起動した後、gebTest タスクを実行してみます。。。が、java.lang.ClassNotFoundException: org.openqa.selenium.MutableCapabilities というエラーメッセージが出て失敗しました。

f:id:ksby:20171027062307p:plain

モジュールを見てみると selenium 関連で4つ表示されているのですが、selenium-apiselenium-remote-driver が 2.53.1 と古いバージョンになっています。全て 3.6.0 になるよう調整します。

f:id:ksby:20171027062538p:plain

build.gradle を以下のように変更します。

dependencies {
    ..........

    // for Geb + Spock
    testCompile("org.gebish:geb-spock:2.0-rc-1")
    testCompile("org.seleniumhq.selenium:selenium-firefox-driver:3.6.0")
    testCompile("org.seleniumhq.selenium:selenium-support:3.6.0")
    testCompile("org.seleniumhq.selenium:selenium-api:3.6.0")
    testCompile("org.seleniumhq.selenium:selenium-remote-driver:3.6.0")
}
  • 以下の2行を追加します。
    • testCompile("org.seleniumhq.selenium:selenium-api:3.6.0")
    • testCompile("org.seleniumhq.selenium:selenium-remote-driver:3.6.0")

変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新すると、今度は全て 3.6.0 になりました。

f:id:ksby:20171027063123p:plain

再度 getTest タスクを実行すると、今度は Firefox が起動して入力画面1にアクセスし、テストが成功しました。

f:id:ksby:20171027063323p:plain

Tomcat を停止した後、clean タスク → Rebuild Project → build タスクを実行すると BUILD SUCCESSFUL のメッセージが出力されました。test タスク中に Firefox は起動しなかったので、Geb で作成したテストは実行されませんでした。

f:id:ksby:20171027063830p:plain

IntelliJ IDEA 上からテストを実行できるようにする

今の設定だけでは IntelliJ IDEA のエディタの左側のメニューから Run 動作確認用() を選択してもテストを実行できません。

f:id:ksby:20171027065029p:plain

Caused by: java.lang.IllegalStateException: The path to the driver executable must be set by the webdriver.gecko.driver system property; for more information, see https://github.com/mozilla/geckodriver. The latest version can be downloaded from https://github.com/mozilla/geckodriver/releases というエラーメッセージが出力されます。

f:id:ksby:20171027065225p:plain

IntelliJ IDEA のメインメニューから「Run」-「Edit Configurations...」を選択して「Run/Debug Configurations」ダイアログを表示した後、「JUnit」の「VM Options」に -Dwebdriver.gecko.driver=C:/geckodriver/0.19.0/geckodriver.exe を追加します。

f:id:ksby:20171027065606p:plain

再度 Run 動作確認用() を選択してテストを実行すると Firefox が起動して入力画面1にアクセスし、テストが成功しました。

f:id:ksby:20171027065818p:plain

履歴

2017/10/27
初版発行。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( 番外編 )( ModelMapper メモ書き )

概要

記事一覧はこちらです。

最近 POJO 間のデータコピーに ModelMapper を使用していますが、使っていて気づいた点やつまずいた点をメモしておきます。

尚、ModelMapper の利用には rozidan/modelmapper-spring-boot-starter を利用しています(利用しなくても ModelMapper を使うのは難しくありませんが少し便利になる感じです)。

参照したサイト・書籍

目次

  1. String --> int 変換は何も定義しなくてもやってくれる
  2. skip に指定したフィールドがプリミティブ型だと実行時に NullPointerException が発生する
  3. 部分的に特別な処理をしたい時には setPreConverter で定義する
  4. コピー元からコピー先へ通常のフィールドコピーが行われるフィールドに preConverter で特別な処理をする場合には、一緒に skip も指定して通常のフィールドコピーが行われないようにする
  5. sourceType, destinationType に指定するクラスが同じで変換ルールが異なる TypeMap を作りたい場合には name を設定する
  6. rozidan/modelmapper-spring-boot-starter を使わずに ModelMapper を使用するには?
  7. 最後に

本文

String --> int 変換は何も定義しなくてもやってくれる

String 型のフィールドを int 型のフィールドにコピーする場合、何か変換処理を入れないといけないのかと思っていましたが、何もしなくても変換してくれます。

@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String age;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        private int age;
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .age("25")
                .build();
        // String 型 --> int には何も用意しなくても自動で変換してくれる
        DstData dstData = modelMapper.map(srcData, DstData.class);
        assertThat(dstData.getAge()).isEqualTo(Integer.parseInt(srcData.getAge()));
    }

}

上のテストを実行すると成功します。

f:id:ksby:20171021145514p:plain

skip に指定したフィールドがプリミティブ型だと実行時に NullPointerException が発生する

テストクラス内でプリミティブ型のフィールドの setter を skip に指定しても正常に動作するのですが(assertThat まで実行されます)、

@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String age;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        // コピー先をプリミティブ型(int)にする
        private int age;
    }

    @Component
    static class SrcData2DstDataTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            // プリミティブ型(int) の setter を skip に指定する
            typeMap.addMappings(mapping -> mapping.skip(DstData::setAge));
        }
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .age("25")
                .build();
        DstData dstData = modelMapper.map(srcData, DstData.class);
        assertThat(dstData.getAge()).isEqualTo(Integer.parseInt(srcData.getAge()));
    }

}

f:id:ksby:20171021153543p:plain

以下のような Controller クラスを作成して、

@Controller
@RequestMapping("/sample")
public class SampleController {

    private final ModelMapper modelMapper;

    public SampleController(ModelMapper modelMapper) {
        this.modelMapper = modelMapper;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String age;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        // コピー先をプリミティブ型(int)にする
        private int age;
    }

    @Component
    static class SrcData2DstDataTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            // プリミティブ型(int) の setter を skip に指定する
            typeMap.addMappings(mapping -> mapping.skip(DstData::setAge));
        }
    }

    @RequestMapping
    @ResponseBody
    public String index() {
        SrcData srcData = SrcData.builder()
                .age("25")
                .build();
        DstData dstData = modelMapper.map(srcData, DstData.class);
        return "sample";
    }

}

Tomcat を起動しようとすると NullPointerException が発生して起動しません。

f:id:ksby:20171021155408p:plain

これは skip に指定するフィールドがプリミティブ型であることが原因です。参照型に変更するとこのエラーは出ません。

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        // コピー先をプリミティブ型(int)にする --> 参照型に変更する
        // private int age;
        private Integer age;
    }

int --> Integer に変更すると Tomcat は起動します。

f:id:ksby:20171021160945p:plain

部分的に特別な処理をしたい時には setPreConverter で定義する

コピー先にだけ存在するフィールドに、コピー元のフィールドの値を見て値をセットしたい場合、setPreConverter で定義します。

以下の処理を行うテストクラスを書いてみます。

  • DstData.name に SrcData.firstName + " " + SrcData.lastName をセットします。
  • DstData.name が空でない場合には DstData.isEmptyNameFlg に true を、そうでない場合には false をセットします。
  • setPreConverter で定義された処理の後に通常のフィールドの値のコピーは行われるので、firstName, lastName はそのまま SrcData --> DstData へコピーされます。
@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String firstName;
        private String lastName;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        private String firstName;
        private String lastName;
        private String name;
        // コピー時に name に値がセットされれば true, 空なら false をセットする
        private boolean isEmptyNameFlg;
    }

    @Component
    static class GlobalConfiguration extends ConfigurationConfigurer {
        @Override
        public void configure(Configuration configuration) {
            // デフォルトの MatchingStrategies.STANDARD だと DstData.name のコピー元のフィールドとして
            // SrcData.firstName, SrcData.lastName の2つがあると判断されるため、MatchingStrategies.STRICT
            // に変更する
            configuration.setMatchingStrategy(MatchingStrategies.STRICT);
        }
    }

    @Component
    static class SrcData2DstDataTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            // setPreConverter に処理を定義して、コピー元に対応するフィールドがない
            // name, isEmptyNameFlg に値をセットする
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                dstData.setName(String.format("%s %s"
                        , srcData.getFirstName(), srcData.getLastName()));
                dstData.setEmptyNameFlg(StringUtils.isNotEmpty(dstData.getName()));
                return context.getDestination();
            });
        }
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .firstName("taro")
                .lastName("tanaka")
                .build();
        DstData dstData = modelMapper.map(srcData, DstData.class);
        assertThat(dstData.getFirstName()).isEqualTo(srcData.getFirstName());
        assertThat(dstData.getLastName()).isEqualTo(srcData.getLastName());
        assertThat(dstData.getName()).isEqualTo(
                String.format("%s %s", srcData.getFirstName(), srcData.getLastName()));
        assertThat(dstData.isEmptyNameFlg()).isTrue();
    }

}

上のテストを実行すると成功します。

f:id:ksby:20171021190710p:plain

コピー元からコピー先へ通常のフィールドコピーが行われるフィールドに preConverter で特別な処理をする場合には、一緒に skip も指定して通常のフィールドコピーが行われないようにする

コピー元とコピー先に同じフィールドが存在するがコピー時に setPreConverter に処理を定義して特殊な処理を行う場合、処理対象のフィールドが通常のフィールドのコピーの対象にならないよう skip で指定する必要があります。

@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String firstName;
        private String lastName;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        private String firstName;
        private String lastName;
    }

    @Component
    static class SrcData2DstDataTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                // firstName はコピー時に先頭に文字数を追加する
                dstData.setFirstName(srcData.getFirstName().length() + ":" + srcData.getFirstName());
                return context.getDestination();
            });
            // setPreConverter で firstName のコピー処理をしているので、通常のフィールドコピーの対象外にする
            typeMap.addMappings(mapping -> mapping.skip(DstData::setFirstName));
        }
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .firstName("taro")
                .lastName("tanaka")
                .build();
        DstData dstData = modelMapper.map(srcData, DstData.class);
        // firstName には "taro" の文字数が追加されて "4:taro" がセットされているはず
        assertThat(dstData.getFirstName()).isEqualTo("4:" + srcData.getFirstName());
        assertThat(dstData.getLastName()).isEqualTo(srcData.getLastName());
    }

}

上のテストを実行すると成功します。

f:id:ksby:20171021200144p:plain

ちなみに skip をコメントアウトすると

    @Component
    static class SrcData2DstDataTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                // firstName はコピー時に先頭に文字数を追加する
                dstData.setFirstName(srcData.getFirstName().length() + ":" + srcData.getFirstName());
                return context.getDestination();
            });
            // setPreConverter で firstName のコピー処理をしているので、通常のフィールドコピーの対象外にする
//            typeMap.addMappings(mapping -> mapping.skip(DstData::setFirstName));
        }
    }

フィールドのコピー処理が行われるのでテストは失敗します。

f:id:ksby:20171021200449p:plain

sourceType, destinationType に指定するクラスが同じで変換ルールが異なる TypeMap を作りたい場合には name を設定する

SrcData --> DstData へデータをコピーするのは同じですが、内部の処理が異なる TypeMap を2つ定義したいような場合には、TypeMap に名前を付けるようにします。そして ModelMapper#map メソッドを呼ぶ時に、第3引数に使用する TypeMap 名を指定します。

@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String firstName;
        private String lastName;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        private String firstName;
        private String lastName;
    }

    /**
     * DstData.firstName に SrcData.firstName + " " + SrcData.lastName をセットする
     * DstData.lastName には何もコピーしない
     */
    @Component
    static class CopyFirstNameOnlyTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        // ①外から参照できるよう public static final String の定数に TypeMap 名を定義する
        public static final String TYPEMAP_NAME = "CopyFirstNameOnlyTypeMap";

        // ②getTypeMapName メソッドをオーバーライドして、TypeMap 名を返す
        @Override
        public String getTypeMapName() {
            return TYPEMAP_NAME;
        }

        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                dstData.setFirstName(String.format("%s %s", srcData.getFirstName(), srcData.getLastName()));
                return context.getDestination();
            });
            typeMap.addMappings(mapping -> mapping.skip(DstData::setFirstName));
            typeMap.addMappings(mapping -> mapping.skip(DstData::setLastName));
        }
    }

    /**
     * DstData.firstName には何もコピーしない
     * DstData.lastName に SrcData.firstName + " " + SrcData.lastName をセットする
     */
    @Component
    static class CopyLastNameOnlyTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        public static final String TYPEMAP_NAME = "CopyLastNameOnlyTypeMap";

        @Override
        public String getTypeMapName() {
            return TYPEMAP_NAME;
        }

        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                dstData.setLastName(String.format("%s %s", srcData.getFirstName(), srcData.getLastName()));
                return context.getDestination();
            });
            typeMap.addMappings(mapping -> mapping.skip(DstData::setFirstName));
            typeMap.addMappings(mapping -> mapping.skip(DstData::setLastName));
        }
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .firstName("taro")
                .lastName("tanaka")
                .build();
        // ③map メソッドの第3引数に使用する TypeMap 名を指定する
        // ※TypeMap Bean をインジェクションして、getTypeMapName メソッドを呼んでもよい
        DstData dstDataFirst = modelMapper.map(srcData, DstData.class, CopyFirstNameOnlyTypeMap.TYPEMAP_NAME);
        assertThat(dstDataFirst.getFirstName()).isEqualTo(srcData.getFirstName() + " " + srcData.getLastName());
        assertThat(dstDataFirst.getLastName()).isNull();

        DstData dstDataLast = modelMapper.map(srcData, DstData.class, CopyLastNameOnlyTypeMap.TYPEMAP_NAME);
        assertThat(dstDataLast.getFirstName()).isNull();
        assertThat(dstDataLast.getLastName()).isEqualTo(srcData.getFirstName() + " " + srcData.getLastName());
    }

}

上のテストを実行すると成功します。

f:id:ksby:20171021212245p:plain

rozidan/modelmapper-spring-boot-starter を使わずに ModelMapper を使用するには?

Java Config で modelMapper Bean を定義し、

@Configuration
public class ModelMapperConfig {

    @Bean
    public ModelMapper modelMapper() {
        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
        return modelMapper;
    }

}

@Component アノテーションを付加したクラスのコンストラクタで ModelMapper#createTypeMap メソッドを呼び出して TypeMap を生成・登録します。あとは使用したい箇所で ModelMapper#map メソッドを呼び出します。

@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String firstName;
        private String lastName;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        private String firstName;
        private String lastName;
    }

    @Component
    static class SrcData2DstDataTypeMap {
        private SrcData2DstDataTypeMap(ModelMapper modelMapper) {
            TypeMap<SrcData, DstData> typeMap
                    = modelMapper.createTypeMap(SrcData.class, DstData.class);

            // setPreConverter に処理を定義して、コピー元に対応するフィールドがない
            // name, isEmptyNameFlg に値をセットする
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                // firstName はコピー時に先頭に文字数を追加する
                dstData.setFirstName(srcData.getFirstName().length() + ":" + srcData.getFirstName());
                return context.getDestination();
            });
            // setPreConverter で firstName のコピー処理をしているので、通常のフィールドコピーの対象外にする
            typeMap.addMappings(mapping -> mapping.skip(DstData::setFirstName));
        }
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .firstName("taro")
                .lastName("tanaka")
                .build();
        DstData dstData = modelMapper.map(srcData, DstData.class);
        // firstName には "taro" の文字数が追加されて "4:taro" がセットされているはず
        assertThat(dstData.getFirstName()).isEqualTo("4:" + srcData.getFirstName());
        assertThat(dstData.getLastName()).isEqualTo(srcData.getLastName());
    }

}

rozidan/modelmapper-spring-boot-starter を入れると、modelMapper Bean を自動生成してくれるのと、ModelMapper#createTypeMap の呼び出しを自動でやってくれます。

最後に

  • ModelMapper はいろいろ機能がありますが、modelMapper.map(...) を呼び出してコピーするか、単純なコピーでない場合には TypeMap を作成すればとりあえず使えるという印象です。
  • Matching strategy は個人的には STRICT にしておいた方が問題がない気がするのですが、まだそんなに使い込んでいる訳ではないので何とも言えないですね。
  • POJO をコピーするのに Dozer もありますが、XML で定義しないといけないし、ModelMapper でもそんなに困らない気がしていて、個人的には ModelMapper でいいんじゃないかなと思っています。

履歴

2017/10/22
初版発行。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その28 )( Spring Boot を 1.5.4 → 1.5.7 へ、error-prone を 2.0.15 → 2.1.1 へバージョンアップする )

概要

記事一覧はこちらです。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その27 )( 入力画面2を作成する6 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • Spring Boot のバージョンを 1.5.4 → 1.5.7 へバージョンアップします。
    • 他にもバージョンアップしているライブラリを更新します。
    • 今回はこれまでエラーが出てバージョンアップできなかった error-prone もバージョンアップします。やっと lombok を使用している環境で error-prone の最新バージョンを使う方法が理解できました。

参照したサイト・書籍

目次

  1. Spring Boot を 1.5.4 → 1.5.7 へバージョンアップする(他のライブラリもバージョンアップする)
  2. error-prone を 2.0.15 → 2.1.1 へバージョンアップする

手順

Spring Boot を 1.5.4 → 1.5.7 へバージョンアップする(他のライブラリもバージョンアップする)

build.gradle の以下の点を変更します。

group 'ksbysample'
version '1.0.0-RELEASE'

buildscript {
    ext {
        springBootVersion = '1.5.7.RELEASE'
    }
    repositories {
        mavenCentral()
        maven { url "https://plugins.gradle.org/m2/" }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        // for Error Prone ( http://errorprone.info/ )
        classpath("net.ltgt.gradle:gradle-errorprone-plugin:0.0.10")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
apply plugin: 'groovy'
apply plugin: 'net.ltgt.errorprone'
apply plugin: 'checkstyle'
apply plugin: 'findbugs'
apply plugin: 'pmd'

sourceCompatibility = 1.8
targetCompatibility = 1.8

task wrapper(type: Wrapper) {
    gradleVersion = '3.5'
}

[compileJava, compileTestGroovy, compileTestJava]*.options*.compilerArgs = ['-Xlint:all,-options,-processing,-path']
compileJava.options.compilerArgs += ['-Xep:RemoveUnusedImports:WARN']

// for Doma 2
// JavaクラスとSQLファイルの出力先ディレクトリを同じにする
processResources.destinationDir = compileJava.destinationDir
// コンパイルより前にSQLファイルを出力先ディレクトリにコピーするために依存関係を逆転する
compileJava.dependsOn processResources

idea {
    module {
        inheritOutputDirs = false
        outputDir = file("$buildDir/classes/main/")
    }
}

configurations {
    // for Doma 2
    domaGenRuntime
}

checkstyle {
    configFile = file("${rootProject.projectDir}/config/checkstyle/google_checks.xml")
    toolVersion = '8.3'
    sourceSets = [project.sourceSets.main]
}

findbugs {
    toolVersion = '3.0.1'
    sourceSets = [project.sourceSets.main]
    ignoreFailures = true
    effort = "max"
    excludeFilter = file("${rootProject.projectDir}/config/findbugs/findbugs-exclude.xml")
}

tasks.withType(FindBugs) {
    // Gradle 3.3以降 + FindBugs Gradle Plugin を組み合わせると、"The following errors occurred during analysis:"
    // の後に "Cannot open codebase filesystem:..." というメッセージが大量に出力されるので、以下の doFirst { ... }
    // のコードを入れることで出力されないようにする
    doFirst {
        def fc = classes
        if (fc == null) {
            return
        }
        fc.exclude '**/*.properties'
        fc.exclude '**/*.sql'
        fc.exclude '**/*.xml'
        fc.exclude '**/META-INF/**'
        fc.exclude '**/static/**'
        fc.exclude '**/templates/**'
        classes = files(fc.files)
    }
    reports {
        xml.enabled = false
        html.enabled = true
    }
}

pmd {
    toolVersion = "5.8.1"
    sourceSets = [project.sourceSets.main]
    ignoreFailures = true
    consoleOutput = true
    ruleSetFiles = rootProject.files("/config/pmd/pmd-project-rulesets.xml")
    ruleSets = []
}

repositories {
    mavenCentral()
}

dependencyManagement {
    imports {
        // BOM は https://repo.spring.io/release/io/spring/platform/platform-bom/Brussels-SR5/
        // の下を見ること
        mavenBom("io.spring.platform:platform-bom:Brussels-SR5") {
            bomProperty 'guava.version', '22.0'
            bomProperty 'thymeleaf.version', '3.0.8.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.1.RELEASE'
        }
    }
}

bootRepackage {
    mainClass = 'ksbysample.webapp.lending.Application'
}

dependencies {
    def spockVersion = "1.1-groovy-2.4"
    def domaVersion = "2.16.1"
    def lombokVersion = "1.16.18"
    def errorproneVersion = "2.0.15"
    def powermockVersion = "1.7.3"

    // dependency-management-plugin によりバージョン番号が自動で設定されるもの
    // Appendix A. Dependency versions ( http://docs.spring.io/platform/docs/current/reference/htmlsingle/#appendix-dependency-versions ) 参照
    compile("org.springframework.boot:spring-boot-starter-web")
    compile("org.springframework.boot:spring-boot-starter-thymeleaf") {
        exclude group: "org.codehaus.groovy", module: "groovy"
    }
    compile("org.springframework.boot:spring-boot-starter-data-jpa")
    compile("org.springframework.boot:spring-boot-starter-freemarker")
    compile("org.springframework.boot:spring-boot-starter-mail")
    compile("org.springframework.boot:spring-boot-starter-security")
    compile("org.springframework.boot:spring-boot-devtools")
    compile("org.springframework.session:spring-session")
    compile("com.google.guava:guava")
    compile("org.apache.commons:commons-lang3")
    compile("org.codehaus.janino:janino")
    testCompile("org.springframework.boot:spring-boot-starter-test")
    testCompile("org.springframework.security:spring-security-test")
    testCompile("org.yaml:snakeyaml")
    testCompile("org.mockito:mockito-core")

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    compile("com.integralblue:log4jdbc-spring-boot-starter:1.0.2")
    compile("org.flywaydb:flyway-core:4.2.0")
    compile("com.h2database:h2:1.4.192")
    compile("com.github.rozidan:modelmapper-spring-boot-starter:1.0.0")
    testCompile("org.dbunit:dbunit:2.5.4")
    testCompile("com.icegreen:greenmail:1.5.5")
    testCompile("org.assertj:assertj-core:3.8.0")
    testCompile("org.spockframework:spock-core:${spockVersion}")
    testCompile("org.spockframework:spock-spring:${spockVersion}")
    testCompile("com.google.code.findbugs:jsr305:3.0.2")
    testCompile("org.jsoup:jsoup:1.10.3")

    // for lombok
    compileOnly("org.projectlombok:lombok:${lombokVersion}")
    testCompileOnly("org.projectlombok:lombok:${lombokVersion}")

    // for Doma
    compile("org.seasar.doma:doma:${domaVersion}")
    domaGenRuntime("org.seasar.doma:doma-gen:${domaVersion}")
    domaGenRuntime("com.h2database:h2:1.4.192")

    // for Error Prone ( http://errorprone.info/ )
    errorprone("com.google.errorprone:error_prone_core:${errorproneVersion}")
    compileOnly("com.google.errorprone:error_prone_annotations:${errorproneVersion}")

    // PowerMock
    testCompile("org.powermock:powermock-module-junit4:${powermockVersion}")
    testCompile("org.powermock:powermock-api-mockito:${powermockVersion}")
}
  • version '1.5.4-RELEASE'version '1.0.0-RELEASE' に変更します。ここは Webアプリケーションのバージョンとして 1.0.0 と書くべきところを、Spring Boot のバージョンである 1.5.4 を書いてしまったという勘違いでした。
  • buildscript の以下の点を変更します。
    • springBootVersion = '1.5.4.RELEASE'springBootVersion = '1.5.7.RELEASE' に変更します。
  • checkstyle の以下の点を変更します。
    • toolVersion = '8.0'toolVersion = '8.3' に変更します。
  • dependencyManagement の以下の点を変更します。
    • mavenBom("io.spring.platform:platform-bom:Brussels-SR3")mavenBom("io.spring.platform:platform-bom:Brussels-SR5") に変更します。
    • bomProperty 'thymeleaf.version', '3.0.7.RELEASE'bomProperty 'thymeleaf.version', '3.0.8.RELEASE' に変更します。
    • bomProperty 'thymeleaf-extras-java8time.version', '3.0.0.RELEASE'bomProperty 'thymeleaf-extras-java8time.version', '3.0.1.RELEASE' に変更します。
  • dependencies の以下の点を変更します。
    • def powermockVersion = "1.7.0"def powermockVersion = "1.7.3" に変更します。
    • compile("com.integralblue:log4jdbc-spring-boot-starter:1.0.1")compile("com.integralblue:log4jdbc-spring-boot-starter:1.0.2") に変更します。
    • testCompile("org.dbunit:dbunit:2.5.3")testCompile("org.dbunit:dbunit:2.5.4") に変更します。

変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

clean タスク実行 → Rebuild Project 実行 → build タスクを実行してみますが、checkstyleProperty 'maxLineLength' in module LeftCurly does not exist, please check the documentation というエラーが出ました。

f:id:ksby:20171017010855p:plain

checkstyle のホームページ を見ると、確かに LeftCurly には maxLineLength は存在しませんでした。1行あたりの文字数のチェックは LineLength で行うようです(google_checks.xml にも定義が書いてありました)。

config/checkstyle/google_checks.xml の以下の点を変更します。

        <module name="NeedBraces"/>
        <module name="LeftCurly"/>
        <module name="RightCurly">
  • LeftCurly から <property name="maxLineLength" value="100"/> を削除し、<module name="LeftCurly"/> に変更します。

再度 clean タスク実行 → Rebuild Project 実行 → build タスクを実行すると "BUILD SUCCESSFUL" のメッセージが出力されました。

f:id:ksby:20171017012140p:plain

Project Tool Window で src/test を選択した後、コンテキストメニューを表示して「Run 'All Tests' with Coverage」を選択し、テストが全て成功することも確認できました。

f:id:ksby:20171017012928p:plain

error-prone を 2.0.15 → 2.1.1 へバージョンアップする

gradle-errorprone-plugin を見ると最新バージョンは 0.0.13、mvnrepository.com の error_prone_core のページ を見ると最新バージョンは 2.1.1 でした。

build.gradle の以下の点を変更します。

buildscript {
    ext {
        springBootVersion = '1.5.7.RELEASE'
    }
    repositories {
        mavenCentral()
        maven { url "https://plugins.gradle.org/m2/" }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        // for Error Prone ( http://errorprone.info/ )
        classpath("net.ltgt.gradle:gradle-errorprone-plugin:0.0.13")
    }
}

..........

dependencies {
    def spockVersion = "1.1-groovy-2.4"
    def domaVersion = "2.16.1"
    def lombokVersion = "1.16.18"
    def errorproneVersion = "2.1.1"
    def powermockVersion = "1.7.3"

    ..........

    // for Error Prone ( http://errorprone.info/ )
    errorprone("com.google.errorprone:error_prone_core:${errorproneVersion}")
    compileOnly("com.google.errorprone:error_prone_annotations:${errorproneVersion}")

    ..........
}
  • buildscript 内で classpath("net.ltgt.gradle:gradle-errorprone-plugin:0.0.10")classpath("net.ltgt.gradle:gradle-errorprone-plugin:0.0.13") に変更します。
  • dependencies 内で def errorproneVersion = "2.0.15"def errorproneVersion = "2.1.1" に変更します。

変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

clean タスク実行 → Rebuild Project 実行 → build タスクを実行すると前とは違いますが、相変わらずエラーが出てコンパイルが通りません。An unhandled exception was thrown by the Error Prone static analysis plugin. というメッセージが出て @Data アノテーションで引っかかっているようです。

f:id:ksby:20171018014059p:plain

もう少しエラーが発生している詳細な原因が知りたいので、コマンドプロンプトから gradlew --stacktrace build コマンドを実行してみます。

f:id:ksby:20171018014639p:plain

結果として上の画像のメッセージが出力されますが、ここで注目するのは com.google.errorprone.bugpatterns のどのクラスで引っかかっているのか、です。今回は NestedInstanceOfConditions で引っかかっています。

NestedInstanceOfConditionsGoogle で検索すると error-prone の Issue で Lombok breaks NestedInstanceOfConditions and InstanceOfAndCastMatchWrongType がヒットします。この中で -Xep:NestedInstanceOfConditions:OFF オプションを追加して無効にするよう記述がありました。

build.gradle を修正し、-Xep:NestedInstanceOfConditions:OFF オプションを追加します。

[compileJava, compileTestGroovy, compileTestJava]*.options*.compilerArgs = ['-Xlint:all,-options,-processing,-path']
compileJava.options.compilerArgs += [
        '-Xep:RemoveUnusedImports:WARN'
        , '-Xep:NestedInstanceOfConditions:OFF'
]

再び clean タスク実行 → Rebuild Project 実行 → build タスクを実行しますが、やっぱりさっきと同じエラーが出ます。

gradlew --stacktrace build コマンドを実行すると今度は InstanceOfAndCastMatchWrongType で引っかかっています。

f:id:ksby:20171018015631p:plain

build.gradle を修正して , '-Xep:InstanceOfAndCastMatchWrongType:OFF' を追加します。

[compileJava, compileTestGroovy, compileTestJava]*.options*.compilerArgs = ['-Xlint:all,-options,-processing,-path']
compileJava.options.compilerArgs += [
        '-Xep:RemoveUnusedImports:WARN'
        , '-Xep:NestedInstanceOfConditions:OFF'
        , '-Xep:InstanceOfAndCastMatchWrongType:OFF'
]

再び clean タスク実行 → Rebuild Project 実行 → build タスクを実行すると、今度は "BUILD SUCCESSFUL" が出力されました。

f:id:ksby:20171018020245p:plain

Project Tool Window で src/test を選択した後、コンテキストメニューを表示して「Run 'All Tests' with Coverage」を選択し、テストが全て成功することも確認しておきます。

f:id:ksby:20171018020618p:plain

履歴

2017/10/18
初版発行。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その27 )( 入力画面2を作成する6 )

概要

記事一覧はこちらです。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その26 )( 入力画面2を作成する5 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • 入力画面2の作成
    • サーバ側のテストを作成します。前回からの続きです。

参照したサイト・書籍

目次

  1. InquiryInputController クラスのテストを変更する
  2. build タスクを実行したら PMD でエラーが出たので pmd-project-rulesets.xml と InquiryInput02FormValidator.java を修正する
  3. 次回は。。。

手順

InquiryInputController クラスのテストを変更する

src/test/groovy/ksbysample/webapp/bootnpmgeb/web/inquiry/InquiryInputControllerTest.groovy の以下の点を変更します。groovy だと SnakeYAML を使わなくてもテストデータを分かりやすく書けることに今更ながら気付いたので、今回は SnakeYAML は使用せずテストデータはテストクラス内に直接記述します。

@RunWith(Enclosed)
class InquiryInputControllerTest {

    ..........

    @RunWith(SpringRunner)
    @SpringBootTest
    static class 入力画面2のテスト {
        private InquiryInput01Form inquiryInput01Form_001 =
                (InquiryInput01Form) new Yaml().load(getClass().getResourceAsStream("InquiryInput01Form_001.yaml"))
        private InquiryInput02Form inquiryInput02Form_001 = new InquiryInput02Form(
                zipcode1: "102"
                , zipcode2: "0072"
                , address: "東京都千代田区飯田橋1-1"
                , tel1: "03"
                , tel2: "1234"
                , tel3: "5678"
                , email: "taro.tanaka@sample.co.jp")

        @Autowired
        private WebApplicationContext context

        MockMvc mockMvc

        @Before
        void setup() {
            mockMvc = MockMvcBuilders.webAppContextSetup(context)
                    .build()
        }

        @Test
        void "初期表示時は画面の項目には何もセットされない"() {
            expect:
            mockMvc.perform(get("/inquiry/input/02"))
                    .andExpect(status().isOk())
                    .andExpect(html("#zipcode1").val(""))
                    .andExpect(html("#zipcode2").val(""))
                    .andExpect(html("#address").val(""))
                    .andExpect(html("#tel1").val(""))
                    .andExpect(html("#tel2").val(""))
                    .andExpect(html("#tel3").val(""))
                    .andExpect(html("#email").val(""))
        }

        @Test
        void "項目全てに入力して前の画面へ戻るボタンをクリックすると入力画面1へ戻り、次へ戻るボタンを押して入力画面2へ戻ると以前入力したデータがセットされて表示される"() {
            when: "入力画面1で項目全てに入力して「次へ」ボタンをクリックする"
            MvcResult result = mockMvc.perform(
                    TestHelper.postForm("/inquiry/input/01?move=next", inquiryInput01Form_001))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrlPattern("**/inquiry/input/02"))
                    .andReturn()
            MockHttpSession session = result.getRequest().getSession()

            and: "入力画面2で項目全てに入力して「前の画面へ戻る」ボタンをクリックする"
            mockMvc.perform(TestHelper.postForm("/inquiry/input/02?move=back", inquiryInput02Form_001)
                    .session(session))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrlPattern("**/inquiry/input/01"))

            and: "入力画面1で「次へ」ボタンをクリックする"
            mockMvc.perform(TestHelper.postForm("/inquiry/input/01?move=next", inquiryInput01Form_001)
                    .session(session))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrlPattern("**/inquiry/input/02"))

            then: "入力画面2が以前入力したデータがセットされて表示される"
            mockMvc.perform(get("/inquiry/input/02").session(session))
                    .andExpect(status().isOk())
                    .andExpect(html("#zipcode1").val(inquiryInput02Form_001.zipcode1))
                    .andExpect(html("#zipcode2").val(inquiryInput02Form_001.zipcode2))
                    .andExpect(html("#address").val(inquiryInput02Form_001.address))
                    .andExpect(html("#tel1").val(inquiryInput02Form_001.tel1))
                    .andExpect(html("#tel2").val(inquiryInput02Form_001.tel2))
                    .andExpect(html("#tel3").val(inquiryInput02Form_001.tel3))
                    .andExpect(html("#email").val(inquiryInput02Form_001.email))
        }

        @Test
        void "項目全てに入力して次へボタンをクリックすると入力画面3へ遷移し、前の画面へ戻るボタンを押して入力画面2へ戻ると以前入力したデータがセットされて表示される"() {
            when: "入力画面2で項目全てに入力して「次へ」ボタンをクリックする"
            MvcResult result = mockMvc.perform(TestHelper.postForm("/inquiry/input/02?move=next", inquiryInput02Form_001))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrlPattern("**/inquiry/input/03"))
                    .andReturn()
            MockHttpSession session = result.getRequest().getSession()

            and: "入力画面3で「前の画面へ戻る」ボタンをクリックする"
            mockMvc.perform(TestHelper.postForm("/inquiry/input/03?move=back", inquiryInput01Form_001)
                    .session(session))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrlPattern("**/inquiry/input/02"))

            then: "入力画面2が以前入力したデータがセットされて表示される"
            mockMvc.perform(get("/inquiry/input/02").session(session))
                    .andExpect(status().isOk())
                    .andExpect(html("#zipcode1").val(inquiryInput02Form_001.zipcode1))
                    .andExpect(html("#zipcode2").val(inquiryInput02Form_001.zipcode2))
                    .andExpect(html("#address").val(inquiryInput02Form_001.address))
                    .andExpect(html("#tel1").val(inquiryInput02Form_001.tel1))
                    .andExpect(html("#tel2").val(inquiryInput02Form_001.tel2))
                    .andExpect(html("#tel3").val(inquiryInput02Form_001.tel3))
                    .andExpect(html("#email").val(inquiryInput02Form_001.email))
        }

        @Test
        void "入力チェックエラーのあるデータで「前の画面へ戻る」ボタンをクリックするとIllegalArgumentExceptionが発生する"() {
            setup: "入力チェックエラーになるデータを用意する"
            inquiryInput02Form_001.zipcode1 = "1"

            expect: "入力画面2の「前の画面へ戻る」ボタンをクリックする"
            Throwable thrown = catchThrowable({
                mockMvc.perform(
                        TestHelper.postForm("/inquiry/input/02?move=back", inquiryInput02Form_001))
                        .andExpect(status().isOk())
            })
            assertThat(thrown.cause).isInstanceOf(IllegalArgumentException)
        }

        @Test
        void "入力チェックエラーのあるデータで「次へ」ボタンをクリックするとIllegalArgumentExceptionが発生する"() {
            setup: "入力チェックエラーになるデータを用意する"
            inquiryInput02Form_001.zipcode1 = "1"

            expect: "入力画面2の「次へ」ボタンをクリックする"
            Throwable thrown = catchThrowable({
                mockMvc.perform(
                        TestHelper.postForm("/inquiry/input/02?move=next", inquiryInput02Form_001))
                        .andExpect(status().isOk())
            })
            assertThat(thrown.cause).isInstanceOf(IllegalArgumentException)
        }

    }

}
  • 入力画面2のテスト クラスを追加します。

テストを実行して全て成功することを確認します。

f:id:ksby:20171015190914p:plain

build タスクを実行したら PMD でエラーが出たので pmd-project-rulesets.xml と InquiryInput02FormValidator.java を修正する

clean タスク実行 → Rebuild Project 実行 → build タスクを実行すると、PMD で何件かメッセージが出ました。

f:id:ksby:20171015194130p:plain

build/reports/pmd/main.xml を見ると以下の3種類の Rule が原因でした。

ただし https://pmd.github.io/pmd-5.8.1/pmd-java/rules/java/codesize.html#ModifiedCyclomaticComplexity を見ると Deprecated の表示がありますね。前に PMD の設定ファイルを作成していた時には気づきませんでした。Code Size の Rules にいくつか Deprecated が表示されていますが pmd-project-rulesets.xml で除外していなかったので、pmd-project-rulesets.xml を修正して除外するようにします。

config/pmd/pmd-project-rulesets.xml の以下の点を変更します。

    <rule ref="rulesets/java/codesize.xml">
        <exclude name="CyclomaticComplexity"/>
        <exclude name="StdCyclomaticComplexity"/>
        <exclude name="ModifiedCyclomaticComplexity"/>
        <exclude name="TooManyMethods"/>
    </rule>
  • 以下の行を追加します。
    • <exclude name="CyclomaticComplexity"/>
    • <exclude name="StdCyclomaticComplexity"/>
    • <exclude name="ModifiedCyclomaticComplexity"/>

また https://pmd.github.io/pmd-5.8.1/pmd-java/rules/java/basic.html#CollapsibleIfStatements の方は src/main/java/ksbysample.webapp.bootnpmgeb/web/inquiry/form/InquiryInput02FormValidator.java の以下の箇所でエラーが出ていたのですが(if 文をネストさせずに && でつなげて1行で書くようにという内容でした)、

            // メールアドレスが入力されていたら入力チェックする
            if (StringUtils.isNotEmpty(email)) {
                if (!EmailValidator.validate(email)) {
                    errors.reject("InquiryInput02Form.email.Invalid");
                }
            }

ここは今の実装のままにしたいので、@SuppressWarnings を指定してメッセージが出ないようにします。

    @SuppressWarnings({"PMD.CollapsibleIfStatements", "PMD.ConfusingTernary"})
    private void checkTelAndEmail(boolean ignoreCheckRequired, String tel1, String tel2, String tel3, String email
            , Errors errors) {
  • @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.ConfusingTernary"})@SuppressWarnings({"PMD.CollapsibleIfStatements", "PMD.ConfusingTernary"}) に変更します。

clean タスク実行 → Rebuild Project 実行 → build タスクを実行すると、今度は "BUILD SUCCESSFUL" のメッセージが出力されました。

f:id:ksby:20171015200528p:plain

次回は。。。

入力画面3の実装に進む前に1~2件書きたいことができたのでそれを書きます(たぶん)。

その後に入力画面1のテストを Geb で作成します。元々今回の記事を書いている理由が Geb のテストの書き方を知りたかったからなんですよね。。。(もうその27ですが)

その後で入力画面3の実装に進みます。

履歴

2017/10/15
初版発行。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その26 )( 入力画面2を作成する5 )

概要

記事一覧はこちらです。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その25 )( 入力画面2を作成する4 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • 入力画面2の作成
    • サーバ側のテストを作成します。長いので2回に分けます。

参照したサイト・書籍

  1. Verify Static Method Call using PowerMockito 1.6
    https://stackoverflow.com/questions/34323909/verify-static-method-call-using-powermockito-1-6

目次

  1. InquiryInput02Form クラスのテストを作成する
  2. InquiryInput02FormNotEmptyRule クラスのテストを作成する
  3. EmailValidator クラスのテストを作成する
  4. InquiryInput02FormValidator クラスのテストを作成する

手順

InquiryInput02Form クラスのテストを作成する

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput02Form.java で Ctrl+Shift+T を押して「Create Test」ダイアログを表示してから、以下の画像の値にした後「OK」ボタンをクリックします。

f:id:ksby:20171014144544p:plain

src/test/groovy/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput02FormTest.groovy が新規作成されるので、以下の内容を記述します。

package ksbysample.webapp.bootnpmgeb.web.inquiry.form

import spock.lang.Specification
import spock.lang.Unroll

import javax.validation.ConstraintViolation
import javax.validation.Validation

class InquiryInput02FormTest extends Specification {

    def validator
    def inquiryInput02Form

    def setup() {
        validator = Validation.buildDefaultValidatorFactory().getValidator()
        inquiryInput02Form = new InquiryInput02Form(
                zipcode1: "102"
                , zipcode2: "0072"
                , address: "東京都千代田区飯田橋1-1"
                , tel1: "03"
                , tel2: "1234"
                , tel3: "5678"
                , email: "taro.tanaka@sample.co.jp")
    }

    def "placeholder で表示している例の Bean Validation のテスト"() {
        when:
        Set<ConstraintViolation<InquiryInput01Form>> constraintViolations =
                validator.validate(inquiryInput02Form)

        then:
        constraintViolations.size() == 0
    }

    @Unroll
    def "zipcode1 の Bean Validation のテスト(#zipcode1 --> #size)"() {
        setup:
        inquiryInput02Form.zipcode1 = zipcode1
        Set<ConstraintViolation<InquiryInput02Form>> constraintViolations =
                validator.validate(inquiryInput02Form)

        expect:
        constraintViolations.size() == size

        where:
        zipcode1 || size
        ""       || 0
        "1"      || 0
        "1" * 3  || 0
        "1" * 4  || 1
    }

    @Unroll
    def "zipcode2 の Bean Validation のテスト(#zipcode2 --> #size)"() {
        setup:
        inquiryInput02Form.zipcode2 = zipcode2
        Set<ConstraintViolation<InquiryInput02Form>> constraintViolations =
                validator.validate(inquiryInput02Form)

        expect:
        constraintViolations.size() == size

        where:
        zipcode2 || size
        ""       || 0
        "1"      || 0
        "1" * 4  || 0
        "1" * 5  || 1
    }

    @Unroll
    def "address の Bean Validation のテスト(#address --> #size)"() {
        setup:
        inquiryInput02Form.address = address
        Set<ConstraintViolation<InquiryInput02Form>> constraintViolations =
                validator.validate(inquiryInput02Form)

        expect:
        constraintViolations.size() == size

        where:
        address   || size
        ""        || 0
        "a"       || 0
        "a" * 256 || 0
        "a" * 257 || 1
    }

    @Unroll
    def "tel1 の Bean Validation のテスト(#tel1 --> #size)"() {
        setup:
        inquiryInput02Form.tel1 = tel1
        Set<ConstraintViolation<InquiryInput02Form>> constraintViolations =
                validator.validate(inquiryInput02Form)

        expect:
        constraintViolations.size() == size

        where:
        tel1    || size
        ""      || 0
        "1"     || 0
        "1" * 5 || 0
        "1" * 6 || 1
    }

    @Unroll
    def "tel2 の Bean Validation のテスト(#tel2 --> #size)"() {
        setup:
        inquiryInput02Form.tel2 = tel2
        Set<ConstraintViolation<InquiryInput02Form>> constraintViolations =
                validator.validate(inquiryInput02Form)

        expect:
        constraintViolations.size() == size

        where:
        tel2    || size
        ""      || 0
        "1"     || 0
        "1" * 4 || 0
        "1" * 5 || 1
    }

    @Unroll
    def "tel3 の Bean Validation のテスト(#tel3 --> #size)"() {
        setup:
        inquiryInput02Form.tel3 = tel3
        Set<ConstraintViolation<InquiryInput02Form>> constraintViolations =
                validator.validate(inquiryInput02Form)

        expect:
        constraintViolations.size() == size

        where:
        tel3    || size
        ""      || 0
        "1"     || 0
        "1" * 4 || 0
        "1" * 5 || 1
    }

    @Unroll
    def "email の Bean Validation のテスト(#email --> #size)"() {
        setup:
        inquiryInput02Form.email = email
        Set<ConstraintViolation<InquiryInput02Form>> constraintViolations =
                validator.validate(inquiryInput02Form)

        expect:
        constraintViolations.size() == size

        where:
        email     || size
        ""        || 0
        "a"       || 0
        "a" * 256 || 0
        "a" * 257 || 1
    }

}

テストを実行して全て成功することを確認します。

f:id:ksby:20171014151810p:plain

InquiryInput02FormNotEmptyRule クラスのテストを作成する

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput02FormNotEmptyRule.java で Ctrl+Shift+T を押して「Create Test」ダイアログを表示してから、以下の画像の値にした後「OK」ボタンをクリックします。

f:id:ksby:20171014150940p:plain

src/test/groovy/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput02FormNotEmptyRuleTest.groovy が新規作成されるので、以下の内容を記述します。

package ksbysample.webapp.bootnpmgeb.web.inquiry.form

import spock.lang.Specification
import spock.lang.Unroll

import javax.validation.ConstraintViolation
import javax.validation.Validation

class InquiryInput02FormNotEmptyRuleTest extends Specification {

    def validator
    def inquiryInput02FormNotEmptyRule

    def setup() {
        validator = Validation.buildDefaultValidatorFactory().getValidator()
        inquiryInput02FormNotEmptyRule = new InquiryInput02FormNotEmptyRule(
                zipcode1: "102"
                , zipcode2: "0072"
                , address: "東京都千代田区飯田橋1-1"
                , tel1: "03"
                , tel2: "1234"
                , tel3: "5678"
                , email: "taro.tanaka@sample.co.jp")
    }

    def "placeholder で表示している例の Bean Validation のテスト"() {
        when:
        Set<ConstraintViolation<InquiryInput02FormNotEmptyRule>> constraintViolations =
                validator.validate(inquiryInput02FormNotEmptyRule)

        then:
        constraintViolations.size() == 0
    }

    @Unroll
    def "zipcode1 の NotEmpty のテスト(#zipcode1 --> #size)"() {
        setup:
        inquiryInput02FormNotEmptyRule.zipcode1 = zipcode1
        Set<ConstraintViolation<InquiryInput02FormNotEmptyRule>> constraintViolations =
                validator.validate(inquiryInput02FormNotEmptyRule)

        expect:
        constraintViolations.size() == size

        where:
        zipcode1 || size
        ""       || 1
        "1"      || 0
    }

    @Unroll
    def "zipcode2 の NotEmpty のテスト(#zipcode2 --> #size)"() {
        setup:
        inquiryInput02FormNotEmptyRule.zipcode2 = zipcode2
        Set<ConstraintViolation<InquiryInput02FormNotEmptyRule>> constraintViolations =
                validator.validate(inquiryInput02FormNotEmptyRule)

        expect:
        constraintViolations.size() == size

        where:
        zipcode2 || size
        ""       || 1
        "1"      || 0
    }

    @Unroll
    def "address の NotEmpty のテスト(#address --> #size)"() {
        setup:
        inquiryInput02FormNotEmptyRule.address = address
        Set<ConstraintViolation<InquiryInput02FormNotEmptyRule>> constraintViolations =
                validator.validate(inquiryInput02FormNotEmptyRule)

        expect:
        constraintViolations.size() == size

        where:
        address || size
        ""      || 1
        "a"     || 0
    }

}

テストを実行して全て成功することを確認します。

f:id:ksby:20171014152525p:plain

EmailValidator クラスのテストを作成する

src/main/java/ksbysample/webapp/bootnpmgeb/util/validator/EmailValidator.java で Ctrl+Shift+T を押して「Create Test」ダイアロ グを表示してから、以下の画像の値にした後「OK」ボタンをクリックします。

f:id:ksby:20171014185311p:plain

src/test/groovy/ksbysample/webapp/bootnpmgeb/util/validator/EmailValidatorTest.groovy が新規作成されるので、以下の内容を記述します。

package ksbysample.webapp.bootnpmgeb.util.validator

import spock.lang.Specification
import spock.lang.Unroll

class EmailValidatorTest extends Specification {

    @Unroll
    def "メールアドレスの Validation のテスト(#email --> #result)"() {
        expect:
        EmailValidator.validate(email) == result

        where:
        email                                                                   || result
        ""                                                                      || true
        "@"                                                                     || false
        "a@"                                                                    || false
        "@b"                                                                    || false
        "a@b"                                                                   || true
        "@@"                                                                    || false
        "a@@b"                                                                  || false
        "a@b@c"                                                                 || false
        // ASCII文字だけなので OK
        "taro.tanaka@sample.co.jp"                                              || true
        "1234567890@1234567890"                                                 || true
        "ABCDEFGHOJKLMNOPQRSTUVWXYZ@ABCDEFGHOJKLMNOPQRSTUVWXYZ"                 || true
        "abcdefghojklmnopqrstuvwxyz@abcdefghojklmnopqrstuvwxyz"                 || true
        "!\"#\$%&'()*+,-./:;<=>?[\\]^_`{|}~@!\"#\$%&'()*+,-./:;<=>?[\\]^_`{|}~" || true
        // スペースがあるので NG
        "taro tanaka@sample.co.jp"                                              || false
        "taro.tanaka@sample co.jp"                                              || false
        // 非ASCII文字があるので NG
        "田中太郎@sample.co.jp"                                                     || false
        "taro.tanaka@サンプル co.jp"                                                || false
    }

}

テストを実行して全て成功することを確認します。

f:id:ksby:20171014192308p:plain

InquiryInput02FormValidator クラスのテストを作成する

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput02FormValidator.java で Ctrl+Shift+T を押して「Create Test」ダイアログを表示してから、以下の画像の値にした後「OK」ボタンをクリックします。

f:id:ksby:20171014153257p:plain

src/test/groovy/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput02FormValidatorTest.groovy が新規作成されるので、以下の内容を記述します。メールアドレスの入力チェックについては

package ksbysample.webapp.bootnpmgeb.web.inquiry.form

import ksbysample.common.test.helper.TestHelper
import ksbysample.webapp.bootnpmgeb.util.validator.EmailValidator
import org.junit.Before
import org.junit.Test
import org.junit.experimental.runners.Enclosed
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.powermock.api.mockito.PowerMockito
import org.powermock.core.classloader.annotations.PowerMockIgnore
import org.powermock.core.classloader.annotations.PrepareForTest
import org.powermock.modules.junit4.PowerMockRunner
import org.powermock.modules.junit4.PowerMockRunnerDelegate
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit4.SpringRunner
import org.springframework.validation.Errors
import spock.lang.Specification
import spock.lang.Unroll

@RunWith(Enclosed)
class InquiryInput02FormValidatorTest {

    @SpringBootTest
    static class InquiryInput02FormValidator_メールアドレス以外 extends Specification {

        @Autowired
        private InquiryInput02FormValidator input02FormValidator

        Errors errors
        InquiryInput02Form inquiryInput02Form

        def setup() {
            errors = TestHelper.createErrors()
            inquiryInput02Form = new InquiryInput02Form(
                    zipcode1: "102"
                    , zipcode2: "0072"
                    , address: "東京都千代田区飯田橋1-1"
                    , tel1: "03"
                    , tel2: "1234"
                    , tel3: "5678"
                    , email: "taro.tanaka@sample.co.jp")
        }

        def "placeholder で表示している例の Bean Validation のテスト"() {
            setup:
            input02FormValidator.validate(inquiryInput02Form, errors)

            expect:
            errors.hasErrors() == false
        }

        @Unroll
        def "郵便番号の Validation のテスト(#zipcode1,#zipcode2 --> #hasErrors,#size)"() {
            setup:
            inquiryInput02Form.zipcode1 = zipcode1
            inquiryInput02Form.zipcode2 = zipcode2

            expect:
            input02FormValidator.validate(inquiryInput02Form, errors)
            errors.hasErrors() == hasErrors
            errors.getAllErrors().size() == size

            where:
            zipcode1 | zipcode2 || hasErrors | size
            ""       | ""       || false     | 0
            "999"    | ""       || true      | 1
            ""       | "9999"   || true      | 1
            "999"    | "9999"   || false     | 0
        }

        @Unroll
        def "電話番号の Validation のテスト(#tel1,#tel2,#tel3 --> #hasErrors,#size)"() {
            given:
            inquiryInput02Form.tel1 = tel1
            inquiryInput02Form.tel2 = tel2
            inquiryInput02Form.tel3 = tel3
            inquiryInput02Form.email = email

            when:
            input02FormValidator.validate(inquiryInput02Form, errors)

            then:
            errors.hasErrors() == hasErrors
            errors.getAllErrors().size() == size
            // メールアドレスのチェックは行われていないことをチェックする
            0 * EmailValidator.validate(email)

            where:
            tel1    | tel2   | tel3   | email || hasErrors | size
            ""      | ""     | ""     | ""    || true      | 1
            "03"    | ""     | ""     | ""    || true      | 1
            ""      | "1234" | ""     | ""    || true      | 1
            ""      | ""     | "5678" | ""    || true      | 1
            "03"    | "1234" | "5678" | ""    || false     | 0
            "3"     | "1234" | "5678" | ""    || true      | 1
            "03"    | "123"  | "5678" | ""    || true      | 1
            "03123" | "4"    | "5678" | ""    || false     | 0
            "03"    | "1234" | "567"  | ""    || true      | 1
        }

    }

    @RunWith(PowerMockRunner)
    @PowerMockRunnerDelegate(SpringRunner)
    @SpringBootTest
    @PrepareForTest(EmailValidator)
    @PowerMockIgnore("javax.management.*")
    static class InquiryInput02FormValidator_メールアドレス {

        @Autowired
        private InquiryInput02FormValidator input02FormValidator

        Errors errors
        InquiryInput02Form inquiryInput02Form

        @Before
        void setup() {
            errors = TestHelper.createErrors()
            inquiryInput02Form = new InquiryInput02Form(
                    zipcode1: "102"
                    , zipcode2: "0072"
                    , address: "東京都千代田区飯田橋1-1"
                    , tel1: ""
                    , tel2: ""
                    , tel3: ""
                    , email: "taro.tanaka@sample.co.jp")
        }

        @Test
        void "メールアドレスの Validation のテスト"() {
            when: "EmailValidator.validate が true を返すように設定してテストする"
            PowerMockito.mockStatic(EmailValidator)
            PowerMockito.when(EmailValidator.validate(Mockito.any())) thenReturn(true)
            input02FormValidator.validate(inquiryInput02Form, errors)

            then: "入力チェックエラーは発生しない"
            assert errors.hasErrors() == false
            assert errors.getAllErrors().size() == 0
            // EmailValidator.validate が呼び出されていることをチェックする
            PowerMockito.verifyStatic(Mockito.times(1))
            EmailValidator.validate("taro.tanaka@sample.co.jp")

            and: "EmailValidator.validate が false を返すように設定してテストする"
            PowerMockito.when(EmailValidator.validate(Mockito.any())) thenReturn(false)
            input02FormValidator.validate(inquiryInput02Form, errors)

            then: "入力チェックエラーが発生する"
            assert errors.hasErrors() == true
            assert errors.getAllErrors().size() == 1
        }

    }

}

テストを実行して全て成功することを確認します。。。が、1つ失敗しました。電話番号、メールアドレスが全て空の場合に入力チェックが OK になってしまうようです。

f:id:ksby:20171015070055p:plain

src/main/assets/js/inquiry/input02.js と src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput02FormValidator.java で最初の方の処理が一致していないことが原因でした。InquiryInput02FormValidator クラスの方も ignoreCheckRequired のようなものを入れる必要がありますね。。。

    var validateFunction = function () {
        if (validator.ignoreCheckRequired && form.isAllEmpty(idList)) {
            return;
        }

        if (form.isAllEmpty(idList)) {
            var errmsg = "電話番号とメールアドレスのいずれか一方を入力してください";
            form.setError(telIdFormGroup, errmsg);
            form.setError(emailIdFormGroup, errmsg);
            throw new Error(errmsg);
        } else {
        if (StringUtils.isEmpty(tel1)
                && StringUtils.isEmpty(tel2)
                && StringUtils.isEmpty(tel3)
                && StringUtils.isEmpty(email)) {
            return;
        }

        if (StringUtils.isEmpty(tel1 + tel2 + tel3)
                && StringUtils.isEmpty(email)) {
            errors.reject("InquiryInput02Form.telOrEmail.NotEmpty");
        } else {

以下の内容で修正することにします。

  • input02.html に <input type="hidden" name="ignoreCheckRequired" ... /> を追加します。
  • input02 で必須チェック不要な時には <input type="hidden" name="ignoreCheckRequired" ... /> にも true をセットします。
  • InquiryInput02Form クラスに ignoreCheckRequired を用意します。
  • InquiryInput02FormValidator で InquiryInput02Form.ignoreCheckRequired == true なら一番最初の必須チェックは行わないようにします。

src/main/resources/templates/web/inquiry/input02.html の以下の点を変更します。

          <!--/*@thymesVar id="inquiryInput02Form" type="ksbysample.webapp.bootnpmgeb.web.inquiry.form.InquiryInput02Form"*/-->
          <form id="inquiryInput02Form" class="form-horizontal" method="post" action=""
                th:action="@{/inquiry/input/02/}"
                th:object="${inquiryInput02Form}">
            <input type="hidden" name="copiedFromSession" id="copiedFromSession" th:value="*{copiedFromSession}"/>
            <input type="hidden" name="ignoreCheckRequired" id="ignoreCheckRequired" value="false"/>
  • <input type="hidden" name="ignoreCheckRequired" id="ignoreCheckRequired" value="false"/> を追加します。value は固定で false にします。

src/main/assets/js/inquiry/input02.js の以下の点を変更します。

var btnBackOrNextClickHandler = function (event, url, ignoreCheckRequired) {
    ..........

    // サーバにリクエストを送信する
    $("#ignoreCheckRequired").val(ignoreCheckRequired);
    $("#inquiryInput02Form").attr("action", url);
    $("#inquiryInput02Form").submit();

    // return false は
    // event.preventDefault() + event.stopPropagation() らしい
    return false;
};
  • btnBackOrNextClickHandler 関数の以下の点を変更します。
    • $("#ignoreCheckRequired").val(ignoreCheckRequired); を追加します。

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput02Form.java の以下の点を変更します。

@Data
public class InquiryInput02Form implements Serializable {

    ..........

    private boolean ignoreCheckRequired = false;

}
  • private boolean ignoreCheckRequired = false; を追加します。

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput02FormValidator.java の以下の点を変更します。

@Component
public class InquiryInput02FormValidator implements Validator {

    ..........

    @Override
    public void validate(Object target, Errors errors) {
        ..........

        checkTelAndEmail(inquiryInput02Form.isIgnoreCheckRequired()
                , inquiryInput02Form.getTel1()
                , inquiryInput02Form.getTel2()
                , inquiryInput02Form.getTel3()
                , inquiryInput02Form.getEmail()
                , errors);
    }

    ..........

    @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.ConfusingTernary"})
    private void checkTelAndEmail(boolean ignoreCheckRequired, String tel1, String tel2, String tel3, String email, Errors errors) {
        if (ignoreCheckRequired
                && StringUtils.isEmpty(tel1)
                && StringUtils.isEmpty(tel2)
                && StringUtils.isEmpty(tel3)
                && StringUtils.isEmpty(email)) {
            return;
        }

        ..........
    }

}
  • checkTelAndEmail メソッドの以下の点を変更します。
    • 引数に boolean ignoreCheckRequired を追加します。
    • 最初の if 文に ignoreCheckRequired の条件を追加します。
  • validate メソッド内で checkTelAndEmail メソッドを呼び出している部分の引数に inquiryInput02Form.isIgnoreCheckRequired() を追加します。

画面を動かして動作に問題がないことを確認した後(画面キャプチャは省略します)、src/test/groovy/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput02FormValidatorTest.groovy の以下の点を変更します。

    @SpringBootTest
    static class InquiryInput02FormValidator_メールアドレス以外 extends Specification {

        ..........

        @Unroll
        def "電話番号の Validation のテスト(#tel1,#tel2,#tel3,#ignoreCheckRequired --> #hasErrors,#size)"() {
            given:
            inquiryInput02Form.tel1 = tel1
            inquiryInput02Form.tel2 = tel2
            inquiryInput02Form.tel3 = tel3
            inquiryInput02Form.email = email
            inquiryInput02Form.ignoreCheckRequired = ignoreCheckRequired

            when:
            input02FormValidator.validate(inquiryInput02Form, errors)

            then:
            errors.hasErrors() == hasErrors
            errors.getAllErrors().size() == size
            // メールアドレスのチェックは行われていないことをチェックする
            0 * EmailValidator.validate(email)

            where:
            tel1    | tel2   | tel3   | email | ignoreCheckRequired || hasErrors | size
            ""      | ""     | ""     | ""    | false               || true      | 1
            ""      | ""     | ""     | ""    | true                || false     | 0
            "03"    | ""     | ""     | ""    | false               || true      | 1
            ""      | "1234" | ""     | ""    | false               || true      | 1
            ""      | ""     | "5678" | ""    | false               || true      | 1
            "03"    | "1234" | "5678" | ""    | false               || false     | 0
            "3"     | "1234" | "5678" | ""    | false               || true      | 1
            "03"    | "123"  | "5678" | ""    | false               || true      | 1
            "03123" | "4"    | "5678" | ""    | false               || false     | 0
            "03"    | "1234" | "567"  | ""    | false               || true      | 1
        }

    }
  • InquiryInput02FormValidator_メールアドレス以外 クラスの以下の点を変更します。
    • 電話番号の Validation のテスト メソッドのメソッド名に ,#ignoreCheckRequired を追加します。
    • given: のところで、inquiryInput02Form.ignoreCheckRequired = ignoreCheckRequired を追加します。
    • where:ignoreCheckRequired を追加しテスト用の値も記述します。

テストを実行すると今度は全て成功しました。

f:id:ksby:20171015164144p:plain

履歴

2017/10/15
初版発行。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その25 )( 入力画面2を作成する4 )

概要

記事一覧はこちらです。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その24 )( 入力画面2を作成する3 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • 入力画面2の作成
    • 郵便番号検索API を利用して、「郵便番号」を入力したら「住所」に都道府県・市区町村・町丁目名がセットされる処理を実装します。

参照したサイト・書籍

  1. Autocomplete | jQuery UI
    https://jqueryui.com/autocomplete/

  2. jquery-ui
    https://www.npmjs.com/package/jquery-ui

  3. [jQuery UI] AutocompleteのソースをAjaxで取得する
    http://www.84kure.com/blog/2015/07/27/jquery-ui-autocompleteのソースをajaxで取得する/

  4. 1分でわかるjQuery $.ajaxによるJSONJSONP読み込み方法
    https://iwb.jp/jquery-ajax-jsonp/

  5. Display jquery ui auto-complete list on focus event
    https://stackoverflow.com/questions/4132058/display-jquery-ui-auto-complete-list-on-focus-event

  6. AJAX通信をするときはタイムアウト処理を必ず入れてほしい(切実)
    https://qiita.com/tonkotsuboy_com/items/d1b3cf45ae5135441f9b

  7. How to include jQuery UI into my project using Webpack?
    https://stackoverflow.com/questions/38796673/how-to-include-jquery-ui-into-my-project-using-webpack

目次

  1. npm で jquery-ui をインストールする
  2. 入力された郵便番号の住所一覧を jQuery-UI の autocomplete で表示・選択する処理を実装する
  3. 動作確認
  4. 次回は。。。

手順

npm で jquery-ui をインストールする

郵便番号を入力したら jQuery-UI の autocomplete で「住所」の入力項目の下にドロップダウンリストを表示して選択できるようにします。

admin-lte パッケージ内の plugins/jQueryUI の下には js ファイルしかないのですが、autocomplete の表示には css ファイルも必要なので npm で jquery-ui パッケージをインストールします。

npm install --save jquery-ui コマンドを実行して jQueryUI をインストールします。

f:id:ksby:20171009195323p:plain

package.jsonjquery-ui の css をコピーする npm-scirpts を追加します。

  "scripts": {
    ..........
    "copy:all": "run-p copy:bootstrap copy:admin-lte copy:font-awesome copy:ionicons copy:jquery-ui-css",
    ..........
    "copy:ionicons": "cpx node_modules/ionicons/dist/{css,fonts}/**/* src/main/resources/static/vendor/ionicons",
    "copy:jquery-ui-css": "cpx node_modules/jquery-ui/themes/base/**/* src/main/resources/static/vendor/jquery-ui/css",
    ..........
  },
  • "copy:jquery-ui-css": "cpx node_modules/jquery-ui/themes/base/**/* src/main/resources/static/vendor/jquery-ui/css" を追加します。
  • copy:all の最後に copy:jquery-ui-css を追加します。

npm run copy:jquery-ui-css コマンドを実行して css ファイルをコピーします。

f:id:ksby:20171009203041p:plain f:id:ksby:20171009203214p:plain

入力された郵便番号の住所一覧を jQuery-UI の autocomplete で表示・選択する処理を実装する

src/main/assets/js/inquiry/input02.js を以下のように変更します。

"use strict";

var Form = require("lib/class/Form.js");
var converter = require("lib/util/converter.js");
var validator = require("lib/util/validator.js");
require("jquery-ui/ui/widgets/autocomplete.js");

..........

var addressList = [];
var findAddressListByZipCode = function (event) {
    // 入力チェックエラーが発生している場合には処理を中断する
    if (event.isPropagationStopped()) {
        return;
    }

    // 郵便番号の入力項目全てにフォーカスがセットされていなければ処理を中断する
    if (!form.isAllFocused(form, ["#zipcode1", "#zipcode2"])) {
        return;
    }

    // 郵便番号検索APIで住所を検索する
    addressList = [];
    $.ajax({
        type: "get",
        url: "http://zipcloud.ibsnet.co.jp/api/search",
        data: {zipcode: $("#zipcode1").val() + $("#zipcode2").val()},
        cache: false,
        dataType: "jsonp",
        jsonpCallback: "callback",
        timeout: 5000
    }).then(
        function (json) {
            if (json.status === 200) {
                if (json.results === null) {
                    form.setError("#form-group-address", "郵便番号に該当する住所はありませんでした");
                    return;
                }

                json.results.forEach(function (result) {
                    addressList.push(result.address1 + result.address2 + result.address3);
                });
                // 「住所」の入力項目の下に autocomplete のドロップダウンリストを表示する
                if ($("#address").is(":focus")) {
                    $("#address").autocomplete("search");
                }
            } else {
                form.setError("#form-group-address", json.message);
            }
        },
        function () {
            form.setError("#form-group-address", "郵便番号APIの呼び出しに失敗しました");
        }
    );
};

..........

$(document).ready(function () {
    // 入力チェック用の validator 関数をセットする
    ..........

    // 入力された郵便番号から住所を取得する処理をセットする
    $("#address").on("focus", findAddressListByZipCode);
    $("#address").autocomplete({
        // source には addressList 変数を直接指定するのではなく、
        // 関数を渡して findAddressListByZipCode 関数で変更された addressList
        // のデータが表示されるようにする
        source: function (request, response) {
            // 「住所」に何も入力されていない時だけドロップダウンリストを表示する
            if ($("#address").val() === "") {
                response(addressList);
            } else {
                response([]);
            }
        },
        // minLength: 0 を指定しないと findAddressListByZipCode 関数内で
        // $("#address").autocomplete("search"); を呼び出した時にドロップダウンリスト
        // が表示されない
        minLength: 0
    });

    ..........
});
  • require("jquery-ui/ui/widgets/autocomplete.js"); を追加します。
  • var addressList = []; を追加します。
  • findAddressListByZipCode 関数を追加します。
  • $(document).ready(function () { ... } の以下の点を変更します。
    • ["#zipcode1", "#zipcode2"].forEach(function (id) { $(id).on("blur", findAddressListByZipCode); }); を追加します。
    • $("#address").autocomplete({ ... }); を追加します。

src/main/resources/templates/web/inquiry/input02.html の以下の点を変更します。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{web/common/fragments :: common_header(~{::title}, ~{::link}, ~{::style})}">
  <title>入力フォーム - 入力画面2</title>

  <link rel="stylesheet" href="/vendor/jquery-ui/css/all.css">
</head>

..........
  • <link rel="stylesheet" href="/vendor/jquery-ui/css/all.css"> を追加します。

ここまでの実装で npm run springboot コマンドを実行すると、Module not found: Error: Can't resolve 'jquery' in ... というエラーメッセージが大量に出力されます。

f:id:ksby:20171009231015p:plain

webpack.config.js で jquery の alias を定義すればこのエラーは出なくなります。以下のように変更します。

    resolve: {
        modules: [
            "node_modules",
            "src/main/assets/js"
        ],
        alias: {
            jquery: "admin-lte/plugins/jQuery/jquery-2.2.3.min.js"
        }
    },
  • alias: { jquery: "admin-lte/plugins/jQuery/jquery-2.2.3.min.js" } を追加します。

再び npm run springboot コマンドを実行すると今度はエラーが出ません。

f:id:ksby:20171009231807p:plain

動作確認

動作確認します。npm run springboot コマンドは実行済なので、Tomcat を起動した後、ブラウザで http://localhost:9080/inquiry/input/01/ にアクセスします。

データを入力してから、「次へ」ボタンをクリックして入力画面2へ遷移します。

f:id:ksby:20171009232944p:plain

「郵便番号」に 079-0177 を入力して「住所」にフォーカスを移動すると、フォーカスが移動した直後はドロップダウンリストは表示されませんが、郵便番号検索APIの検索が終了するとドロップダウンリストが表示されます。

f:id:ksby:20171009233117p:plain

上下矢印キーで選択すると、選択された項目が「住所」の入力項目にセットされます。

f:id:ksby:20171009233516p:plain

選択してから追加で "1丁目" と入力しても特に動作には問題はありません。

f:id:ksby:20171010000114p:plain

存在しない郵便番号を入力すると「住所」の入力項目の下に "郵便番号に該当する住所はありませんでした" のメッセージが表示されます。

f:id:ksby:20171009235701p:plain

次回は。。。

サーバ側のテストを書きます。

履歴

2017/10/10
初版発行。

IntelliJ IDEA を 2017.2.4 → 2017.2.5 へ、Git for Windows を 2.14.1 → 2.14.2(2) へバージョンアップ

IntelliJ IDEA を 2017.2.4 → 2017.2.5 へバージョンアップする

IntelliJ IDEA の 2017.2.5 がリリースされたのでバージョンアップします。

※ksbysample-webapp-lending プロジェクトを開いた状態でバージョンアップしています。

  1. IntelliJ IDEA のメインメニューから「Help」-「Check for Updates...」を選択します。

  2. IDE and Plugin Updates」ダイアログが表示されます。左下に「Update and Restart」ボタンが表示されていますので、「Update and Restart」ボタンをクリックします。

    f:id:ksby:20171008072409p:plain

  3. Plugin の update も表示されました。「Error-prone Compiler Integration」はバージョンアップすると動かなくなりますので、これだけチェックを外して「Update and Restart」ボタンをクリックします。

    f:id:ksby:20171008072519p:plain

  4. Patch がダウンロードされて IntelliJ IDEA が再起動します。

  5. IntelliJ IDEA が起動すると画面下部に「Indexing…」のメッセージが表示されます。。。と思いましたが、今回は出ませんでした。

  6. IntelliJ IDEA のメインメニューから「Help」-「About」を選択し、2017.2.5 へバージョンアップされていることを確認します。

  7. Gradle Tool Window のツリーを見ると「Tasks」の下に「other」しかない状態になっているので、左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

    f:id:ksby:20171008073252p:plain

  8. clean タスク実行 → Rebuild Project 実行 → build タスクを実行して、"BUILD SUCCESSFUL" のメッセージが出力されることを確認します。

    f:id:ksby:20171008074304p:plain

  9. Project Tool Window で src/test を選択した後、コンテキストメニューを表示して「Run 'All Tests' with Coverage」を選択し、テストが全て成功することを確認します。

f:id:ksby:20171008074732p:plain

Git for Windows を 2.14.1 → 2.14.2(2) へバージョンアップする

Git for Windows の 2.14.2(2) がリリースされていたのでバージョンアップします。

  1. https://git-for-windows.github.io/ の「Download」ボタンをクリックして Git-2.14.2.2-64-bit.exe をダウンロードします。

  2. Git-2.14.2.2-64-bit.exe を実行します。

  3. 「Git 2.14.2.2 Setup」ダイアログが表示されます。[Next >]ボタンをクリックします。

  4. 「Select Components」画面が表示されます。「Git LFS(Large File Support)」だけチェックした状態で [Next >]ボタンをクリックします。

  5. 「Adjusting your PATH environment」画面が表示されます。中央の「Use Git from the Windows Command Prompt」が選択されていることを確認後、[Next >]ボタンをクリックします。

  6. 「Choosing HTTPS transport backend」画面が表示されます。「Use the OpenSSL library」が選択されていることを確認後、[Next >]ボタンをクリックします。

  7. 「Configuring the line ending conversions」画面が表示されます。一番上の「Checkout Windows-style, commit Unix-style line endings」が選択されていることを確認した後、[Next >]ボタンをクリックします。

  8. 「Configuring the terminal emulator to use with Git Bash」画面が表示されます。「Use Windows'default console window」が選択されていることを確認した後、[Next >]ボタンをクリックします。

  9. 「Configuring extra options」画面が表示されます。「Enable file system caching」だけがチェックされていることを確認した後、[Install]ボタンをクリックします。

  10. インストールが完了すると「Completing the Git Setup Wizard」のメッセージが表示された画面が表示されます。中央の「View Release Notes」のチェックを外した後、「Finish」ボタンをクリックしてインストーラーを終了します。

  11. コマンドプロンプトを起動して git --version を実行し、git のバージョンが git version 2.14.2.windows.2 になっていることを確認します。

    f:id:ksby:20171008075447p:plain

  12. git-cmd.exe を起動して日本語の表示・入力が問題ないかを確認します。

    f:id:ksby:20171008075629p:plain

  13. 特に問題はないようですので、2.14.2(2) で作業を進めたいと思います。