かんがるーさんの日記

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

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) {
            // 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());
    }

}

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

f:id:ksby:20171021200144p:plain

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

    @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();
                // 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 をコピーするのに Dozar もありますが、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.4.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) で作業を進めたいと思います。

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

概要

記事一覧はこちらです。

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

  • 今回の手順で確認できるのは以下の内容です。
    • 入力画面2の作成
    • サーバ側の処理を実装します。

参照したサイト・書籍

目次

  1. 電話番号に必須チェックを追加する
  2. Form クラスを作成する
  3. FormValidator クラスを作成する
  4. input02.html, input02.js を変更する
  5. SessionData クラスを変更する
  6. 画面表示時と「前の画面へ戻る」「次へ」ボタンクリック時の処理を実装する
  7. 動作確認
  8. 次回は。。。

手順

電話番号に必須チェックを追加する

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その23 )( 入力画面2を作成する2 ) で checkRequired 関数以外は値が空の場合に入力チェックを行わないように変更したのですが、電話番号の市外局番+市内局番だけ入力しても入力チェックOKになる等の問題があることに気づきました。

f:id:ksby:20171007071819p:plain

電話番号のいずれかが入力されている場合には、市外局番、市内局番、加入者番号で必須チェックを行うようにします。

ignoreCheckRequired === true の時でも必須チェックする関数が欲しいので、src/main/assets/js/lib/util/validator.js を以下のように変更します。

    /**
     * 必須チェック用関数 (ignoreCheckRequired によるスキップ機能あり)
     * @param {Form} form - Form オブジェクト
     * @param {string} idFormGroup - Validation の SUCCESS/ERROR の結果を反映する要素の id
     * @param {Array} idList - チェックを行う要素の id の配列
     * @param {string} errmsg - チェックエラー時に表示するエラーメッセージ
     */
    checkRequired: function (form, idFormGroup, idList, errmsg) {
        if (this.ignoreCheckRequired === true) return;
        this.forceCheckRequired(form, idFormGroup, idList, errmsg);
    },

    /**
     * 必須チェック用関数
     * @param {Form} form - Form オブジェクト
     * @param {string} idFormGroup - Validation の SUCCESS/ERROR の結果を反映する要素の id
     * @param {Array} idList - チェックを行う要素の id の配列
     * @param {string} errmsg - チェックエラー時に表示するエラーメッセージ
     */
    forceCheckRequired: function (form, idFormGroup, idList, errmsg) {
        var isValid = !form.isAnyEmpty(idList);
        setSuccessOrError(form, idFormGroup, errmsg, isValid);
    },
  • forceCheckRequired 関数を追加します。
  • checkRequired 関数内の必須チェック処理を forceCheckRequired 関数を呼び出すように変更します。

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

            // 「電話番号」に1つでも値が入力されていたら入力チェックする
            if (form.isAnyNotEmpty(telIdList)) {
                try {
                    validator.forceCheckRequired(form, telIdFormGroup, telIdList,
                        "市外局番、市内局番、加入者番号は全て入力してください");
                    validator.checkRegexp(form, telIdFormGroup, ["#tel1"],
                        "^0", "市外局番の先頭には 0 の数字を入力してください");
                    validator.checkRegexp(form, telIdFormGroup, ["#tel1", "#tel2"],
                        "^[0-9]{6}$", "市外局番+市内局番の組み合わせが数字6桁になるように入力してください");
                    validator.checkRegexp(form, telIdFormGroup, ["#tel3"],
                        "^[0-9]{4}$", "加入者番号には4桁の数字を入力してください");
                } catch (e) {
                    errmsg = e.message;
                }
            }
  • telAndEmailValidator 関数の中で定義している validateFunction 関数内に validator.forceCheckRequired(form, telIdFormGroup, telIdList, "市外局番、市内局番、加入者番号は全て入力してください"); を追加します。

Form クラスを作成する

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form の下に InquiryInput02Form.java を新規作成し、以下の内容を記述します。

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


import lombok.Data;

import javax.validation.constraints.Size;
import java.io.Serializable;

/**
 * 入力画面2用 Form クラス
 */
@Data
public class InquiryInput02Form implements Serializable {

    private static final long serialVersionUID = -2484970766971811218L;

    @Size(max = 3)
    private String zipcode1;

    @Size(max = 4)
    private String zipcode2;

    @Size(max = 256)
    private String address;

    @Size(max = 5)
    private String tel1;

    @Size(max = 4)
    private String tel2;

    @Size(max = 4)
    private String tel3;

    @Size(max = 256)
    private String email;

    private boolean copiedFromSession = false;

}

必須チェックだけ実行するためのクラスも作ります。src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form の下に InquiryInput02FormNotEmptyRule.java を新規作成し、以下の内容を記述します。

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


import lombok.Data;
import org.hibernate.validator.constraints.NotEmpty;

/**
 * 入力画面2 必須チェック用クラス
 */
@Data
public class InquiryInput02FormNotEmptyRule {

    private static final long serialVersionUID = -2484970766971811218L;

    @NotEmpty
    private String zipcode1;

    @NotEmpty
    private String zipcode2;

    @NotEmpty
    private String address;

    private String tel1;

    private String tel2;

    private String tel3;

    private String email;

    private boolean copiedFromSession = false;

}

FormValidator クラスを作成する

最初にメールアドレスチェック用のクラスを作成します。src/main/java/ksbysample/webapp/bootnpmgeb/util の下に validator パッケージを新規作成します。

src/main/java/ksbysample/webapp/bootnpmgeb/validator の下に EmailValidator.java を新規作成し、以下の内容を記述します。

package ksbysample.webapp.bootnpmgeb.util.validator;

import org.apache.commons.lang3.StringUtils;

import java.util.Arrays;
import java.util.regex.Pattern;

/**
 * メールアドレスチェック用 Util クラス
 */
public class EmailValidator {

    private static final Pattern PATTERN_EMAIL = Pattern.compile("^[\\x21-\\x7E]+$");

    /**
     * メールアドレスが正しいフォーマットかチェックする
     *
     * @param email チェックするメールアドレス
     * @return チェック結果 true:OK, false:NG
     */
    public static boolean validate(String email) {
        // 値が入力されていなければチェックしない
        if (StringUtils.isEmpty(email)) {
            return true;
        }

        // @で分割して要素数が2つかどうかチェックする
        String[] elements = email.split("@");
        if (elements.length != 2) {
            return false;
        }

        // 1つ目及び2つ目の要素に空白、制御文字、非ASCII文字が含まれていないかチェックする
        return Arrays.stream(elements)
                .map(e -> PATTERN_EMAIL.matcher(e).matches())
                .reduce(true, (prs, prv) -> prs && prv);
    }

}

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form の下に InquiryInput02FormValidator.java を新規作成し、以下の内容を記述します。

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

import ksbysample.webapp.bootnpmgeb.util.validator.EmailValidator;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

import java.util.regex.Pattern;

/**
 * 入力画面2入力チェック用クラス
 */
@Component
public class InquiryInput02FormValidator implements Validator {

    private static final Pattern PATTERN_ZIPCODE = Pattern.compile("^[0-9]{7}$");
    private static final Pattern PATTERN_TEL1 = Pattern.compile("^0.*");
    private static final Pattern PATTERN_TEL1_TEL2 = Pattern.compile("^[0-9]{6}$");
    private static final Pattern PATTERN_TEL3 = Pattern.compile("^[0-9]{4}$");

    @Override
    public boolean supports(Class<?> clazz) {
        return clazz.equals(InquiryInput02Form.class);
    }

    @Override
    public void validate(Object target, Errors errors) {
        InquiryInput02Form inquiryInput02Form = (InquiryInput02Form) target;

        checkZipcode(inquiryInput02Form.getZipcode1(), inquiryInput02Form.getZipcode2(), errors);

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

    private void checkZipcode(String zipcode1, String zipcode2, Errors errors) {
        if (StringUtils.isEmpty(zipcode1) && StringUtils.isEmpty(zipcode2)) {
            return;
        }

        if (!PATTERN_ZIPCODE.matcher(zipcode1 + zipcode2)
                .matches()) {
            errors.reject("InquiryInput02Form.zipcode.UnmatchPattern");
        }
    }

    @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.ConfusingTernary"})
    private void checkTelAndEmail(String tel1, String tel2, String tel3, String email, Errors errors) {
        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 {
            // 電話番号に1つでも入力されていたら入力チェックする
            if (StringUtils.isNoneEmpty(tel1)
                    || StringUtils.isNotEmpty(tel2)
                    || StringUtils.isNotEmpty(tel3)) {
                if (StringUtils.isEmpty(tel1)
                        || StringUtils.isEmpty(tel2)
                        || StringUtils.isEmpty(tel3)) {
                    errors.reject("InquiryInput02Form.tel1AndTel2AndTel3.AnyEmpty");
                } else if (!PATTERN_TEL1.matcher(tel1).matches()) {
                    errors.reject("InquiryInput02Form.tel1.UnmatchPattern");
                } else if (!PATTERN_TEL1_TEL2.matcher(tel1 + tel2).matches()) {
                    errors.reject("InquiryInput02Form.tel1AndTel2.UnmatchPattern");
                } else if (!PATTERN_TEL3.matcher(tel3).matches()) {
                    errors.reject("InquiryInput02Form.tel3.UnmatchPattern");
                }
            }

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

src/main/resources/messages_ja_JP.properties に以下の記述を追加します。

InquiryInput02Form.zipcode.UnmatchPattern=郵便番号が数字7桁ではありません。
InquiryInput02Form.telOrEmail.NotEmpty=郵便番号が数字7桁ではありません。
InquiryInput02Form.tel1AndTel2AndTel3.AnyEmpty=市外局番、市内局番、加入者番号は全て入力してください。
InquiryInput02Form.tel1.UnmatchPattern=市外局番の先頭には 0 の数字を入力してください。
InquiryInput02Form.tel1AndTel2.UnmatchPattern=市外局番+市内局番の組み合わせが数字6桁になるように入力してください。
InquiryInput02Form.tel3.UnmatchPattern=加入者番号には4桁の数字を入力してください。
InquiryInput02Form.email.Invalid=メールアドレスを入力してください

input02.html, input02.js を変更する

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>
</head>

<body class="skin-blue layout-top-nav">
<div class="wrapper">

  <!-- Content Wrapper. Contains page content -->
  <div class="content-wrapper">
    <!-- Content Header (Page header) -->
    <section class="content-header">
      <h1>
        入力画面2
      </h1>
    </section>

    <!-- Main content -->
    <section class="content">
      <div class="row">
        <div class="col-xs-12">
          <!--/*@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}" />

            <!-- 郵便番号 -->
            <div class="form-group" id="form-group-zipcode">
              <div class="control-label col-sm-2">
                <label class="float-label">郵便番号</label>
                <div class="label label-required">必須</div>
              </div>
              <div class="col-sm-10">
                <div class="row">
                  <div class="col-sm-10">
                    <p class="form-control-static-inline"></p>
                    <input type="text" name="zipcode1" id="zipcode1" class="form-control form-control-inline"
                           style="width: 60px;" maxlength="3" value="" placeholder="" autofocus
                           th:field="*{zipcode1}"/>
                    <p class="form-control-static-inline">-</p>
                    <input type="text" name="zipcode2" id="zipcode2" class="form-control form-control-inline"
                           style="width: 80px;" maxlength="4" value="" placeholder=""
                           th:field="*{zipcode2}"/>
                  </div>
                </div>
                <div class="row hidden js-errmsg">
                  <div class="col-sm-10">
                    <p class="form-control-static text-danger">
                      <small>ここにエラーメッセージを表示します</small>
                    </p>
                  </div>
                </div>
              </div>
            </div>

            <!-- 住所 -->
            <div class="form-group" id="form-group-address">
              <div class="control-label col-sm-2">
                <label class="float-label">住所</label>
                <div class="label label-required">必須</div>
              </div>
              <div class="col-sm-10">
                <div class="row">
                  <div class="col-sm-10">
                    <input type="text" name="address" id="address" class="form-control"
                           maxlength="256" value="" placeholder="例)東京都千代田区飯田橋1-1"
                           th:field="*{address}"/>
                  </div>
                </div>
                <div class="row hidden js-errmsg">
                  <div class="col-sm-10">
                    <p class="form-control-static text-danger">
                      <small>ここにエラーメッセージを表示します</small>
                    </p>
                  </div>
                </div>
              </div>
            </div>

            <!-- 電話番号 -->
            <div class="form-group" id="form-group-tel">
              <div class="control-label col-sm-2">
                <label class="float-label">電話番号</label>
              </div>
              <div class="col-sm-10">
                <div class="row">
                  <div class="col-sm-10">
                    <input type="text" name="tel1" id="tel1" class="form-control form-control-inline"
                           style="width: 100px;" maxlength="5" value="" placeholder=""
                           th:field="*{tel1}"/>
                    <p class="form-control-static-inline">-</p>
                    <input type="text" name="tel2" id="tel2" class="form-control form-control-inline"
                           style="width: 100px;" maxlength="4" value="" placeholder=""
                           th:field="*{tel2}"/>
                    <p class="form-control-static-inline">-</p>
                    <input type="text" name="tel3" id="tel3" class="form-control form-control-inline"
                           style="width: 100px;" maxlength="4" value="" placeholder=""
                           th:field="*{tel3}"/>
                  </div>
                </div>
                <div class="row hidden js-errmsg">
                  <div class="col-sm-10">
                    <p class="form-control-static text-danger">
                      <small>ここにエラーメッセージを表示します</small>
                    </p>
                  </div>
                </div>
              </div>
            </div>

            <!-- メールアドレス -->
            <div class="form-group" id="form-group-email">
              <div class="control-label col-sm-2">
                <label class="float-label">メールアドレス</label>
              </div>
              <div class="col-sm-10">
                <div class="row">
                  <div class="col-sm-10">
                    <input type="text" name="email" id="email" class="form-control"
                           style="width: 250px;" maxlength="256" value="" placeholder="例)taro.tanaka@sample.co.jp"
                           th:field="*{email}"/>
                  </div>
                </div>
                <div class="row hidden js-errmsg">
                  <div class="col-sm-10">
                    <p class="form-control-static text-danger">
                      <small>ここにエラーメッセージを表示します</small>
                    </p>
                  </div>
                </div>
              </div>
            </div>

            <div class="text-center">
              <br/>
              <p class="text-primary text-bold">※電話番号とメールアドレスのいずれか一方は必ず入力してください。</p><br/>
              <button class="btn bg-blue js-btn-back"><i class="fa fa-arrow-left"></i> 前の画面へ戻る</button>
              <button class="btn bg-green js-btn-next"><i class="fa fa-arrow-right"></i> 次へ</button>
            </div>
          </form>
        </div>
      </div>
    </section>
    <!-- /.content -->
  </div>
  <!-- /.content-wrapper -->
</div>
<!-- ./wrapper -->

<!-- REQUIRED JS SCRIPTS -->
<script src="/js/inquiry/input02.js"></script>

</body>
</html>
  • form タグの上に <!--/*@thymesVar id="inquiryInput02Form" type="ksbysample.webapp.bootnpmgeb.web.inquiry.form.InquiryInput02Form"*/--> を追加します。
  • <form id="input02Form" ...<form id="inquiryInput02Form" ... に変更します。
  • form タグの末尾に th:object="${inquiryInput02Form}" を追加します。
  • <input type="hidden" name="copiedFromSession" id="copiedFromSession" th:value="*{copiedFromSession}"/> を追加します。
  • 入力/選択項目のタグに th:field="*{...}"(... には入力項目に対応した変数を記述) を追加します。

form タグの id 属性を変更したので src/main/assets/js/inquiry/input02.js の以下の点を変更します。

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

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

    ..........
};

..........

$(document).ready(function (event) {
    ..........

    // 初期画面表示時にセッションに保存されていたデータを表示する場合には
    // 入力チェックを実行して画面の表示を入力チェック後の状態にする
    if ($("#copiedFromSession").val() === "true") {
        executeAllValidator(event);
    }

    // 「お名前(漢字)」の「姓」にフォーカスをセットする
    $("#lastname").focus().select();
});
  • $("#input02Form")$("#inquiryInput02Form") に変更します。
  • if ($("#copiedFromSession").val() === "true") { ... } を追加します。

SessionData クラスを変更する

src/main/java/ksbysample/webapp/bootnpmgeb/session/SessionData.java の以下の点を変更します。

@Data
public class SessionData implements Serializable {

    private static final long serialVersionUID = -2673191456750655164L;

    private InquiryInput01Form inquiryInput01Form;

    private InquiryInput02Form inquiryInput02Form;

}
  • private InquiryInput02Form inquiryInput02Form; を追加します。

画面表示時と「前の画面へ戻る」「次へ」ボタンクリック時の処理を実装する

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

@Slf4j
@Controller
@RequestMapping("/inquiry/input")
@SessionAttributes("sessionData")
public class InquiryInputController {

    private static final String TEMPLATE_BASE = "web/inquiry";
    private static final String TEMPLATE_INPUT01 = TEMPLATE_BASE + "/input01";
    private static final String TEMPLATE_INPUT02 = TEMPLATE_BASE + "/input02";
    private static final String TEMPLATE_INPUT03 = TEMPLATE_BASE + "/input03";

    private final ModelMapper modelMapper;

    private final InquiryInput02FormValidator inquiryInput02FormValidator;

    private final Validator mvcValidator;

    /**
     * コンストラクタ
     *
     * @param modelMapper                 {@link ModelMapper} オブジェクト
     * @param inquiryInput02FormValidator {@link InquiryInput02FormValidator} オブジェクト
     * @param mvcValidator                {@link Validator} オブジェクト
     */
    public InquiryInputController(ModelMapper modelMapper
            , InquiryInput02FormValidator inquiryInput02FormValidator
            , Validator mvcValidator) {
        this.modelMapper = modelMapper;
        this.inquiryInput02FormValidator = inquiryInput02FormValidator;
        this.mvcValidator = mvcValidator;
    }

    /**
     * inquiryInput02Form 用 InitBinder
     *
     * @param binder {@link WebDataBinder} オブジェクト
     */
    @InitBinder(value = "inquiryInput02Form")
    public void inquiryInput02FormInitBinder(WebDataBinder binder) {
        binder.addValidators(inquiryInput02FormValidator);
    }

    ..........

    /**
     * 入力画面2 初期表示処理
     *
     * @return 入力画面2の Thymeleaf テンプレートファイルのパス
     */
    @GetMapping("/02")
    public String input02(InquiryInput02Form inquiryInput02Form
            , SessionData sessionData) {
        // セッションに保存されているデータがある場合にはコピーする
        if (sessionData.getInquiryInput02Form() != null) {
            modelMapper.map(sessionData.getInquiryInput02Form(), inquiryInput02Form);
            inquiryInput02Form.setCopiedFromSession(true);
        }

        return TEMPLATE_INPUT02;
    }

    /**
     * 入力画面2 「前の画面へ戻る」ボタンクリック時の処理
     *
     * @return 入力画面1の URL
     */
    @PostMapping(value = "/02", params = {"move=back"})
    public String input02MoveBack(@Validated InquiryInput02Form inquiryInput02Form
            , BindingResult bindingResult
            , SessionData sessionData
            , UriComponentsBuilder builder) {
        if (bindingResult.hasErrors()) {
            bindingResult.getAllErrors().stream().forEach(e -> log.warn(e.getCode()));
            throw new IllegalArgumentException("セットされるはずのないデータがセットされています");
        }

        // 入力されたデータをセッションに保存する
        sessionData.setInquiryInput02Form(inquiryInput02Form);

        return UrlBasedViewResolver.REDIRECT_URL_PREFIX
                + builder.path(UrlConst.URL_INQUIRY_INPUT_01).toUriString();
    }

    /**
     * 入力画面2 「次へ」ボタンクリック時の処理
     *
     * @return 入力画面3の URL
     */
    @PostMapping(value = "/02", params = {"move=next"})
    public String input02MoveNext(@Validated InquiryInput02Form inquiryInput02Form
            , BindingResult bindingResult
            , InquiryInput02FormNotEmptyRule inquiryInput02FormNotEmptyRule
            , SessionData sessionData
            , UriComponentsBuilder builder) {
        // 必須チェックをする
        mvcValidator.validate(inquiryInput02FormNotEmptyRule, bindingResult);
        if (bindingResult.hasErrors()) {
            bindingResult.getAllErrors().stream().forEach(e -> log.warn(e.getCode()));
            throw new IllegalArgumentException("セットされるはずのないデータがセットされています");
        }

        // 入力されたデータをセッションに保存する
        sessionData.setInquiryInput02Form(inquiryInput02Form);

        return UrlBasedViewResolver.REDIRECT_URL_PREFIX
                + builder.path(UrlConst.URL_INQUIRY_INPUT_03).toUriString();
    }

    ..........
  • 以下のフィールド変数を追加します。また追加したフィールドにセットする処理をコンストラクタに追加します。
    • private final InquiryInput02FormValidator inquiryInput02FormValidator;
    • private final Validator mvcValidator;
  • inquiryInput02FormInitBinder メソッドを追加します。
  • input02 メソッドの以下の点を変更します。
    • 引数に InquiryInput02Form inquiryInput02FormSessionData sessionData を追加します。
    • if (sessionData.getInquiryInput02Form() != null) { ... } の処理を追加します。
  • input02MoveBack メソッドの以下の点を変更します。
    • 引数に @Validated InquiryInput02Form inquiryInput02FormBindingResult bindingResultSessionData sessionData を追加します。
    • if (bindingResult.hasErrors()) { ... } の処理を追加します。
    • sessionData.setInquiryInput02Form(inquiryInput02Form); を追加します。
  • input02MoveNext メソッドの以下の点を変更します。
    • 引数に @Validated InquiryInput02Form inquiryInput02FormBindingResult bindingResultInquiryInput02FormNotEmptyRule inquiryInput02FormNotEmptyRuleSessionData sessionData を追加します。
    • mvcValidator.validate(inquiryInput02FormNotEmptyRule, bindingResult); を追加します。
    • if (bindingResult.hasErrors()) { ... } を追加します。
    • sessionData.setInquiryInput02Form(inquiryInput02Form); を追加します。

動作確認

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

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

f:id:ksby:20171007184631p:plain

何も入力せずに「前の画面へ戻る」ボタンをクリックすると、入力画面1へ戻ります。サーバ側でも必須チェックは行われません。

f:id:ksby:20171007184814p:plain

「次へ」ボタンをクリックして入力画面2へ戻った後、データを入力します。

f:id:ksby:20171007185123p:plain

「前の画面へ戻る」ボタンをクリックして入力画面1へ戻ってから、

f:id:ksby:20171007185211p:plain

「次へ」ボタンをクリックして入力画面2へ戻ると、前に入力したデータが表示されます。

f:id:ksby:20171007185312p:plain

入力したデータを変更してから、

f:id:ksby:20171007185957p:plain

「次へ」ボタンをクリックして入力画面3へ遷移した後、

f:id:ksby:20171007190109p:plain

「前の画面へ戻る」ボタンをクリックすると、入力画面2へ戻り変更したデータが表示されます。

f:id:ksby:20171007190229p:plain

最後に各入力項目に最大文字数のデータを入力して、

f:id:ksby:20171007190620p:plain

「前の画面へ戻る」ボタンをクリックすると、入力画面1へ戻ります。サーバ側の最大文字数の入力チェックでエラーになりません。

f:id:ksby:20171007190734p:plain

「次へ」ボタンをクリックして入力画面2へ戻ると、入力した最大文字数のデータが表示されます。

f:id:ksby:20171007190952p:plain

問題なく動作しているようです。

次回は。。。

  • 郵便番号検索API を利用して、「郵便番号」を入力したら「住所」に都道府県・市区町村・町丁目名がセットされる処理を実装します。

履歴

2017/10/07
初版発行。