Spring Boot で DI を必要とする Validator のテストを高速化する


Spring Bootのプロジェクトでユニットテスト実行時間の高速化を模索している中で、DIが必要なカスタムバリデーターのテスト実行を高速化する方法が分かったので紹介します。

確認環境

  • Spring Boot 2.2.2
  • JUnit 5.5.2
  • AdoptOpenJDK 11.0.3
  • macOS Mojave 10.14.6 (3.1GHz Intel Core i5, 16 GB RAM)

背景

入力値の一意性をチェックする場合など、Bean Validation で DB 接続をしたい場合があり、次のような DI を使ったカスタムバリデーターをいくつも作っていました。

@Documented
@Constraint(validatedBy = { EmployeeCodeUniqueValidator.class })
@Target({ TYPE })
@Retention(RUNTIME)
public @interface EmployeeCodeUniqueConstraint {
    ...
}
public class EmployeeCodeUniqueValidator implements ConstraintValidator<EmployeeCodeUniqueConstraint, Object> {
    private static final String EMPLOYEE_ID = "id";
    private static final String EMPLOYEE_CODE = "employeeCode";

    private final EmployeeDao employeeDao;

    public EmployeeCodeUniqueValidator(final EmployeeDao employeeDao) {
        this.employeeDao = employeeDao;
    }

    @Override
    public boolean isValid(final Object value, final ConstraintValidatorContext context) {
        String employeeCode = getField(value, EMPLOYEE_CODE);
        if (employeeCode == null) {
            return true;
        }
        Optional<Employee> employeeOpt = employeeDao.findByEmployeeCode(employeeCode);
        if (employeeOpt.isEmpty()) {
            return true;
        }
        ...

しかし、このようなカスタムバリデーターのユニットテストを書く場合には、依存コンポーネントをモックで差し替えて DI した Validator を初期化する必要があります。そのため、これまでは @SpringBootTest を使ったテストを書いてきましたが、Spring の初期化に時間がかかるため、カスタムバリデーターが増えるにつれて実行時間の増加に我慢できなくなってきました。

@SpringBootTest
class EmployeeCodeUniqueValidatorTest {

    @Autowired
    private Validator validator;

    @MockBean
    private EmployeeDao employeeDao;

    @EmployeeCodeUniqueConstraint
    class Form {
        public String id;
        public String employeeCode;
    }

    @Test
    void newEmployeeCode() {
        when(employeeDao.findByEmployeeCode("012345")).thenReturn(Optional.empty());
        Form form = new Form();
        form.employeeCode = "012345";
        assertTrue(validator.validate(form).isEmpty());
    }

    ...
}

解決策

@SpringBootTest アノテーションの classes 属性を指定すると、Validator コンポーネントだけを初期化することができ、テスト起動時の初期化時間を短縮できます。

- @SpringBootTest
+ @SpringBootTest(classes = {ValidationAutoConfiguration.class})
class EmployeeCodeUniqueValidatorTest {

    @Autowired
    private Validator validator;

    @MockBean
    private EmployeeDao employeeDao;

    ...
}

2022/02/24 追記 初稿では、EmployeeDao.class も classes 属性に列挙していましたが、@ MockBean でDIするコンポーネントは列挙する必要がありませんでした。

改善効果

参考程度ですが、およそクラス数で30弱、メソッド数で500弱のバリデーター関連テストに対して適用し、実行時間が半分以下になりました

  • 変更前: 78.5s
  • 変更後: 29.8s

補足

テストの実行時間は速くなりますが、いちいち DI するクラスを列挙しなければならないのが玉に瑕です。カスタムバリデーターのテストクラスでは DI コンポーネントが少ないので何とかなりますが、多数の依存があるようなクラスでは実行速度よりもメンテナンスのしやすさを優先した方がよい可能性もあります。

また、チーム開発をしている場合にはこのような実装ルールの統一が難しく、知らないうちに @SpringBootTest だけを指定したテストコードが増えがちです。 ArchUnit などを使って、機械的に気付ける仕組みを合わせて導入しておくとよさそうです。

@Test
void validatorTestShouldeRestrictAutowiredComponent() {
    classes().that().haveNameMatching(".+ValidatorTest$").and().areAnnotatedWith(SpringBootTest.class)
        .should()
        .beAnnotatedWith(new DescribedPredicate<>("@SpringBootTest(classes = {ValidationAutoConfiguration.class, ...} でDIするコンポーネントを制限する") {
            @Override
            public boolean apply(JavaAnnotation annot) {
                if (!annot.getRawType().getSimpleName().equals("SpringBootTest")) {
                    return true;
                }
                JavaClass[] classes = (JavaClass[]) annot.get("classes").or(new JavaClass[]{});
                return Arrays.stream(classes)
                    .anyMatch(clazz -> clazz.getSimpleName().equals("ValidationAutoConfiguration"));
            }
        })
        .check(ALL_CLASSES);
}

まとめ

この記事では、Spring Boot を使ったプロジェクトでカスタムバリデーターのユニットテストを高速化する方法を紹介しました。カスタムバリデーターのテスト実行が遅いことは以前から認識していましたが、ネットで検索してもなかなかこの方法にたどり着けなかったので、同じ悩みをかかえている人の参考になれば幸いです。