2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-03-10

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

テスト実行時に、自作したValidation内のフィールドへDIする方法

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

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に現在ログインしているパスワードと比較するバリデーションがあり、それが下記の通りです。

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側のバリデーターをテストしていきます。

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

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を作っています。
⑤・・・対象となるオブジェクトに値をセットしています。

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

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()でサイズを図り、0かチェックしています。

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

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

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

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

#全体像

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

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

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

2
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?