SpringBoot Executable Entry Point
Spring Boot の実行入口と ApplicationRunner のテストメモ
Spring Boot の CLI batch では、main() は Spring を起動する入口で、 実際の batch 処理は ApplicationRunner に置くことが多い。
1
2
3
public static void main(String[] args) {
SpringApplication.exit(SpringApplication.run(Main.class, args));
}
ざっくり流れはこうなる。
- Spring Boot を起動する
- Spring アプリケーションコンテナ(ApplicationContext)を作る
- bean をスキャンする
- auto-configuration を適用する
- startup runner を実行する
- Spring アプリケーションコンテナを閉じる
ApplicationRunner を実行入口にする
1
2
3
4
5
6
7
8
9
@Component
@ConditionalOnBooleanProperty(prefix = "app.reminder.runner", name = "enabled", matchIfMissing = true)
public class ReminderRunner implements ApplicationRunner {
@Override
public void run(@NonNull ApplicationArguments args) {
reminderService.runToday();
}
}
ApplicationRunner は Spring Boot 起動後に呼ばれる bean。 つまり、このクラスが Spring bean として登録されると、 アプリケーション起動時に run() が呼ばれる。
app.reminder.runner.enabled は boolean の起動スイッチになる。
- 未指定: 有効
true: 有効false: 無効
CLI batch が「起動したら必ず実行する」設計なら、 本番実行のためだけに @ConditionalOnBooleanProperty が必須というわけではない。 ただし、テストや一時的な起動で runner を作らない選択肢を残したいなら、この annotation は役に立つ。
1
2
3
4
5
@SpringBootTest(properties = {
"app.reminder.runner.enabled=false"
})
class TsnoticereminderApplicationTests {
}
@ConditionalOnBooleanProperty がなければ、 上の app.reminder.runner.enabled=false は runner の登録には効かない。
@SpringBootTest が Spring アプリケーションコンテナ(ApplicationContext)を作ったあと、 ApplicationRunner bean は Spring Boot から呼ばれる。 そのため、テスト起動時にも runner の run() が実行される。
起こり得ることは次のようなもの。
- Spring アプリケーションコンテナを作れるかだけ確認したいのに、runner の処理も動く
- datasource、SMTP、その他の外部設定がなくてテストが失敗する
- テスト環境が外部サービスにつながっている場合、テストから起動したくない処理が動く
整理の方向はだいたい 2 つ。
@ConditionalOnBooleanPropertyを残す: テストではapp.reminder.runner.enabled=falseで runner を無効にする@ConditionalOnBooleanPropertyを外す: 起動したら必ず runner が動く設計にして、テストでは runner の依存先を差し替える
後者なら、例えばこういう形になる。
1
2
3
4
5
6
7
8
9
10
@SpringBootTest
class MyApplicationTests {
@MockitoBean
private ReminderService reminderService;
@Test
void contextLoads() {
}
}
この場合、Spring アプリケーションコンテナ起動時に runner は呼ばれる。 ただし呼び出される service は test double なので、外部資源には触れない。
DAO、service、設定 binding だけを見たい場合は、 そもそも full の @SpringBootTest ではなく slice test に分けるほうが読みやすい。
空の contextLoads() は何を見ているのか
contextLoads() はよく空で書かれる。
1
2
3
@Test
void contextLoads() {
}
これは method body の中で assert しているわけではない。 本体は、@SpringBootTest が Spring アプリケーションコンテナを作るところにある。
Spring が bean を作れない、または constructor injection の依存先を解決できない場合、 contextLoads() に入る前にテストは失敗する。
簡略化した例で見る。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@SpringBootApplication
@ConfigurationPropertiesScan
public class Main {
}
@ConfigurationProperties(prefix = "app.reminder")
public record ReminderProperties(boolean dryRun) {
}
@Configuration(proxyBeanMethods = false)
class ClockConfig {
@Bean
Clock clock() {
return Clock.systemDefaultZone();
}
}
@Service
class AcademicYearService {
AcademicYearService(Clock clock) {
}
}
@Repository
class ReminderSettingDao {
ReminderSettingDao(JdbcTemplate jdbcTemplate) {
}
}
@Service
class ReminderService {
ReminderService(
ReminderProperties properties,
AcademicYearService academicYearService,
ReminderSettingDao settingDao
) {
}
}
そしてテスト側で外部資源の test double を渡す。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@SpringBootTest(properties = {
"app.reminder.runner.enabled=false",
"spring.autoconfigure.exclude=org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration"
})
class MyApplicationTests {
@Test
void contextLoads() {
}
@TestConfiguration
static class ExternalResourceTestConfig {
@Bean
JdbcTemplate jdbcTemplate() {
return mock(JdbcTemplate.class);
}
}
}
このテストは次の項目を個別に assert しているわけではない。
ReminderPropertiesが binding できるかClockConfigがClockを提供できるかAcademicYearServiceがClockを受け取れるか- DAO がテスト用
JdbcTemplateを受け取れるか ReminderServiceが constructor の依存先を受け取れるか
ただし Spring はアプリケーションコンテナを作る過程で、 これらを実際に解決しようとする。 どこか 1 つでもつながらなければ、Spring アプリケーションコンテナを作れず、テストは失敗する。
Spring アプリケーションコンテナ起動テストは MainTest ではない
Spring Boot のアプリケーションコンテナ起動テストは、 通常 MainTest とは呼ばない。
@SpringBootTest はデフォルトでは main() method を呼ばない。 @SpringBootConfiguration / @SpringBootApplication を探して、 ApplicationContext を作る。
そのため、この種のテストはだいたいこういう名前になる。
1
2
3
4
5
6
7
@SpringBootTest
class MyApplicationTests {
@Test
void contextLoads() {
}
}
これは Spring アプリケーションコンテナを作れるかを見るテストであって、 次の main() の振る舞いを直接テストしているわけではない。
1
2
3
public static void main(String[] args) {
SpringApplication.exit(SpringApplication.run(Main.class, args));
}
MainTest という名前にすると、main() 自体をテストしているように見えやすい。 そのため、MyApplicationTests のような名前のほうが Spring Boot の慣例に近い。 class doc には、Spring アプリケーションコンテナ起動テストであることを書いておくと読みやすい。
Runner 専用テストは必要か
@SpringBootTest の空の contextLoads() は、 runner の中身をテストしているわけではない。 また、app.reminder.runner.enabled=false で runner を無効にしているなら、 そのテストでは runner 自体も作られない。
そのため、coverage を見ると runner や Main が低く見えることがある。 これは application context test の性質としては自然。
ただし runner に少しでも判断や例外処理、ログ出力、戻り値の扱いがあるなら、 runner 専用の小さな単体テストを足すと読みやすくなる。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@ExtendWith(MockitoExtension.class)
class ReminderRunnerTest {
@Mock
private ReminderService reminderService;
@InjectMocks
private ReminderRunner runner;
@Test
void runCallsReminderService() {
when(reminderService.runToday()).thenReturn(List.of());
runner.run(mock(ApplicationArguments.class));
verify(reminderService).runToday();
}
}
これは Spring Boot の起動確認ではなく、 ReminderRunner が ReminderService に処理を渡すことだけを見る単体テスト。 必須ではないが、coverage 上の穴を埋めたい場合や、 runner の責務を明示したい場合には足してよい。