1. 引言

本文将深入探讨测试使用定时任务的Spring应用时可能遇到的问题。当项目中大量使用定时任务时,编写测试(尤其是集成测试)可能会成为开发者的噩梦。我们将介绍几种解决方案,确保测试的稳定性和可靠性。

2. 示例场景

假设我们有一个企业通知系统,允许公司代表向客户发送通知。其中部分通知需要立即发送,而部分则需要等到下一个工作日。因此我们需要一个定时任务机制来周期性处理这些通知:

public class DelayedNotificationScheduler {
    private NotificationService notificationService;

    @Scheduled(fixedDelayString = "${notification.send.out.delay}", initialDelayString = "${notification.send.out.initial.delay}")
    public void attemptSendingOutDelayedNotifications() {
        notificationService.sendOutDelayedNotifications();
    }
}

关键点: attemptSendingOutDelayedNotifications() 方法使用了 @Scheduled 注解。该方法首次执行时间由 initialDelayString 控制,后续执行间隔由 fixedDelayString 控制。实际业务逻辑委托给了 NotificationService

要启用定时任务,**需要在 @Configuration 注解的类上添加 @EnableScheduling**。虽然这是基础配置,但后续我们会看到如何避免它对测试产生负面影响。

3. 集成测试中的定时任务问题

先看一个基础的集成测试示例:

@SpringBootTest(
  classes = { ApplicationConfig.class, SchedulerTestConfiguration.class },
  properties = {
      "notification.send.out.delay: 10",
      "notification.send.out.initial.delay: 0"
  }
)
public class DelayedNotificationSchedulerIntegrationTest {
    @Autowired
    private Clock testClock;

    @Autowired
    private NotificationRepository repository;

    @Autowired
    private DelayedNotificationScheduler scheduler;

    @Test
    public void whenTimeIsOverNotificationSendOutTime_thenItShouldBeSent() {
        ZonedDateTime fiveMinutesAgo = ZonedDateTime.now(testClock).minusMinutes(5);
        Notification notification = new Notification(fiveMinutesAgo);
        repository.save(notification);

        scheduler.attemptSendingOutDelayedNotifications();

        Notification processedNotification = repository.findById(notification.getId());
        assertTrue(processedNotification.isSentOut());
    }
}

@TestConfiguration
class SchedulerTestConfiguration {
    @Bean
    @Primary
    public Clock testClock() {
        return Clock.fixed(Instant.parse("2024-03-10T10:15:30.00Z"), ZoneId.systemDefault());
    }
}

注意:@EnableScheduling 直接应用在 ApplicationConfig 类上,该类负责创建测试中所有自动装配的Bean。

运行测试后观察日志输出:

2024-03-13T00:17:38.637+01:00  INFO 4728 --- [pool-1-thread-1] c.b.d.DelayedNotificationScheduler       : Scheduled notifications send out attempt
2024-03-13T00:17:38.637+01:00  INFO 4728 --- [pool-1-thread-1] c.b.d.NotificationService                : Sending out delayed notifications
2024-03-13T00:17:38.644+01:00  INFO 4728 --- [           main] c.b.d.DelayedNotificationScheduler       : Scheduled notifications send out attempt
2024-03-13T00:17:38.644+01:00  INFO 4728 --- [           main] c.b.d.NotificationService                : Sending out delayed notifications
2024-03-13T00:17:38.647+01:00  INFO 4728 --- [pool-1-thread-1] c.b.d.DelayedNotificationScheduler       : Scheduled notifications send out attempt
2024-03-13T00:17:38.647+01:00  INFO 4728 --- [pool-1-thread-1] c.b.d.NotificationService                : Sending out delayed notifications

⚠️ 问题分析:

  • attemptSendingOutDelayedNotifications() 被多次调用
  • 一次来自 main 线程(测试直接调用)
  • 多次来自 pool-1-thread-1 线程(Spring定时任务线程池)

虽然测试通过了,但方法被多次执行是个危险的信号。 在更复杂的场景下,这会导致测试不稳定。理想情况下,测试应该完全可控,只执行我们显式调用的代码。

4. 在集成测试中禁用定时任务

以下是几种确保测试隔离性的解决方案,与条件启用定时任务的思路类似,但专门针对测试场景优化。

4.1. 基于Profile条件启用配置

@EnableScheduling 抽取到独立配置类,并通过Profile控制加载。 例如在 integrationTest 激活时禁用定时任务:

@Configuration
@EnableScheduling
@Profile("!integrationTest")
public class SchedulingConfig {
}

测试端只需激活对应Profile:

@SpringBootTest(
  classes = { ApplicationConfig.class, SchedulingConfig.class, SchedulerTestConfiguration.class },
  properties = {
      "notification.send.out.delay: 10",
      "notification.send.out.initial.delay: 0"
  }
)
@ActiveProfiles("integrationTest")

效果: 所有标记 @ActiveProfiles("integrationTest") 的测试类都会自动禁用定时任务。

4.2. 基于属性条件启用配置

另一种方案是通过属性值控制配置加载。 修改配置类如下:

@Configuration
@EnableScheduling
@ConditionalOnProperty(value = "scheduling.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig {
}

现在定时任务依赖 scheduling.enabled 属性。测试中只需禁用该属性:

@SpringBootTest(
  classes = { ApplicationConfig.class, SchedulingConfig.class, SchedulerTestConfiguration.class },
  properties = {
      "notification.send.out.delay: 10",
      "notification.send.out.initial.delay: 0",
      "scheduling.enabled: false"
  }
)

效果: 与Profile方案效果相同,但更适合需要动态控制的场景。

4.3. 调整定时任务配置参数

最后一种方案是延长定时任务的初始延迟时间,确保测试执行完成前Spring不会触发定时任务:

@SpringBootTest(
  classes = { ApplicationConfig.class, SchedulingConfig.class, SchedulerTestConfiguration.class },
  properties = {
      "notification.send.out.delay: 10",
      "notification.send.out.initial.delay: 60000" // 60秒延迟
  }
)

缺点:

  • 这是最后的无奈之选,当前两种方案不可用时才考虑
  • 引入了时间依赖,可能导致测试不稳定
  • 在CI服务器负载高时容易失败
  • 违背了测试应该快速、可靠的原则

⚠️ 建议: 除非有特殊约束,否则优先使用前两种方案。

5. 总结

本文探讨了在测试使用定时任务的Spring应用时的几种配置方案:

核心问题: 让定时任务与集成测试同时运行会导致测试不稳定(flaky tests)。

推荐方案:

  1. ✅ 将 @EnableScheduling 抽取到独立配置类
  2. ✅ 通过Profile或属性条件控制加载
  3. ❌ 避免使用延长延迟时间的方案(除非别无选择)

最佳实践: 测试环境应完全隔离定时任务的自动执行,确保测试结果可预测、可重复。所有示例代码可在GitHub仓库中获取。


原始标题:Disable @EnableScheduling on Spring Tests