かんがるーさんの日記

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

Spring Boot 2.0.x の Web アプリを 2.1.x へバージョンアップする ( 番外編 )( テストクラス内の @Component を付与したクラスを Bean として登録するには? )

概要

記事一覧はこちらです。

Spring Boot 2.1 へのバージョンアップとは全然関係ないのですが、Spring Boot + npm + Geb で入力フォームを作ってテストする ( 番外編 )( ModelMapper メモ書き ) で書いた ModelMapper のテストクラスと似たようなテストを新規プロジェクトを作成して実行したら失敗したのに、そのテストを ksbysample-webapp-lending プロジェクトにコピーして実行したら成功して原因が分からなかったので、その調査をした時のメモ書きです。

参照したサイト・書籍

目次

  1. 作成した新規プロジェクトと、どのようなテストを実行しようとして動作しなかったのか?
  2. テストクラスを ksbysample-webapp-lending プロジェクトにコピーして実行するとどうなるのか?
  3. 調べてみると @ComponentScan アノテーションを明記するとテストが成功する
  4. @ComponentScan("com.example.demo") を書いた時と書かなかった時で何が違うのか?
  5. ちょっと動作確認
    1. Tomcat を起動した時にもテストクラスに @Component で定義したクラスが Bean として登録されてしまうのか?
    2. 別のテストクラスを実行した時には Bean として登録されてしまうのか?
  6. まとめ

手順

作成した新規プロジェクトと、どのようなテストを実行しようとして動作しなかったのか?

新規プロジェクトを Spring Initializr で作成して build.gradle を以下のように変更します。

buildscript {
    ext {
        group "com.example"
        version "0.0.1-SNAPSHOT"
    }
    repositories {
        mavenCentral()
        maven { url "https://repo.spring.io/release/" }
        maven { url "https://plugins.gradle.org/m2/" }
    }
}

plugins {
    id "java"
    id "eclipse"
    id "idea"
    id "org.springframework.boot" version "2.1.3.RELEASE"
    id "io.spring.dependency-management" version "1.0.7.RELEASE"
}

sourceCompatibility = 11
targetCompatibility = 11

wrapper {
    gradleVersion = "5.2.1"
    distributionType = Wrapper.DistributionType.ALL
}

[compileJava, compileTestJava]*.options*.encoding = "UTF-8"
[compileJava, compileTestJava]*.options*.compilerArgs = ["-Xlint:all,-options,-processing,-path"]

idea {
    module {
        inheritOutputDirs = false
        outputDir = file("$buildDir/classes/main/")
    }
}

configurations {
    // annotationProcessor と testAnnotationProcessor、compileOnly と testCompileOnly を併記不要にする
    testAnnotationProcessor.extendsFrom annotationProcessor
    testImplementation.extendsFrom compileOnly
}

repositories {
    mavenCentral()
}

dependencyManagement {
    imports {
        mavenBom(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)
        mavenBom("org.junit:junit-bom:5.4.1")
    }
}

dependencies {
    def lombokVersion = "1.18.6"

    implementation("org.springframework.boot:spring-boot-starter-web")
    runtimeOnly("org.springframework.boot:spring-boot-devtools")
    testImplementation("org.springframework.boot:spring-boot-starter-test")

    // for lombok
    annotationProcessor("org.projectlombok:lombok:${lombokVersion}")
    compileOnly("org.projectlombok:lombok:${lombokVersion}")

    // for JUnit 5
    testCompile("org.junit.jupiter:junit-jupiter")
    testRuntime("org.junit.platform:junit-platform-launcher")

    implementation("com.github.rozidan:modelmapper-spring-boot-starter:1.0.0")
}

既に作成されている src/test/java/com/example/demo/DemoApplicationTests.java を以下のように変更してから、

package com.example.demo;

import com.github.rozidan.springboot.modelmapper.TypeMapConfigurer;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.Test;
import org.modelmapper.ModelMapper;
import org.modelmapper.TypeMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.stereotype.Component;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
public class DemoApplicationTests {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    static class Sample {
        private String key;
        private String value;
    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    static class Dummy {
        private String key;
    }

    @Component
    static class SampleToDummyTypeMap
            extends TypeMapConfigurer<Sample, Dummy> {

        @Override
        public void configure(TypeMap<Sample, Dummy> typeMap) {
            typeMap.setPreConverter(context -> {
                Sample sample = context.getSource();
                Dummy dummy = context.getDestination();

                dummy.setKey(sample.getKey() + ":" + sample.getValue());

                return context.getDestination();
            });

            typeMap.addMappings(mapping -> mapping.skip(Dummy::setKey));
        }

    }

    @Test
    public void modelMapperTest() {
        Sample sample = new Sample("001", "suzuki");
        Dummy dummy = modelMapper.map(sample, Dummy.class);
        assertThat(dummy.getKey()).isEqualTo("001:suzuki");
    }

}

テストを実行してみると失敗しました。

f:id:ksby:20190325003336p:plain

@Component アノテーションを付与している SampleToDummyTypeMap クラスのインスタンスが Spring の DI コンテナに Bean として登録されれば、Dummy dummy = modelMapper.map(sample, Dummy.class); を実行した時に SampleToDummyTypeMap クラスに定義したルールが適用されるのですが、なぜか単にフィールドのデータがコピーされています。

SampleToDummyTypeMap クラスのインスタンスが Spring の DI コンテナに登録されているか確認するために、テストクラスを以下のように変更してから、

@SpringBootTest
public class DemoApplicationTests {

    @Autowired
    private ApplicationContext context;

    ..........

    @Test
    public void modelMapperTest() {
        String[] all = context.getBeanDefinitionNames();
        assertThat(all)
                .filteredOn(c -> c.contains("ampleToDummyTypeMap"))
                .hasSize(1);

        Sample sample = new Sample("001", "suzuki");
        Dummy dummy = modelMapper.map(sample, Dummy.class);
        assertThat(dummy.getKey()).isEqualTo("001:suzuki");
    }

}

実行してみましたが、確かに登録されていませんでした。

f:id:ksby:20190325004039p:plain

テストクラスを ksbysample-webapp-lending プロジェクトにコピーして実行するとどうなるのか?

ksbysample-webapp-lending プロジェクトの build.gradle に implementation("com.github.rozidan:modelmapper-spring-boot-starter:1.0.0") を追加してから、src/test/java/ksbysample/webapp/lending の下に DemoApplicationTests テストクラスをコピーして実行してみると、なぜか成功します。

f:id:ksby:20190325005323p:plain

調べてみると @ComponentScan アノテーションを明記するとテストが成功する

いろいろ試してみたところ、@ComponentScan("com.example.demo") を以下のように明記すると、

f:id:ksby:20190325070634p:plain

テストが成功するようになりました。

f:id:ksby:20190325070807p:plain

ただし @ComponentScan("com.example.demo") はデフォルトで設定されているものなので IntelliJ IDEA だと赤い波下線が表示されて(com.example だと表示されません)、Alt+Enter を押すと「delete element」のメニューが表示されます。

f:id:ksby:20190325071029p:plain

@ComponentScan("com.example.demo") なら書いても書かなくても同じはずと考えていましたが、何かが違うようです。

@ComponentScan("com.example.demo") を書いた時と書かなかった時で何が違うのか?

IntelliJ IDEA の Find in Path で @ComponentScan で調べてみると org.springframework.context.annotation.ConfigurationClassParser#doProcessConfigurationClass に // Process any @ComponentScan annotations というコメントが書かれていました。ここで処理をしているようです。

f:id:ksby:20190325071816p:plain

この部分の最初に breakpoint を設定して debug 実行してみると、@ComponentScan("com.example.demo") を書いた時には breakpoint を設定した Set<AnnotationAttributes> componentScans = AnnotationConfigUtils.attributesForRepeatable(sourceClass.getMetadata(), ComponentScans.class, ComponentScan.class); のところで excludeFilters の value に何も設定されないのに対し、

f:id:ksby:20190325072118p:plain

書かなかった時には org.springframework.boot.context.TypeExcludeFilter、org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter という2種類の Filter がセットされていました。

f:id:ksby:20190325072435p:plain

確かに @ComponentScan には excludeFilters 属性がありますが、書かないとこの2つが設定されるんですね。初めて知りました。。。

f:id:ksby:20190325073140p:plain

設定されているところを探してみたところ、@SpringBootApplication アノテシーションのソースに記述がありました。

f:id:ksby:20190325073515p:plain

この2つの Filter が設定されているとなぜ @Component アノテーションが付与されたクラスが Bean にならないのかも調べてみると、org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider#scanCandidateComponents の中の if (isCandidateComponent(metadataReader)) { という箇所で、

f:id:ksby:20190325074339p:plain

TypeExcludeFilter で false が返されるからでした。ここで isCandidateComponent(metadataReader) で false が返ると Bean が生成されません。

f:id:ksby:20190325074721p:plain

もう少し追ってみると、tf.match(metadataReader, getMetadataReaderFactory())(TypeExcludeFilter#match)の中で delegate という変数に TestTypeExcludeFilter クラスのインスタンスがセットされてその match メソッドが呼び出されるのですが、

f:id:ksby:20190325075416p:plain

org.springframework.boot.test.context.filter.TestTypeExcludeFilter#match の中で @Component アノテーションが付与されたクラスのアウタークラスがテストクラスと判断されると Bean が登録されないようになっていました。

f:id:ksby:20190325080202p:plain f:id:ksby:20190325080705p:plain f:id:ksby:20190325080437p:plain

ちょっと動作確認

Tomcat を起動した時にもテストクラスに @Component で定義したクラスが Bean として登録されてしまうのか?

DemoApplication クラスに @ComponentScan("com.example.demo") を書いた状態で src/main/java/com/example/demo/SampleController.java を新規作成して以下の内容を記述し、

package com.example.demo;

import org.springframework.context.ApplicationContext;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.Arrays;
import java.util.stream.Collectors;

@Controller
@RequestMapping("/sample")
public class SampleController {

    private final ApplicationContext context;

    public SampleController(ApplicationContext context) {
        this.context = context;
    }

    @RequestMapping(produces = MediaType.TEXT_PLAIN_VALUE)
    @ResponseBody
    public String index() {
        String[] all = context.getBeanDefinitionNames();
        return Arrays.stream(all)
                .filter(s -> s.contains("Controller") || s.contains("ampleToDummyTypeMap"))
                .collect(Collectors.joining("\r\n"));
    }

}

Tomcat を起動して http://localhost:8080/sample にアクセスしてみたところ DemoApplicationTests$SampleToDummyTypeMap は Bean として登録されていませんでした。@ComponentScan を書いた時でもテストではなく普通に Tomcat を起動した時にはテストクラスに定義したものは Bean として登録されないようです。

f:id:ksby:20190325165733p:plain

別のテストクラスを実行した時には Bean として登録されてしまうのか?

DemoApplication クラスに @ComponentScan("com.example.demo") を書いた状態で src/test/java/com/example/demo/SampleControllerTest.java を新規作成して以下の内容を記述した後、

package com.example.demo;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class SampleControllerTest {

    @Autowired
    private ApplicationContext context;

    @Test
    void sampleTest() {
        String[] all = context.getBeanDefinitionNames();
        assertThat(all)
                .filteredOn(c -> c.contains("ampleToDummyTypeMap"))
                .hasSize(1);
    }

}

このテストを実行すると成功しました。別テストクラスに定義した Bean は生成されるようです。

f:id:ksby:20190325170139p:plain

src/test/java/com/example/demo/DemoApplicationTests.java で @Component アノテーション を付加したクラスを @TestConfiguration アノテーションで付加したクラスで囲んでも、

@SpringBootTest
public class DemoApplicationTests {

    ..........

    @TestConfiguration
    static class TestConfig {
        @Component
        static class SampleToDummyTypeMap
                extends TypeMapConfigurer<Sample, Dummy> {

            @Override
            public void configure(TypeMap<Sample, Dummy> typeMap) {
                typeMap.setPreConverter(context -> {
                    Sample sample = context.getSource();
                    Dummy dummy = context.getDestination();

                    dummy.setKey(sample.getKey() + ":" + sample.getValue());

                    return context.getDestination();
                });

                typeMap.addMappings(mapping -> mapping.skip(Dummy::setKey));
            }

        }
    }

    ..........

}

SampleControllerTest クラスのテストは成功します。Bean が生成されます。

f:id:ksby:20190325170636p:plain

ここから更に DemoApplication クラスの @ComponentScan("com.example.demo")コメントアウトして DemoApplicationTests、SampleControllerTest クラスのテストを実行すると、今度は DemoApplicationTests の方だけ成功するようになります。SampleControllerTest のテストを実行した時には DemoApplicationTests$SampleToDummyTypeMap クラスは Bean として登録されません。

f:id:ksby:20190325170951p:plain

まとめ

  • @ComponentScan アノテーションは出来るだけ記述しない。
  • @ComponentScan アノテーションを明記する時は @SpringBootApplication のソースを見て同じ excludeFilters を設定する。
  • テストクラス内で Bean を定義したい時には @TestConfiguration アノテーションを付加したクラス内で定義する。

履歴

2019/03/26
初版発行。