Spring Boot 2.0.x の Web アプリを 2.1.x へバージョンアップする ( 番外編 )( テストクラス内の @Component を付与したクラスを Bean として登録するには? )
概要
記事一覧はこちらです。
Spring Boot 2.1 へのバージョンアップとは全然関係ないのですが、Spring Boot + npm + Geb で入力フォームを作ってテストする ( 番外編 )( ModelMapper メモ書き ) で書いた ModelMapper のテストクラスと似たようなテストを新規プロジェクトを作成して実行したら失敗したのに、そのテストを ksbysample-webapp-lending プロジェクトにコピーして実行したら成功して原因が分からなかったので、その調査をした時のメモ書きです。
参照したサイト・書籍
目次
- 作成した新規プロジェクトと、どのようなテストを実行しようとして動作しなかったのか?
- テストクラスを ksbysample-webapp-lending プロジェクトにコピーして実行するとどうなるのか?
- 調べてみると @ComponentScan アノテーションを明記するとテストが成功する
@ComponentScan("com.example.demo")を書いた時と書かなかった時で何が違うのか?- ちょっと動作確認
- まとめ
手順
作成した新規プロジェクトと、どのようなテストを実行しようとして動作しなかったのか?
新規プロジェクトを 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"); } }
テストを実行してみると失敗しました。

@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"); } }
実行してみましたが、確かに登録されていませんでした。

テストクラスを 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 テストクラスをコピーして実行してみると、なぜか成功します。

調べてみると @ComponentScan アノテーションを明記するとテストが成功する
いろいろ試してみたところ、@ComponentScan("com.example.demo") を以下のように明記すると、

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

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

@ComponentScan("com.example.demo") なら書いても書かなくても同じはずと考えていましたが、何かが違うようです。
@ComponentScan("com.example.demo") を書いた時と書かなかった時で何が違うのか?
IntelliJ IDEA の Find in Path で @ComponentScan で調べてみると org.springframework.context.annotation.ConfigurationClassParser#doProcessConfigurationClass に // Process any @ComponentScan annotations というコメントが書かれていました。ここで処理をしているようです。

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

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

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

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

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

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

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

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

ちょっと動作確認
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 として登録されないようです。

別のテストクラスを実行した時には 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 は生成されるようです。

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 が生成されます。

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

まとめ
- @ComponentScan アノテーションは出来るだけ記述しない。
- @ComponentScan アノテーションを明記する時は @SpringBootApplication のソースを見て同じ excludeFilters を設定する。
- テストクラス内で Bean を定義したい時には @TestConfiguration アノテーションを付加したクラス内で定義する。
履歴
2019/03/26
初版発行。