1. Introduction

In this tutorial, we’ll dive into the topic of testing Spring applications which utilize scheduled tasks. Their extensive usage might cause a headache when we try to develop tests, especially integration ones. We’ll talk about possible options to make sure that they’re as stable as they can be.

2. Example

Let’s start with a short explanation of the example we’ll be using throughout the article. Let’s imagine a system that allows the representative of the company to send notifications to their clients. Some of them are time-sensitive and should be delivered immediately, but some should wait until the next business day. Therefore, we need a mechanism that will periodically attempt to send them:

public class DelayedNotificationScheduler {
    private NotificationService notificationService;

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

We can spot the @Scheduled annotation on the attemptSendingOutDelayedNotifications() method. The method will be invoked for the first time when the time configured by the initialDelayString passes. Once the execution ends, Spring invokes it again after the time configured by the fixedDelayString parameter. The method itself delegates the actual logic to NotificationService. 

Of course, we also need to turn on the scheduling. We do this by applying the @EnableScheduling annotation on the class annotated with @Configuration. Although crucial, we won’t dive into it here, because it’s tightly connected to the main topic. Later on, we’ll see a couple of ways how we can do it in a way that doesn’t negatively interfere with tests.

3. Problems With Scheduled Tasks in Integration Tests

First, let’s write a basic integration test for our notification application:

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

It’s important to mention that the @EnableScheduling annotation was simply applied to the ApplicationConfig class, which is also responsible for creating all additional beans we autowire in the test.

Let’s run this test and see the produced logs:

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

Analyzing the output, we can spot that the attemptSendingOutDelayedNotifications() method has been invoked more than once.

One invocation comes from the main thread, while the others from the pool-1-thread-1 one.

We can observe this behavior because the application initialized scheduled tasks during startup. They periodically invoke our scheduler in the threads belonging to the separate thread pool. That’s why we can see method calls coming from the pool-1-thread-1. On the other hand, the call coming from the main thread is one that we directly called in the integration test.

The test passed, but the action was invoked multiple times. It’s just a code smell here, but it may lead to flaky tests in less fortunate circumstances. Our tests should be as explicit and isolated as possible. Therefore, we should introduce the fix reassuring us that the only time when the scheduler is invoked is the time when we directly call it.

4. Disabling Scheduled Tasks for Integration Tests

Let’s consider what we can do to make sure that during the test, only the code we’d like to execute is being executed. The approaches we’re going to go through are similar to the ones allowing us to conditionally enable scheduled jobs in Spring applications but are adjusted for usage in integration tests.

4.1. Enabling the Configuration Annotated With @EnableScheduling Based on Profile

First, we can extract the part of the configuration enabling scheduling to another configuration class. Then, we can apply it conditionally based on the active profile. In our case, we’d like to disable scheduling when the integrationTest profile is active:

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

On the integration test side, the only thing we need to do is to enable the mentioned profile:

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

This setup allows us to make sure that during the execution of all tests defined in the DelayedNotificationSchedulerIntegrationTest, the scheduling is disabled and no code will be executed automatically as a scheduled task.

4.2. Enabling the Configuration Annotated With @EnableScheduling Based on Property

Another, but still similar approach, is to enable the scheduling for the application based on the property value. We can use the already extracted configuration class and apply it based on the different condition:

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

Now, the scheduling is dependent on the value of the scheduling.enabled property. If we consciously set it to false, Spring won’t pick up the SchedulingConfig configuration class. Changes required on the integration test side are minimal:

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

The effect is identical to the one we achieved following the previous idea.

4.3. Fine-Tuning the Scheduled Tasks Configuration

The last approach we can take is to carefully fine-tune the configuration of our scheduled tasks. We can set a very long initial delay time for them so that integration tests have a lot of time to be executed before Spring tries to execute any periodic actions:

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

We just set the 60 seconds of the initial delay. There should be enough time for the integration test to pass without interferences with Spring-managed scheduled tasks.

However, we need to note that it’s the action of a last resort when the previously shown options are impossible to be introduced. It’s a good practice to avoid introducing any time-related dependencies to the code. There are many reasons for the test to sometimes take slightly more time to execute. Let’s consider a trivial example of an overutilized CI server. In such a case, we risk having flaky tests in the project.

5. Conclusion

In this article, we’ve discussed different options for configuring integration tests while testing applications that use scheduled tasks mechanisms.

We’ve showcased the consequences of just leaving the scheduler running together with the integration test at the same time. We risk it being flaky.

Next, we proceeded with ideas on how to make sure that scheduling doesn’t negatively impact our tests. It’s a good idea to disable scheduling by extracting the @EnableScheduling annotation to the separate configuration applied conditionally, based either on the profile or the property’s value. When it’s not possible, we can always set a high initial delay for the task executing the logic we’re testing.

All the code presented in this article is available over on GitHub.