Post

SpringBoot Executable Entry Point

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));
}

ざっくり流れはこうなる。

  1. Spring Boot を起動する
  2. Spring アプリケーションコンテナ(ApplicationContext)を作る
  3. bean をスキャンする
  4. auto-configuration を適用する
  5. startup runner を実行する
  6. 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() が実行される。

起こり得ることは次のようなもの。

  1. Spring アプリケーションコンテナを作れるかだけ確認したいのに、runner の処理も動く
  2. datasource、SMTP、その他の外部設定がなくてテストが失敗する
  3. テスト環境が外部サービスにつながっている場合、テストから起動したくない処理が動く

整理の方向はだいたい 2 つ。

  1. @ConditionalOnBooleanProperty を残す: テストでは app.reminder.runner.enabled=false で runner を無効にする
  2. @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 しているわけではない。

  1. ReminderProperties が binding できるか
  2. ClockConfigClock を提供できるか
  3. AcademicYearServiceClock を受け取れるか
  4. DAO がテスト用 JdbcTemplate を受け取れるか
  5. 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 の起動確認ではなく、 ReminderRunnerReminderService に処理を渡すことだけを見る単体テスト。 必須ではないが、coverage 上の穴を埋めたい場合や、 runner の責務を明示したい場合には足してよい。

This post is licensed under CC BY 4.0 by the author.