如何测试Spring的@Scheduled?

49

我该如何在我的Spring Boot应用程序中测试@Scheduled作业任务?

 package com.myco.tasks;

 public class MyTask {
     @Scheduled(fixedRate=1000)
     public void work() {
         // task execution logic
     }
 }

9
您想要测试什么?如果您想测试work()方法是否按照预期执行,您可以像测试其他bean的方法一样进行测试:创建一个bean实例,调用该方法,并测试它是否按照预期执行。如果您想测试Spring是否确实每秒调用该方法,这没有实际意义:Spring已经为您测试过了。 - JB Nizet
我同意你的观点,尝试测试框架的功能对我来说似乎并不必要,但是我被要求这样做。我找到了一个解决方法,通过添加一个小的日志消息并检查预期的消息是否在预期的时间范围内记录下来来绕过这个问题。 - S Puddin
5
测试的另一个好处是,如果删除了@EnableScheduling注解,就会有一个失败的测试。 - C-Otto
7个回答

45

如果我们假设你的工作运行在非常短的时间间隔内,你确实希望测试等待作业执行,并且只想测试作业是否被调用,那么可以使用以下解决方案:

Awaitility添加到类路径中:

<dependency>
    <groupId>org.awaitility</groupId>
    <artifactId>awaitility</artifactId>
    <version>3.1.0</version>
    <scope>test</scope>
</dependency>

编写类似于以下测试:

@RunWith(SpringRunner.class)
@SpringBootTest
public class DemoApplicationTests {

    @SpyBean
    private MyTask myTask;

    @Test
    public void jobRuns() {
        await().atMost(Duration.FIVE_SECONDS)
               .untilAsserted(() -> verify(myTask, times(1)).work());
    }
}

1
verify()times()函数找不到。您能指定包名吗? - LiTTle
1
这些函数来自于Mockito。包名为:org.mockito.Mockito#verifytimes同理。 - Maciej Walkowiak
1
这不是一个好的解决方案。它只适用于在几秒钟内执行的@Scheduled。那么每周执行怎么办? - Cristian Batista
1
@CristianBatista “如果我们假设您的工作运行在如此短的时间间隔内”,我认为测试工作是否运行并不是很有意义,而是测试工作的行为。尽管如此,如果您真的想要这样做,那是我知道的选项之一。欢迎您也提交您的答案 :-) - Maciej Walkowiak
3
你可以使用属性而不是硬编码来测试cron作业的不同频率。 - Niccolò

36

我的问题是:“你想测试什么?”

如果你的答案是“我想知道Spring在我想要的时候运行我的定时任务”,那么你正在测试Spring而不是你的代码。 这不是你需要进行单元测试的内容。

如果你的答案是“我想知道我正确地配置了我的任务”,那么请编写一个频繁运行任务的测试应用程序,并验证任务在你期望的时间运行。 这不是一个单元测试,但将显示您知道如何正确配置您的任务。

如果答案是“我想知道我编写的任务功能是否正确”,那么您需要对任务方法进行单元测试。 在你的例子中,你想对work()方法进行单元测试。 通过编写直接调用任务方法(work())的单元测试来实现这一点。 例如:

public class TestMyTask
{
  @InjectMocks
  private MyTask classToTest;

  // Declare any mocks you need.
  @Mock
  private Blammy mockBlammy;

  @Before
  public void preTestSetup()
  {
    MockitoAnnotations.initMocks(this);

    ... any other setup you need.
  }

  @Test
  public void work_success()
  {
    ... setup for the test.


    classToTest.work();


    .. asserts to verify that the work method functioned correctly.
  }

是的,选择第二个,因为配置很容易被忽略(或不正确地了解)。 - Code Name Jack

5

@Maciej提供的答案解决了问题,但未解决测试@Scheduled使用过长间隔(例如几小时)的难点,正如@cristian-batista所提到的。

为了独立于实际调度间隔测试@Scheduled,我们需要从测试中使其可参数化。幸运的是,Spring添加了一个fixedRateString参数用于此目的

以下是一个完整的示例:

public class MyTask {
     // Control rate with property `task.work.rate` and use 3600000 (1 hour) as a default:
     @Scheduled(fixedRateString = "${task.work.rate:3600000}")
     public void work() {
         // task execution logic
     }
 }

使用awaitility进行测试:

@RunWith(SpringRunner.class)
@SpringBootTest
// Override the scheduling rate to something really short:
@TestPropertySource(properties = "task.work.rate=100") 
public class DemoApplicationTests {

    @SpyBean
    private MyTask myTask;

    @Test
    public void jobRuns() {
        Awaitility.await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
            verify(myTask, Mockito.atLeastOnce()).work()
        );
    }
}

3

这通常很困难。您可以考虑在测试期间加载Spring上下文,并伪造其中的一些bean,以便能够验证计划的调用。

我在我的Github存储库中有这样的示例。这是一个简单的计划示例,使用了上述方法进行测试。


6
仅等待预定任务绝对不是正确的方式。应该有一个窍门来操作时钟,以便调度程序可以做出响应。 - rohit
3
@rohit,欢迎发布你的解决方案。如果你不这样做,我会认为你没有解决方案。 - luboskrnac

2

这个类是使用Spring Framework调度生成cron表达式的。

import org.apache.log4j.Logger;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.scheduling.support.CronSequenceGenerator;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.AbstractJUnit4SpringContextTests;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;

@RunWith(SpringJUnit4ClassRunner.class)
@Configuration
@PropertySource("classpath:application.properties")
public class TrimestralReportSenderJobTest extends AbstractJUnit4SpringContextTests {

    protected Logger LOG = Logger.getLogger(getClass());

    private static final String DATE_CURRENT_2018_01_01 = "2018-01-01";
    private static final String SCHEDULER_TWO_MIN_PERIOD = "2 0/2 * * * *";
    private static final String SCHEDULER_QUARTER_SEASON_PERIOD = "0 0 20 1-7 1,4,7,10 FRI";

    @Test
    public void cronSchedulerGenerator_0() {
        cronSchedulerGenerator(SCHEDULER_QUARTER_SEASON_PERIOD, 100);
    }

    @Test
    public void cronSchedulerGenerator_1() {
        cronSchedulerGenerator(SCHEDULER_TWO_MIN_PERIOD, 200);
    }

    public void cronSchedulerGenerator(String paramScheduler, int index) {
        CronSequenceGenerator cronGen = new CronSequenceGenerator(paramScheduler);
        java.util.Date date = java.sql.Date.valueOf(DATE_CURRENT_2018_01_01);

        for (int i = 0; i < index; i++) {
            date = cronGen.next(date);
            LOG.info(new java.text.SimpleDateFormat("EEE, MMM d, yyyy 'at' hh:mm:ss a").format(date));
        }

    }
}

以下是输出日志:

<com.medici.scheduler.jobs.TrimestralReportSenderJobTest> - lun, gen 1, 2018 at 12:02:02 AM
<com.medici.scheduler.jobs.TrimestralReportSenderJobTest> - lun, gen 1, 2018 at 03:02:02 AM
<com.medici.scheduler.jobs.TrimestralReportSenderJobTest> - lun, gen 1, 2018 at 06:02:02 AM
<com.medici.scheduler.jobs.TrimestralReportSenderJobTest> - lun, gen 1, 2018 at 09:02:02 AM
<com.medici.scheduler.jobs.TrimestralReportSenderJobTest> - lun, gen 1, 2018 at 12:02:02 PM

1
CronSequenceGenerator已于5.3版本被弃用,现在推荐使用CronExpression,请查看此示例中org.springframework.scheduling.support.CronTrigger的用法:https://dev59.com/BmQm5IYBdhLWcg3wyhnk#33504624 - DependencyHell

2
我们可以使用至少两种方法来测试Spring中的定时任务:
  • 集成测试
如果我们使用Spring Boot,我们需要以下依赖项:
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
 
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
</dependency>

我们可以在Task中添加一个计数器count,并在work方法内将其递增:
 public class MyTask {
   private final AtomicInteger count = new AtomicInteger(0);
   
   @Scheduled(fixedRate=1000)
   public void work(){
     this.count.incrementAndGet();
   }

   public int getInvocationCount() {
    return this.count.get();
   }
 }

然后检查 count
@SpringJUnitConfig(ScheduledConfig.class)
public class ScheduledIntegrationTest {
 
    @Autowired
    MyTask task;

    @Test
    public void givenSleepBy100ms_whenWork_thenInvocationCountIsGreaterThanZero() 
      throws InterruptedException {
        Thread.sleep(2000L);

        assertThat(task.getInvocationCount()).isGreaterThan(0);
    }
}

另一个选择是使用像 @maciej-walkowiak 提到的 Awaitility。
在这种情况下,我们需要添加 Awaitility 依赖项:
<dependency>
    <groupId>org.awaitility</groupId>
    <artifactId>awaitility</artifactId>
    <version>3.1.6</version>
    <scope>test</scope>
</dependency>

使用其DSL检查方法work的调用次数:
@SpringJUnitConfig(ScheduledConfig.class)
public class ScheduledAwaitilityIntegrationTest {

    @SpyBean 
    MyTask task;

    @Test
    public void whenWaitOneSecond_thenWorkIsCalledAtLeastThreeTimes() {
        await()
          .atMost(Duration.FIVE_SECONDS)
          .untilAsserted(() -> verify(task, atLeast(3)).work());
    }
}

我们需要注意,虽然它们很好,但更好的是专注于工作方法内部逻辑的单元测试。
我在这里放了一个例子here
此外,如果您需要测试像“*/15 * 1-4 * * *”这样的CRON表达式,可以使用CronSequenceGenerator(链接)
@Test
public void at50Seconds() {
    assertThat(new CronSequenceGenerator("*/15 * 1-4 * * *").next(new Date(2012, 6, 1, 9, 53, 50))).isEqualTo(new Date(2012, 6, 2, 1, 0));
}

你可以在官方代码库中找到更多的例子。


0
首先,在这里发布了一些很棒的答案和评论,但我想补充一些总结和你可以使用的另一种方法。
1. 为什么你可能想要测试它?
确实,没有理由去测试Spring的机制。然而,还有另一面。为了使整个调度机制正常工作,您需要在配置级别上使用@EnableScheduling注解或者使用@Scheduled注解+一个cron表达式。有很多地方可能出错。想象一下,有人不小心删除了其中一个注解,或者将@Scheduled放在了错误的方法上。从技术角度来看,Spring会验证cron表达式,但并不从业务角度进行验证。
2. 如何测试它?
我们不需要这个测试变得复杂,并在其中测试业务逻辑,业务逻辑已经在其他测试中测试过了。我们将专注于测试调度器是否正常工作的事实。
选项1. 使用Spy Bean和等待机制
添加以下依赖:
<dependency>
    <groupId>org.awaitility</groupId>
    <artifactId>awaitility</artifactId>
    <version>${awaitility.version}</version>
    <scope>test</scope>
</dependency>


 package com.myco.tasks;

 public class MyTask {

     @Scheduled(fixedRate = ${app.scheduler.rate})
     public void work() {
         // task execution logic
     }
 }

@SpringBootTest
public class SchedulerTest {

    @SpyBean
    private MyTask myTask;

    @Test
    public void jobRuns() {
        Awaitility.await()
                .atMost(Durations.FIVE_SECONDS)
                .untilAsserted(() -> Mockito.verify(myTask, Mockito.atLeast(1)).work());
    }
}

Mockito 在这里对bean进行监视,Awaitility 进行轮询并设置超时时间。你可以在这里使用简单的 Thread.sleep(),但是测试会变得更慢且不稳定,你要么需要等待更长时间,要么慢速可能会导致测试意外失败。

重要提示:你必须使用可配置的值来设置调度表达式,如果你的真实配置以小时或天为单位,那么测试配置应该以秒为单位。

优点:

  • 编写简单
  • 测试实际的bean调用

缺点:

  • 无法测试实际的表达式,测试配置可能与实际配置不同,特别是在调度大间隔的情况下。
  • 使用非常短的间隔可能会干扰其他Spring测试,有可能调度器会启动。可以通过为此测试使用专用配置文件来避免。

选项2. 任务调度持有者

 package com.myco.tasks;

 public class MyTask {

     @Scheduled(cron = "${app.scheduler.cron}")
     public void work() {
         // task execution logic
     }
 }

@SpringBootTest
public class SchedulerTest {

    @Value("${app.scheduler.cron}")
    private String expectedCronExpression;

    @Autowired
    private ScheduledTaskHolder taskHolder;

    @Test
    public void cronTaskIsScheduled() {
        CronTask cronTask = taskHolder.getScheduledTasks()
                .stream()
                .map(ScheduledTask::getTask)
                .filter(CronTask.class::isInstance)
                .map(CronTask.class::cast)
                .findFirst()
                .orElseThrow(() -> new IllegalStateException("No scheduled tasks"));

        assertEquals(cronExpression, cronTask.getExpression());
        assertTrue(cronTask.toString().contains("tasks.MyTask.work"));
    }
}

注入任务持有者并通过断言表达式和类名+方法名来断言是否有计划任务。这个例子是针对CronTask的,但可以很容易地适应OP示例,只需使用FixedRateTask即可。
重要提示:自Spring 5.0.2起,可以使用ScheduledTaskHolder,如果您使用的是Spring 3或更高版本,则可以直接注入ScheduledAnnotationBeanPostProcessor
优点:
- 可以测试实际的精确cron表达式
缺点:
- 不测试bean调用 - 更难重构:类名、方法名(以及包名,如果在断言中包含)的更改需要手动调整。
如果您正在测试一个非常关键的调度程序,可以结合两种方法。我建议为测试使用不同的配置文件。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接