MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

BeanValidationを用いるフォームクラスをテストする

f:id:microad-developer:20190801120345p:plain

アプリケーションエンジニア1年目の宮田です。
今回は以下のようなBeanValidationを用いるフォームクラスのテストについて、用いる道具と実際のテストにおけるTipsの2点からまとめます。

import lombok.Getter;
import lombok.Setter;
import org.hibernate.validator.constraints.URL;

import javax.validation.Valid;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

@Getter @Setter
public class MyForm {
  @NotNull(message = "IDは必須です。")
  private Integer id;
  @URL(message = "URLの形式に問題があります。")
  @Size(max = 2047, message = "URLは2047文字まで入力可能です。")
  private String url;
  @Valid
  private OriginalClass extraData; // 自作した追加データクラス

  @AssertTrue(message = "URLはascii文字のみ指定してください。")
  private boolean isUrl() {
    return url == null || UrlChecker.isAsciiUrl(url);
  }
}

用いる道具

テストのために必要な内容として、以下について書きます。

  • バリデーション結果を取得する
  • インバリッドだったフィールドを取得する
    • @Validによるネストしたバリデーションに関してフィールドを取得する
  • (反応したアノテーションを取得する)

ここで紹介する内容は大概ConstraintViolationの機能なので、より詳細な情報はドキュメントを参照してください。

バリデーション結果を取得する

テストを行うためには、まず任意のタイミングでバリデーション結果を取得する必要があります。

これを行う手段はいくつか有りますが、今回は以下の方法で行います。 resultにはインバリッドだったフィールドのみが格納されます。

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import java.util.Set;

/* 略 */

Set<ConstraintViolation</* バリデーション対象クラス */>> result =
     Validation
        .buildDefaultValidatorFactory()
        .getValidator()
        .validate(/* バリデーション対象 */);

インバリッドだったフィールドを取得する

次に、インバリッドだったフィールドを取得します。

インバリッドだったフィールド名はConstraintViolation::getPropertyPathで取得できるので、Setからの抽出はこれを参照することで、以下のようにして比較できます。

// 取得は省略
ConstraintViolation</* バリデーション対象クラス */> element;

// フィールド名の比較
boolean isInvalidField = element.getPropertyPath().toString().equals("id");

@Validによるネストしたバリデーションに関してフィールドを取得する

@Validによるネストしたバリデーションの結果は/* フォームクラスでのフィールド名 */./* 内部クラスでのフィールド名 */という形式で保存されるため、これを参照・比較することで抽出できます。
ただし、フォームクラスのテストという観点では/* 内部クラスでのフィールド名 */に何が入っても大丈夫なようになっているべきなので、比較にはString::startWithを用いるべきだと思います。

// 取得は省略
ConstraintViolation</* バリデーション対象クラス */> element;

// フィールド名が何から始まるかの比較
boolean isInvalidField = element.getPropertyPath().toString().startWith("id");

また、配列やListに関してバリデーションした場合には/* フォームクラスでのフィールド名 */[/* インデックス */]./* 内部クラスでのフィールド名 */という形で格納されるため、より明確な確認が必要であれば正規表現で比較しても良いでしょう。

実際のテストにおけるTips

ここからは実際のテストでのTips的な内容を書きます。

バリデーション結果取得用のユーティリティを作成する

結果の取得やフィールドの比較といった同じ内容を一々書くのは非効率であるため、自分は以下のようなUtilを作って対応しました。

/**
 * バリデーション結果を手軽に扱うためのUtilクラス
 * @param <T> 検証対象クラス
 */
public class ValidateResult<T> {
  // バリデーション結果
  private Set<ConstraintViolation<T>> result;

  public ValidateResult(T form) {
    this.result = Validation.buildDefaultValidatorFactory().getValidator().validate(form);
  }

  /**
   * バリデーション結果に入力と一致するフィールドが存在するか
   * @param expected 期待されるフィールド名
   * @return 比較結果
   */
  public boolean includesFieldName(String expected) {
    return result.stream().anyMatch(it -> it.getPropertyPath().toString().equals(expected));
  }

  /**
   * バリデーション結果に入力から始まるフィールドが存在するか
   * @param expected 期待されるフィールド名('.'は除く)
   * @return 比較結果
   */
  public boolean includesFieldNameStartWith(String expected) {
    return result.stream().anyMatch(it -> it.getPropertyPath().toString().startsWith(expected + "."));
  }
}

JUnit5のネストしたテスト用の基盤クラスを作成する

ここまでの内容を用いて実際にテストを作成すると、フィールド毎にクラス分けした場合、JUnit5では以下のような形になります。
一方、このままではincludesFieldNameでの比較を行う際にフィールド名を一々文字列で入力する必要があり、それを間違えた場合に気付くことが難しいという問題が有ります。

@DisplayName("マイフォームのテスト")
class MyFormTest {
  private MyFormForm myForm;

  @BeforeEach
  void beforeEach() {
    myForm = new myForm();
  }

  @Nested
  @DisplayName("ID関連のテスト")
  class IdTest {
    @Test
    @DisplayName("Null入力")
    void isNull() {
      myForm.setId(null);

      ValidateResult<MyForm> result = new ValidationResult<>(myForm);

      assertTrue(
        result.includesFieldName("id"), // ここで文字列を用いるため安全性が低い
        "Null入力がバリデーションできていません"
      );
    }
  }
}

改善方法

以下のような基盤クラスを設定し、実際のテストではそれを継承することで、フィールド名を間違うリスクを軽減し、修正を容易にすることができます。

import lombok.Setter;

public abstract class FormTest {
  @Setter private Object targetObject;
  private final String targetField;

  public FormTest(String targetField) {
    this.targetField = targetField;
  }

  /**
   * @return 対象フィールドにアノテーションが反応しているか
   */
  protected boolean isIncludedFieldName() {
    return new ValidateResult(targetObject).includesFieldName(targetField);
  }

  /**
   * @return 対象フィールドから始まる名前にアノテーションが反応しているか
   */
  protected boolean isIncludedFieldNameStartWith() {
    return new ValidateResult(targetObject).includesFieldNameStartWith(targetField);
  }
}

使い方

実際にこれを用いると以下のようになります。
テスト対象はテスト毎にセットし直す必要がある点には注意が必要です。

@DisplayName("マイフォームのテスト")
class MyFormTest {
  private MyFormForm myForm;

  @BeforeEach
  void beforeEach() {
    myForm = new myForm();
  }

  @Nested
  @DisplayName("ID関連のテスト")
  class IdTest extends FormTest {
    IdTest() {
      super("id"); // 対象フィールド名
    }

    @BeforeEach
    void beforeEach() {
      setTargetObject(myForm); // テスト対象はテスト毎にセットし直す必要がある
    }

    @Test
    @DisplayName("Null入力")
    void isNull() {
      myForm.setId(null);

      assertTrue(isIncludedFieldName(), "Null入力がバリデーションできていません");
    }
  }
}

ネストしたバリデーションをテストする

BeanValidationでは@Validアノテーションによってネストしたバリデーションを行うことができます。

import lombok.Getter;
import lombok.Setter;

import javax.validation.Valid;

@Getter @Setter
public class MyForm {
  @Valid
  private OriginalClass extraData;
}

このようなフォームクラスでネストしたバリデーションが行われるかテストする場合、実際の実装を使うというのは一つの手ですが、テストが他のクラスに依存していることは望ましくありません。
この場合は、以下のように、対象フィールドのオブジェクトを継承した上でフィールドを設けたものをセットし、それに対してバリデーションを行う形にすることで、テストの他クラスへの依存を最小限にすることができます。

@Test
@DisplayName("@Validが機能しているか")
void isValid() {
  myForm.setExtraData(new OriginalClass() {
    @NotNull(message = "テスト用に絶対に反応する状態にしている。")
    private String fieldForTest = null;
  });

  ValidateResult<MyForm> result = validate(myForm);

  assertTrue(
      result.includesFieldName("extraData.fieldForTest"),
      "内部への異常入力がバリデーションできていません。"
  );
}

終わりに

今回はフォームクラスのバリデーションのテストについて書きました。
ニッチな内容ですが、まとめてみると結構大きくなるものですね。 この記事が何かの役に立てば幸いです。

マイクロアドでは「VueのWebフロントとSpringBootのサーバーサイド両方開発したい!」というエンジニアを積極的に募集していますので、ご興味を持たれた方はこちらから是非ご応募下さい!