在 Spring 测试中禁用 @EnableScheduling

1、简介

本文将带你了解如何测试启用了定时任务(@EnableScheduling)的 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 配置的时间过去后,该方法将被首次调用。执行结束后,Spring 会在 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

这是因为应用在启动过程中初始化了调度任务。它们会在属于独立线程池的线程中定期调用调定时任务。这就是为什么我们能看到来自 pool-1-thread-1 的方法调用。而,来自 main 线程的调用是我们在集成测试中直接调用的。

测试通过了,但任务被调用了多次。这只是一个代码问题,但在不太幸运的情况下,它可能会导致测试不稳定。我们的测试应该尽可能明确和隔离。因此,应该引入修复措施,确保调度程序只在我们直接调用它的时候被调用。

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

接下来看看在集成测试中禁用定时任务的一些方法,这些方法类似于允许我们在 Spring 应用中有条件地启用定时任务的方法,但针对集成测试中的使用进行了调整。

4.1、根据 Profile 启用 @EnableScheduling 配置类

首先,可以将配置中支持调度的部分提取到另一个配置类中。然后,可以根据激活的 Profile 有条件地应用它。在本例中,我们希望在 integrationTest Profile 处于激活状态时禁用调度:

@Configuration
@EnableScheduling
@Profile("!integrationTest") // 非 integrationTest Profile 时激活配置类
public class SchedulingConfig {
}

在集成测试中,唯一需要做的就是启用上述 Profile:

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

通过这种设置,可以确保在 elayedNotificationSchedulerIntegrationTest 中定义的所有测试执行期间,调度功能被禁用,任何定时任务都不会执行。

4.2、基于配置属性启用配 @EnableScheduling 配置类

另一种类似的方法是根据属性值为应用启用调度。使用 @ConditionalOnProperty 注解根据不同的配置属性值来启用配置类:

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

现在,调度取决于 scheduling.enabled 属性的值。如果我们有意地将其设置为 false,Spring 就不会加载 SchedulingConfig 配置类。

集成测试方面所需的更改微乎其微:

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

4.3、微调定时任务配置

最后,可以稍微修改一下定时任务的配置。可以为它们设置很长的初始延迟时间,这样集成测试在 Spring 尝试执行任何周期性操作之前就有足够的时间来执行:

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

只需设置 60 秒的初始延迟。在不影响 Spring 管理的定时任务的情况下,集成测试应该有足够的时间通过。

不过,需要注意的是,这是在无法引入前述选项的情况下不得已而为之的做法。最好的做法是避免在代码中引入任何与时间相关的依赖。测试有时需要更长的时间来执行,这有很多原因,例如:过度使用的 CI 服务器。在这种情况下,项目中可能会出现不稳定的测试。

5、总结

本文介绍了在集成测试中禁用 Spring 定时任务的几种方式。


Ref:https://www.baeldung.com/spring-test-disable-enablescheduling