SpringBootでフォームクラスのバリデーションテスト

javax側が持っているバリデーション（アノテーションで定義出来る）とSpring側のバリデーションのテスト方法について困惑したため、下記の通り纏めます。
ちなみに、javax側のカスタムアノテーションで全て作ればいいじゃんって思うかもしれませんが、思うようにテストが出来なかったため、やむを得ずSpring側で作成しました。

[テスト実行時に、自作したValidation内のフィールドへDIする方法](https://teratail.com/questions/243089)

#やりたいこと
まずは対象となるクラスを確認します。

```java:PasswordForm.java
package com.ssp_engine.user.domain.model;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

import org.hibernate.validator.constraints.Length;

import com.ssp_engine.user.domain.model.validation.ConfirmPassword;
import com.ssp_engine.user.domain.model.validation.ValidGroup1;
import com.ssp_engine.user.domain.model.validation.ValidGroup2;
import com.ssp_engine.user.domain.model.validation.ValidGroup3;
import com.ssp_engine.user.domain.model.validation.ValidGroup4;

import lombok.Data;

@Data
@ConfirmPassword(field = "password", groups = ValidGroup4.class)
public class PasswordForm {

	@NotBlank(groups = ValidGroup1.class)
	private String currentPassword;

	@NotBlank(groups = ValidGroup1.class)
	@Length(min = 4, max = 8, groups = ValidGroup2.class)
	@Pattern(regexp="^[a-zA-Z0-9]+$", groups = ValidGroup3.class)
	private String password;

	@NotBlank(groups = ValidGroup1.class)
	private String confirmPassword;

}
```

こんな感じの、一般的なWebサービスの管理画面にある自身のパスワードを編集する時のフォームクラスです。
`currentPassword`は、現在ログイン中のパスワード
`password`は、変更するパスワード
`confirmPassword`は、確認用パスワード

で、それぞれに`@NotBlank`やら`@Pattern`の正規表現のバリデーションがあります。
`currentPassword`に現在ログインしているパスワードと比較するバリデーションがあり、それが下記の通りです。

```java:LoginPassAndFormPassValidator.java
package com.ssp_engine.user.domain.model.validation;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

import com.ssp_engine.user.domain.model.PasswordForm;

@Component
public class LoginPassAndFormPassValidator implements Validator {

	@Autowired
    private PasswordEncoder passwordEncoder;

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

    @Override
    public void validate(Object target, Errors errors) {
    	PasswordForm form = (PasswordForm) target;
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    	UserDetails principal = (UserDetails) auth.getPrincipal();
    	String userPass = principal.getPassword();

        if (form.getCurrentPassword() == null) {
            return;
        }

        if (!this.passwordEncoder.matches(form.getCurrentPassword(), userPass)) {
            errors.rejectValue("currentPassword",
					           "LoginPassAndFormPassValidator.PasswordForm.currentPassword",
					           "ログイン中のパスワードと異なります。");
        }
    }
}
```

コントローラー側で、` @InitBinder`してから使用しています。
これらのSpring側とJavax側のバリデーターをテストしていきます。

##テスト
そして、テストですが、コントローラー側のテストとフォームクラスのテストで分けたほうが長くならず、テスト内容を切り分けやすいとのことで、こんな感じになりました。

```java:PasswordFormTests.java

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

    private PasswordForm passwordForm = new PasswordForm();
    private BindingResult bindingResult = new BindException(passwordForm, "PasswordForm"); //①

    @Autowired
    @Qualifier("loginPassAndFormPassValidator") //②
    /* Spring側 */
	private org.springframework.validation.Validator loginPassAndFormPassValidator;
    /* javax側 */
    private static Validator validator; //③

    @BeforeClass
    public static void 初期化処理() { //④
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory(); 
        validator = validatorFactory.getValidator(); 
    }

    @Before
    public void 値をセット() throws Exception{ //⑤
    	this.passwordForm.setCurrentPassword("currentpassword");
    	this.passwordForm.setConfirmPassword("password");
    	this.passwordForm.setPassword("password");
    }

}
```

①・・・バリデーターを実行した後に、結果を受け取るためのフィールド
②・・・明示的にどのクラスかを指定してあげる必要があったため、`@Qualifier`で指定しました。
③・④・・・`@AutoWired`で指定して、Beanをゲットしたかったのですが、上手く行かなかったので、明示的にBeanを作っています。
⑤・・・対象となるオブジェクトに値をセットしています。


準備が整ったところで、ゴリゴリテストをしていくわけですが、こんな感じになりました。
まずは、エラーが出ないことを確認。

```java:PasswordFormTests.java
    @Test
    @WithMockUser(username = "username",
    			  password ="$2a$10$p3/Malw3/KWyfOlPwWoUCulx4iDb2C/nmo6x8P2svXjfJQ5ETLhG2",
    			  roles = "USER")
    public void エラー無し() throws Exception{
    	loginPassAndFormPassValidator.validate(this.passwordForm, bindingResult); //①
        Set<ConstraintViolation<PasswordForm>> violations =
        		validator.validate(this.passwordForm,ValidGroup1.class,ValidGroup2.class,ValidGroup3.class,ValidGroup4.class); //②
        assertThat(bindingResult.getFieldError(), is(nullValue())); //③
        assertThat(violations.size(), is(0)); //④
    }
```

①・・・Spring側のバリデーションを実行しています。第一引数に対象のオブジェクト、第二引数に、結果を格納するオブジェクト`bindingResult`を指定してあげています。
②・・・`ConstraintViolation`は制約違反の内容を格納したオブジェクトのセットを返し、validateの第一引数には、対象のオブジェクト。第二引数には、ValidGroupを指定していたため、どのバリデーションを有効にするか指定しています。
②・・・Spring側の結果を`bindingResult`で受け取り、Nullかどうかチェックしています。
③・・・Javax側の結果を`violations.size()`でサイズを図り、０かチェックしています。

`@WithMockUser`は、`loginPassAndFormPassValidator`でログイン情報を取得する必要があったため、ログイン状態にしています。

これで、エラーが出ないことが分かったら、こんな感じで書いていきました。

```java:PasswordFormTests.java
    @Test
    @WithMockUser(username = "username",
    			  password ="currentpassword",
    			  roles = "USER")
    public void ログインパスと入力パスが違う() throws Exception{
    	loginPassAndFormPassValidator.validate(this.passwordForm, bindingResult);
        assertThat(bindingResult.getFieldError("currentPassword"), is(bindingResult.getFieldError()));
        assertThat(bindingResult.getFieldError().getDefaultMessage(), is("ログイン中のパスワードと異なります。"));
    }

    @Test
    public void 現在のパスワードがBlank() throws Exception{
    	this.passwordForm.setCurrentPassword("");
        Set<ConstraintViolation<PasswordForm>> violations =
        		validator.validate(this.passwordForm,ValidGroup1.class);
        assertThat(violations.size(), is(1));
        assertThat(getAnnotation(violations, "currentPassword"), is(instanceOf(NotBlank.class))); //①
    }

    private Annotation getAnnotation(Set<ConstraintViolation<PasswordForm>> violations, String path) { //②
        return violations.stream()
                .filter(cv -> cv.getPropertyPath().toString().equals(path))
                .findFirst()
                .map(cv -> cv.getConstraintDescriptor().getAnnotation())
                .get();
    }

```

①・・・どのアノテーションでエラーが出てるか確認しています。
②・・・エラーで弾かれたアノテーションのインスタンスを取得するために、メソッドを作っています。


#全体像

全体像はこんな感じになりました。

```java:PasswordFormTests.java

package com.ssp_engine.user.domain.model;

import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;

import java.lang.annotation.Annotation;
import java.util.Set;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

import org.hibernate.validator.constraints.Length;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;

import com.ssp_engine.user.domain.model.validation.ConfirmPassword;
import com.ssp_engine.user.domain.model.validation.ValidGroup1;
import com.ssp_engine.user.domain.model.validation.ValidGroup2;
import com.ssp_engine.user.domain.model.validation.ValidGroup3;
import com.ssp_engine.user.domain.model.validation.ValidGroup4;


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

    private PasswordForm passwordForm = new PasswordForm();
    private BindingResult bindingResult = new BindException(passwordForm, "PasswordForm");

    @Autowired
    @Qualifier("loginPassAndFormPassValidator")
    /* Spring側 */
	private org.springframework.validation.Validator loginPassAndFormPassValidator;
    /* javax側 */
    private static Validator validator;

    @BeforeClass
    public static void 初期化処理() {
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        validator = validatorFactory.getValidator();
    }

    @Before
    public void 値をセット() throws Exception{
    	this.passwordForm.setCurrentPassword("currentpassword");
    	this.passwordForm.setConfirmPassword("password");
    	this.passwordForm.setPassword("password");
    }

    @Test
    @WithMockUser(username = "username",
    			  password ="$2a$10$p3/Malw3/KWyfOlPwWoUCulx4iDb2C/nmo6x8P2svXjfJQ5ETLhG2",
    			  roles = "USER")
    public void エラー無し() throws Exception{
    	loginPassAndFormPassValidator.validate(this.passwordForm, bindingResult);
        Set<ConstraintViolation<PasswordForm>> violations =
        		validator.validate(this.passwordForm,ValidGroup1.class,ValidGroup2.class,ValidGroup3.class,ValidGroup4.class);
        assertThat(bindingResult.getFieldError(), is(nullValue()));
        assertThat(violations.size(), is(0));
    }

    @Test
    @WithMockUser(username = "username",
    			  password ="currentpassword",
    			  roles = "USER")
    public void ログインパスと入力パスが違う() throws Exception{
    	loginPassAndFormPassValidator.validate(this.passwordForm, bindingResult);
        assertThat(bindingResult.getFieldError("currentPassword"), is(bindingResult.getFieldError()));
        assertThat(bindingResult.getFieldError().getDefaultMessage(), is("ログイン中のパスワードと異なります。"));
    }

    @Test
    public void 現在のパスワードがBlank() throws Exception{
    	this.passwordForm.setCurrentPassword("");
        Set<ConstraintViolation<PasswordForm>> violations =
        		validator.validate(this.passwordForm,ValidGroup1.class);
        assertThat(violations.size(), is(1));
        assertThat(getAnnotation(violations, "currentPassword"), is(instanceOf(NotBlank.class)));
    }

    @Test
    public void パスワードがBlank() throws Exception{
        this.passwordForm.setPassword("");
        Set<ConstraintViolation<PasswordForm>> violations =
        		validator.validate(this.passwordForm,ValidGroup1.class);
        assertThat(violations.size(), is(1));
        assertThat(getAnnotation(violations, "password"), is(instanceOf(NotBlank.class)));
    }

    @Test
    public void 確認用パスワードがBlank() throws Exception{
        this.passwordForm.setConfirmPassword("");
        Set<ConstraintViolation<PasswordForm>> violations =
        		validator.validate(this.passwordForm,ValidGroup1.class);
        assertThat(violations.size(), is(1));
        assertThat(getAnnotation(violations, "confirmPassword"), is(instanceOf(NotBlank.class)));
    }

    @Test
    public void 確認用パスワードと入力パスワードが異なる時() throws Exception{
        this.passwordForm.setPassword("aiueo");
        this.passwordForm.setConfirmPassword("kakikukeko");
        Set<ConstraintViolation<PasswordForm>> violations =
        		validator.validate(this.passwordForm,ValidGroup4.class);
        assertThat(violations.size(), is(1));
        assertThat(getAnnotation(violations, "password"), is(instanceOf(ConfirmPassword.class)));
    }

    @Test
    public void パスワードが8文字以上の時() throws Exception{
    	this.passwordForm.setPassword("aiueokakikukeko");
        Set<ConstraintViolation<PasswordForm>> violations =
        		validator.validate(this.passwordForm,ValidGroup2.class);
        assertThat(violations.size(), is(1));
        assertThat(getAnnotation(violations, "password"), is(instanceOf(Length.class)));
    }

    @Test
    public void パスワードが4文字以下の時() throws Exception{
    	this.passwordForm.setPassword("aiu");
        Set<ConstraintViolation<PasswordForm>> violations =
        		validator.validate(this.passwordForm,ValidGroup2.class);
        assertThat(violations.size(), is(1));
        assertThat(getAnnotation(violations, "password"), is(instanceOf(Length.class)));
    }

    @Test
    public void パスワードが半角英数字以外の時() throws Exception{
    	this.passwordForm.setPassword("テストです");
        Set<ConstraintViolation<PasswordForm>> violations =
        		validator.validate(this.passwordForm,ValidGroup3.class);
        assertThat(violations.size(), is(1));
        assertThat(getAnnotation(violations, "password"), is(instanceOf(Pattern.class)));
    }

    private Annotation getAnnotation(Set<ConstraintViolation<PasswordForm>> violations, String path) {
        return violations.stream()
                .filter(cv -> cv.getPropertyPath().toString().equals(path))
                .findFirst()
                .map(cv -> cv.getConstraintDescriptor().getAnnotation())
                .get();
    }
}

```


参考になった方がいらっしゃいましたら、幸いです。