Testing Scheduled Jobs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A schedule is code that nobody watches run: the cron expression that is wrong by one day, the job that needs the scheduler’s thread to finish before it is visible, the JDBC table that is missing one column on Oracle, the second node that fires the same job again, all of them are found by the customer on the day after the release. This page is the testing toolbox for the Quartz pages of this section, ordered from the cheapest test to the most expensive: job logic without a scheduler, trigger definitions without a scheduler, the Spring wiring with an in-memory scheduler, the Actuator endpoint, the real JDBC store on five databases, a two-node cluster including a kill -9, and, for comparison, a @Scheduled method guarded by ShedLock. It builds on the job and trigger code of Jobs & Job Data, Triggers & Schedules and Cron Expressions, and the cluster test is the automated version of the manual experiments of Clustering.

It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9, Java 21 (bytecode level; the build ran on JDK 25.0.2 with Maven 3.9.16), JUnit Jupiter 6.0.3, Mockito 5.23.0, Awaitility 4.3.0, Testcontainers 2.0.5 and ShedLock 7.10.1, and verified on 5 October 2026. Every test in this page was compiled and run in a test project on one Apple Silicon laptop with Docker Desktop (Docker 29.8.2); the results quoted under the examples are the real Surefire results. Nothing here is a benchmark: the times show the order of magnitude of each kind of test, not a limit. Spring Boot 3.5.16 manages the same Quartz 2.5.2, but its test packages, its starters, Testcontainers 1.21.4, JUnit 5.12.2 and Spring Framework 6.2.19 differ; the differences are collected in What changed since Spring Boot 3.5 and the page was not re-run on Boot 3.5.

What to test, and at which level

Every question about a schedule has one cheapest place where it can be answered. The mistake that makes scheduler tests slow and flaky is to ask every question with the most expensive tool, a started application and a Thread.sleep.

flowchart TD Q{"What is the question?"} Q -->|"Does the job do the right thing for this data?"| U["Unit test
new job with mocks, call executeInternal
no scheduler, milliseconds"] Q -->|"When will the trigger fire?"| T["Trigger test
TriggerUtils.computeFireTimes, CronExpression
no scheduler, milliseconds"] Q -->|"Are the beans, YAML and job data wired?"| S["SpringBootTest, memory store
auto-startup false, triggerJob, Awaitility
seconds"] Q -->|"What does an operator see?"| A["MockMvc on /actuator/quartz
same context as above"] Q -->|"Do the tables, delegate and serialization work?"| D["Testcontainers JDBC test
one run per database
5 to 15 seconds each"] Q -->|"Does one fire time run on one node, does fail-over work?"| C["Cluster test
two schedulers, a container, a second JVM to kill
about 25 seconds"] Q -->|"Does the @Scheduled plus ShedLock variant lock?"| L["Lock test
LockProvider and two contexts
seconds"]
Figure 1. Which test answers which question
Table 1. The test classes of this page, what each proves and what Surefire reported (5 October 2026)
Class What a failure means Needs Reported time

OverdueReminderJobTest

The job logic or the binding of a job data entry to its setter is wrong

JUnit and Mockito only

4 tests, 0.2 s

BookshelfTriggersTest

A cron expression, time zone, calendar or misfire instruction is not what the schedule says

Quartz classes only

7 tests, 0.04 s

BookshelfSchedulerTest

A bean, a property or the job data did not reach the scheduler, or the job cannot be created by Spring

Spring Boot, memory store

3 tests, 4.6 s

QuartzEndpointTest

What /actuator/quartz reports or accepts changed

Spring Boot and MockMvc

6 tests, 0.4 s

JdbcJobStoreIT

The tables, the delegate or the serialization are wrong on a database

Docker

5 databases, 33.7 s (2.2 s PostgreSQL to 9.4 s Oracle Free)

ClusterIT

A fire time ran twice, or a crashed node’s job was lost

Docker and a second JVM

2 tests, 24.1 s

RetentionCleanupTest

The @SchedulerLock configuration of the @Scheduled variant is wrong

ShedLock, H2

4 tests, 6.2 s

Source: measured in a test run (Surefire reports); see Maven Surefire Plugin. The classes are listed in full below.

OverdueReminderJobTest, BookshelfTriggersTest and the other classes whose name ends in Test are run by mvn test. The classes that need Docker end in IT, which Surefire does not select by default (mvn test ran 27 tests and none of the IT classes); in a real build the Failsafe plugin runs them in the verify phase, and in the test project behind this page they were run by name with -Dtest=JdbcJobStoreIT.

Dependencies

pom.xml (test-related part; the Boot parent manages every version except ShedLock’s)
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-quartz</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>

    <!-- JUnit 6, Mockito, AssertJ, Awaitility, Spring Test: see the next subsection -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-quartz-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Testcontainers 2.x module artifacts; Boot 4.1.1 manages 2.0.5 -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- the same for testcontainers-mysql, -mariadb, -oracle-free and -mssqlserver -->

    <!-- the JDBC drivers of the databases under test (and H2 for the ShedLock test); Boot manages their versions -->
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- only for the @Scheduled comparison; ShedLock is not managed by Boot -->
    <dependency>
        <groupId>net.javacrumbs.shedlock</groupId>
        <artifactId>shedlock-spring</artifactId>
        <version>7.10.1</version>
    </dependency>
    <dependency>
        <groupId>net.javacrumbs.shedlock</groupId>
        <artifactId>shedlock-provider-jdbc-template</artifactId>
        <version>7.10.1</version>
    </dependency>
</dependencies>

The Gradle (Kotlin DSL) equivalent of the test starters is testImplementation("org.springframework.boot:spring-boot-starter-quartz-test") and testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test"). With Boot 4.1.1 the Testcontainers module artifacts need no version (a dependency resolution without any version resolved testcontainers-postgresql to 2.0.5, because the Boot BOM imports the Testcontainers BOM); ShedLock needs one (the Boot BOM has no entry for it).

What spring-boot-starter-quartz-test is

The starter exists for Boot 4.x and it does not exist for Boot 3.5. This is what Maven Central says, checked on 5 October 2026:

Table 2. The facts about spring-boot-starter-quartz-test (Maven Central and the Boot BOMs)
Question Answer

Does it exist for Boot 4.1.1?

Yes: org.springframework.boot:spring-boot-starter-quartz-test:4.1.1, description "Starter for testing the Quartz scheduler". The 4.1.1 POM is served with HTTP 200 and the spring-boot-dependencies 4.1.1 BOM manages the artifact

Which versions are published?

maven-metadata.xml lists 4.0.0-RC1, 4.0.0 to 4.0.8, 4.1.0-M1 to 4.1.0 and 4.1.1, and 4.2.0-M1, 4.2.0-M2 (latest, last updated 24 September 2026). There is no 3.x version

Does it exist for Boot 3.5.16?

No: the 3.5.16 POM returns HTTP 404, the artifact has no 3.x entry in maven-metadata.xml, and the spring-boot-dependencies 3.5.16 BOM does not mention it

What does it contain?

Nothing but a convenience set of dependencies. Its POM has two compile dependencies, spring-boot-starter-quartz and spring-boot-starter-test; its JAR holds only META-INF/MANIFEST.MF, LICENSE.txt and NOTICE.txt, no classes at all

What does mvn dependency:tree show with it?

Under spring-boot-starter-quartz-test the whole spring-boot-starter-test set: spring-boot-test, spring-boot-test-autoconfigure, JUnit Jupiter 6.0.3 (with junit-jupiter-params), Mockito 5.23.0 and mockito-junit-jupiter, AssertJ 3.27.7, Awaitility 4.3.0, Hamcrest 3.0, JsonPath 2.10.0

So the starter is a naming convention, not a feature: the Boot 4.0 migration guide says every test starter is named spring-boot-starter-<technology>-test and that a technology with its own test infrastructure should be swapped to that equivalent, and for Quartz that equivalent adds no test class of its own. It gives you no scheduler test utilities, no fake clock, no @AutoConfigureQuartz; everything in this page works with spring-boot-starter-test plus spring-boot-starter-quartz, which is also what a Boot 3.5 project uses. The one practical reason to prefer it on Boot 4 is that the build then states in one line that this module tests Quartz, and the day a test-specific Quartz artifact appears it arrives without a build change.

The code under test

The Bookshelf service of the section has a nightly overdue-loan reminder and a weekly statistics report. For testing, the reminder job has three collaborators, all injected through its constructor: a LoanService that finds overdue loans, a ReminderSender that sends one reminder and a Clock that says what "today" is. The job data entry maxReminders caps one run and reaches the job through a setter, the standard QuartzJobBean mechanism of Getting Started with Spring Boot.

OverdueReminderJob.java (src/main)
package bookshelf;

import java.time.Clock;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;

import org.quartz.DisallowConcurrentExecution;
import org.quartz.JobExecutionContext;
import org.springframework.scheduling.quartz.QuartzJobBean;

@DisallowConcurrentExecution
public class OverdueReminderJob extends QuartzJobBean {

    private final LoanService loans;
    private final ReminderSender sender;
    private final Clock clock;

    private int maxReminders = 100;          // set from the JobDataMap entry "maxReminders"

    public OverdueReminderJob(LoanService loans, ReminderSender sender, Clock clock) {
        this.loans = loans;
        this.sender = sender;
        this.clock = clock;
    }

    public void setMaxReminders(int maxReminders) {
        this.maxReminders = maxReminders;
    }

    @Override
    protected void executeInternal(JobExecutionContext context) {
        LocalDate today = LocalDate.now(clock);
        List<OverdueLoan> overdue = loans.overdueBefore(today);
        int sent = 0;
        for (OverdueLoan loan : overdue) {
            if (sent == maxReminders) {
                break;
            }
            sender.send(loan.memberId(), loan.loanId(), ChronoUnit.DAYS.between(loan.dueDate(), today));
            sent++;
        }
        context.setResult(sent);
    }
}

The trigger definitions are plain static methods, so that a test can build a trigger without a Spring context; the configuration class only passes the YAML values in.

BookshelfTriggers.java (src/main)
package bookshelf;

import java.time.ZoneId;
import java.util.TimeZone;

import org.quartz.CronScheduleBuilder;
import org.quartz.JobDetail;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;

/** Trigger definitions as plain static methods, so tests can build them without Spring. */
public final class BookshelfTriggers {
    private BookshelfTriggers() { }

    public static Trigger overdueReminders(JobDetail job, String cron, ZoneId zone) {
        return TriggerBuilder.newTrigger().forJob(job).withIdentity("overdueLoanRemindersTrigger", "bookshelf")
                .withSchedule(CronScheduleBuilder.cronSchedule(cron)
                        .inTimeZone(TimeZone.getTimeZone(zone))
                        .withMisfireHandlingInstructionFireAndProceed())
                .build();
    }

    public static Trigger weeklyStatistics(JobDetail job, ZoneId zone) {
        return TriggerBuilder.newTrigger().forJob(job).withIdentity("weeklyStatisticsTrigger", "bookshelf")
                .withSchedule(CronScheduleBuilder.cronSchedule("0 0 6 ? * MON")
                        .inTimeZone(TimeZone.getTimeZone(zone)))
                .build();
    }
}
BookshelfSchedule.java (src/main): the beans Boot registers with the scheduler
package bookshelf;

import java.time.ZoneId;

import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.Trigger;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class BookshelfSchedule {

    @Bean
    JobDetail overdueLoanRemindersJob(@Value("${bookshelf.reminders.max-per-run}") int maxPerRun) {
        return JobBuilder.newJob(OverdueReminderJob.class).withIdentity("overdueLoanReminders", "bookshelf")
                .usingJobData("maxReminders", maxPerRun).storeDurably().build();
    }

    @Bean
    Trigger overdueLoanRemindersTrigger(JobDetail overdueLoanRemindersJob,
            @Value("${bookshelf.reminders.cron}") String cron, @Value("${bookshelf.time-zone}") ZoneId zone) {
        return BookshelfTriggers.overdueReminders(overdueLoanRemindersJob, cron, zone);
    }

    @Bean
    JobDetail weeklyStatisticsJob() {
        return JobBuilder.newJob(WeeklyStatisticsJob.class).withIdentity("weeklyStatistics", "bookshelf")
                .storeDurably().build();
    }

    @Bean
    Trigger weeklyStatisticsTrigger(JobDetail weeklyStatisticsJob, @Value("${bookshelf.time-zone}") ZoneId zone) {
        return BookshelfTriggers.weeklyStatistics(weeklyStatisticsJob, zone);
    }
}
application.yml (src/main/resources)
spring:
  quartz:
    job-store-type: memory
    wait-for-jobs-to-complete-on-shutdown: true
management:
  endpoints:
    web:
      exposure:
        include: health,quartz
bookshelf:
  time-zone: Europe/Madrid
  reminders:
    cron: "0 0 2 * * ?"
    max-per-run: 100

The test application deliberately uses the every-day cron 0 0 2 * * ? for the overdue-loan reminders instead of the production 0 0 2 ? * MON-FRI, so that the trigger tests below can show the spring-forward Sunday. The Clock bean is declared by the application (Clock.system(zone) with the zone from bookshelf.time-zone); Boot does not declare one for you. Two other classes complete the picture and are not repeated here: WeeklyStatisticsJob, which only logs, and LoggingReminderSender, the @Service that implements ReminderSender.

Unit-testing job logic without a scheduler

A job is a class with one method. If the logic sits in executeInternal and its collaborators are constructor parameters, a test needs a mock of JobExecutionContext and nothing else: no scheduler, no Spring, no thread pool. executeInternal is protected in QuartzJobBean, which is reachable from a test in the same package; the public execute is final and does one more thing before it calls executeInternal: it copies the scheduler context and the merged JobDataMap onto the job’s setters. A second test therefore calls execute to check that binding, and has to stub getScheduler(), getContext() and getMergedJobDataMap() for it.

OverdueReminderJobTest.java
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;

import org.junit.jupiter.api.Test;
import org.quartz.JobDataMap;
import org.quartz.JobExecutionContext;
import org.quartz.Scheduler;
import org.quartz.SchedulerContext;

class OverdueReminderJobTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    // 5 October 2026, 02:00 in Madrid: the moment the real trigger fires
    private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-05T00:00:00Z"), MADRID);

    private final LoanService loans = mock(LoanService.class);
    private final ReminderSender sender = mock(ReminderSender.class);
    private final OverdueReminderJob job = new OverdueReminderJob(loans, sender, CLOCK);

    @Test
    void remindsEveryMemberWithAnOverdueLoan() {
        LocalDate today = LocalDate.of(2026, 10, 5);
        when(loans.overdueBefore(today)).thenReturn(List.of(
                new OverdueLoan(1, 10, today.minusDays(3)), new OverdueLoan(2, 11, today.minusDays(1))));
        JobExecutionContext context = mock(JobExecutionContext.class);

        job.executeInternal(context);                 // protected, reachable from the same package

        verify(sender).send(10, 1, 3);
        verify(sender).send(11, 2, 1);
        verify(context).setResult(2);
    }

    @Test
    void jobDataMapEntryReachesTheSetterAndCapsTheRun() throws Exception {
        LocalDate today = LocalDate.of(2026, 10, 5);
        when(loans.overdueBefore(today)).thenReturn(List.of(
                new OverdueLoan(1, 10, today.minusDays(3)), new OverdueLoan(2, 11, today.minusDays(1))));
        JobExecutionContext context = mock(JobExecutionContext.class);
        Scheduler scheduler = mock(Scheduler.class);
        when(context.getScheduler()).thenReturn(scheduler);
        when(scheduler.getContext()).thenReturn(new SchedulerContext());
        when(context.getMergedJobDataMap()).thenReturn(new JobDataMap(java.util.Map.of("maxReminders", 1)));

        job.execute(context);                         // the public entry point: binds the data, then executeInternal

        verify(sender).send(10, 1, 3);
        verify(sender, never()).send(11, 2, 1);
        verify(context).setResult(1);
    }

    @Test
    void nothingOverdueSendsNothing() {
        when(loans.overdueBefore(LocalDate.of(2026, 10, 5))).thenReturn(List.of());
        JobExecutionContext context = mock(JobExecutionContext.class);

        job.executeInternal(context);

        verify(sender, never()).send(anyLong(), anyLong(), anyLong());
        assertThat(CLOCK.instant()).isEqualTo(Instant.parse("2026-10-05T00:00:00Z"));
    }

    @Test
    void executeNeedsTheSchedulerOfTheContextToBeStubbed() {
        JobExecutionContext context = mock(JobExecutionContext.class);      // getScheduler() returns null
        org.junit.jupiter.api.Assertions.assertThrows(NullPointerException.class, () -> job.execute(context));
    }
}

All four tests passed (0.2 s). The fourth test, executeNeedsTheSchedulerOfTheContextToBeStubbed, records a measured trap: calling execute with a bare mock fails with a NullPointerException, because QuartzJobBean.execute calls context.getScheduler().getContext() and a mock returns null for getScheduler(). Calling executeInternal avoids the trap, at the price of not testing the setter binding, which is why the page has both.

Deterministic time with an injectable Clock

The job asks the Clock for "today" (LocalDate.now(clock)), and the test hands it Clock.fixed(Instant.parse("2026-10-05T00:00:00Z"), MADRID), so that "three days late" is 3 on any day the test runs. That is the whole technique: a java.time.Clock is a constructor parameter, production gets Clock.system(zone), the test gets a fixed or a hand-moved one. Three limits are worth knowing:

  • Quartz itself does not use your clock. In 2.5.2 the scheduler thread reads System.currentTimeMillis() (QuartzSchedulerThread), and the org.quartz.spi.TimeBroker interface that looks like a hook is documented as "not currently used in the Quartz code base". A test cannot make Quartz believe it is 02:00; it can only (a) test the job with a fixed clock, as above, and (b) test the trigger’s arithmetic without waiting, as in the next section.

  • The fixed clock has to be the one the job reads. A job that calls LocalDate.now() or Instant.now() directly cannot be made deterministic; this is why OverdueReminderJob takes the clock in the constructor.

  • For @Scheduled, Spring’s ThreadPoolTaskScheduler has a setClock(Clock), but the delays are still waited out by a real executor; the practical test is the one at the end of this page, which calls the method directly and tests the lock separately.

Testing trigger definitions

Whether a cron expression means what the ticket said is arithmetic, and Quartz can do it without starting anything. TriggerUtils.computeFireTimes(OperableTrigger, Calendar, int) clones the trigger and returns its next fire times; computeFireTimesBetween does the same for a date range; a CronExpression answers getNextValidTimeAfter on its own. Two details matter for a deterministic test. First, the builder defaults the start time to "now", so the expected dates would move every day: a test pins the start with trigger.getTriggerBuilder().startAt(start).build(). Second, computeFireTimes needs an OperableTrigger, the SPI interface that every trigger implementation also implements; the test casts the built trigger to it, and passes null for the calendar unless it tests one.

BookshelfTriggersTest.java
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.List;

import org.junit.jupiter.api.Test;
import org.quartz.CronExpression;
import org.quartz.CronTrigger;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.Trigger;
import org.quartz.TriggerUtils;
import org.quartz.impl.calendar.HolidayCalendar;
import org.quartz.spi.OperableTrigger;

class BookshelfTriggersTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private final JobDetail job = JobBuilder.newJob(WeeklyStatisticsJob.class).withIdentity("weeklyStatistics", "bookshelf")
            .storeDurably().build();

    private static Date at(int year, int month, int day, int hour, int minute) {
        return Date.from(ZonedDateTime.of(year, month, day, hour, minute, 0, 0, MADRID).toInstant());
    }

    /** The builder defaults the start time to "now"; pin it so the expected dates never move. */
    private static OperableTrigger startingAt(Trigger trigger, Date start) {
        return (OperableTrigger) trigger.getTriggerBuilder().startAt(start).build();
    }

    @Test
    void weeklyStatisticsFiresOnMondaysAtSixInMadrid() {
        OperableTrigger trigger = startingAt(BookshelfTriggers.weeklyStatistics(job, MADRID), at(2026, 10, 5, 12, 0));

        List<Date> next = TriggerUtils.computeFireTimes(trigger, null, 3);

        assertThat(next).containsExactly(at(2026, 10, 12, 6, 0), at(2026, 10, 19, 6, 0), at(2026, 10, 26, 6, 0));
    }

    @Test
    void theWeeklyReportFollowsTheClockChangeNotTheUtcOffset() {
        // Madrid leaves summer time on 25 October 2026: 06:00 is 04:00Z before and 05:00Z after
        OperableTrigger trigger = startingAt(BookshelfTriggers.weeklyStatistics(job, MADRID), at(2026, 10, 19, 12, 0));

        List<Date> next = TriggerUtils.computeFireTimes(trigger, null, 2);

        assertThat(next.get(0).toInstant()).isEqualTo(Instant.parse("2026-10-26T05:00:00Z"));
        assertThat(next.get(1).toInstant()).isEqualTo(Instant.parse("2026-11-02T05:00:00Z"));
    }

    @Test
    void aHolidayCalendarRemovesFireTimes() {
        HolidayCalendar holidays = new HolidayCalendar();
        holidays.setTimeZone(java.util.TimeZone.getTimeZone(MADRID));
        holidays.addExcludedDate(at(2026, 10, 12, 0, 0));          // Monday 12 October is a public holiday

        OperableTrigger trigger = startingAt(BookshelfTriggers.weeklyStatistics(job, MADRID), at(2026, 10, 5, 12, 0));

        assertThat(TriggerUtils.computeFireTimes(trigger, holidays, 2))
                .containsExactly(at(2026, 10, 19, 6, 0), at(2026, 10, 26, 6, 0));
    }

    @Test
    void theReminderTriggerIsACronTriggerThatFiresOnceAfterAMisfire() {
        Trigger trigger = BookshelfTriggers.overdueReminders(job, "0 0 2 * * ?", MADRID);

        assertThat(trigger).isInstanceOf(CronTrigger.class);
        assertThat(((CronTrigger) trigger).getTimeZone().getID()).isEqualTo("Europe/Madrid");
        assertThat(trigger.getMisfireInstruction()).isEqualTo(CronTrigger.MISFIRE_INSTRUCTION_FIRE_ONCE_NOW);
    }

    @Test
    void aBadExpressionIsRejectedBeforeTheApplicationStarts() {
        assertThat(CronExpression.isValidExpression("0 0 2 * * *")).isFalse();     // day-of-month and day-of-week both set
        assertThat(CronExpression.isValidExpression("0 0 2 ? * MON-FRI")).isTrue();
    }

    @Test
    void cronExpressionAnswersWithoutATriggerAtAll() throws Exception {
        CronExpression expression = new CronExpression("0 0 2 ? * MON-FRI");
        expression.setTimeZone(java.util.TimeZone.getTimeZone(MADRID));

        Date friday = at(2026, 10, 9, 12, 0);
        Date afterFriday = expression.getNextValidTimeAfter(friday);

        assertThat(afterFriday).isEqualTo(at(2026, 10, 12, 2, 0));              // the weekend is skipped
        assertThat(Duration.between(friday.toInstant(), afterFriday.toInstant())).isEqualTo(Duration.ofHours(62));
    }

    @Test
    void onTheSpringForwardDayTheTwoOClockRunIsSkipped() {
        // Madrid clocks jump from 02:00 to 03:00 on Sunday 29 March 2026
        OperableTrigger trigger = startingAt(BookshelfTriggers.overdueReminders(job, "0 0 2 * * ?", MADRID), at(2026, 3, 27, 12, 0));

        List<Date> next = TriggerUtils.computeFireTimes(trigger, null, 4);

        assertThat(next).containsExactly(at(2026, 3, 28, 2, 0), at(2026, 3, 30, 2, 0),
                at(2026, 3, 31, 2, 0), at(2026, 4, 1, 2, 0));          // nothing on Sunday 29 March
    }
}

All seven tests passed (0.04 s) on the first run. What they pin down, each of them a real incident when it goes wrong:

  • The weekly report fires on Mondays at 06:00 Madrid time, and the fire time follows the clock change rather than the UTC offset: 06:00 is 04:00Z until 25 October 2026 and 05:00Z after it (the assertions use 2026-10-26T05:00:00Z).

  • A HolidayCalendar removes a Monday from the list of fire times; the calendar is passed as the second argument of computeFireTimes.

  • The reminder trigger is a CronTrigger in Europe/Madrid whose misfire instruction is MISFIRE_INSTRUCTION_FIRE_ONCE_NOW, which is what withMisfireHandlingInstructionFireAndProceed() selects.

  • CronExpression.isValidExpression rejects 0 0 2 * * * (both day fields set) before the application is started; this is the cheapest guard against a typo in a YAML property.

  • On the spring-forward day, Sunday 29 March 2026, 0 0 2 * * ? in Madrid does not fire: the fire times from 27 March are the 28th, the 30th, the 31st and 1 April, all at 02:00. That is the measured behaviour on 2.5.2; the cron page explains the rule and the fall-back day.

The DST test is the pattern for every "what does this do on the night the clocks change" question: write the date range into the test, run it, read what Quartz did, and then assert it, so that a Quartz upgrade that changes it is noticed.

Testing the Spring wiring with @SpringBootTest

@SpringBootTest starts the real application with Boot’s Quartz auto-configuration, the real YAML and the real JobDetail and Trigger beans. With the default memory store this costs a few seconds and needs no database. Three techniques make it a good test instead of a slow one.

Start the scheduler stopped. spring.quartz.auto-startup=false makes Boot register every job and trigger and leave the scheduler in standby, so nothing fires behind the test’s back. A test asserts on what was registered, and starts the scheduler (scheduler.start()) only for the test that needs jobs to run. Measured on 4.1.1: triggerJob on a stopped scheduler does not run the job (the first test below waits two seconds and asserts that the mock was never called), the job waits in the store, and it runs as soon as the scheduler starts.

Replace the collaborators with @MockitoBean. Boot creates job instances at fire time through the application context (the SpringBeanJobFactory of Getting Started with Spring Boot), so a bean that the test replaces is the bean the job receives. @MockitoBean is Spring Framework’s annotation (org.springframework.test.context.bean.override.mockito.MockitoBean); it replaced Boot’s own @MockBean, which is gone in Boot 4.

Wait for the effect with Awaitility, not Thread.sleep. The job runs on a Quartz worker thread, so the test polls an assertion until it holds or a timeout expires; await().atMost(…​).untilAsserted(…​) is the form, and await().during(…​) asserts that something stays true for a period, which is how the first test proves that nothing ran. The official smoke test of Boot’s Quartz sample waits the same way (the page on Getting Started with Spring Boot shows it).

BookshelfSchedulerTest.java
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

import java.time.Duration;

import org.junit.jupiter.api.Test;
import org.quartz.JobDataMap;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.Trigger.TriggerState;
import org.quartz.TriggerKey;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

@SpringBootTest(properties = {
        "spring.quartz.auto-startup=false",            // register everything, fire nothing until the test says so
        "bookshelf.reminders.max-per-run=5"
})
class BookshelfSchedulerTest {

    private static final JobKey REMINDERS = JobKey.jobKey("overdueLoanReminders", "bookshelf");

    @Autowired
    Scheduler scheduler;

    @MockitoBean
    ReminderSender sender;                             // replaces the real bean; the job gets the mock injected

    @Test
    void theScheduleIsRegisteredButTheSchedulerIsInStandby() throws SchedulerException {
        assertThat(scheduler.isInStandbyMode()).isTrue();
        assertThat(scheduler.checkExists(REMINDERS)).isTrue();
        assertThat(scheduler.getJobDetail(REMINDERS).getJobDataMap().getInt("maxReminders")).isEqualTo(5);
        assertThat(scheduler.getTriggerState(TriggerKey.triggerKey("overdueLoanRemindersTrigger", "bookshelf")))
                .isEqualTo(TriggerState.NORMAL);
    }

    @Test
    void aTriggeredJobWaitsInStandbyAndRunsOnceTheSchedulerStarts() throws SchedulerException {
        scheduler.triggerJob(REMINDERS);

        // nothing runs while the scheduler is in standby
        await().during(Duration.ofSeconds(2)).atMost(Duration.ofSeconds(3))
                .untilAsserted(() -> verify(sender, never()).send(anyLong(), anyLong(), anyLong()));

        scheduler.start();
        try {
            await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> verify(sender).send(10, 1, 3));
        } finally {
            scheduler.standby();                       // leave the shared context the way the next test found it
        }
    }

    @Test
    void jobDataPassedToTriggerJobOverridesTheStoredValueForThatRunOnly() throws SchedulerException {
        scheduler.start();
        try {
            scheduler.triggerJob(REMINDERS, new JobDataMap(java.util.Map.of("maxReminders", 1)));

            await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> verify(sender).send(10, 1, 3));
            verify(sender, never()).send(11, 2, 1);
            assertThat(scheduler.getJobDetail(REMINDERS).getJobDataMap().getInt("maxReminders")).isEqualTo(5);
        } finally {
            scheduler.standby();
        }
    }
}

All three tests passed (4.6 s including the context start). The third test shows the other way to run a job on demand: scheduler.triggerJob(JobKey, JobDataMap) with a data map that overrides maxReminders for that run only; the stored job data still says 5 afterwards. Each test that starts the scheduler stops it again in a finally block, because the Spring context (and its scheduler) is shared by the test methods of the class and, as the pitfalls table shows, by other classes with the same configuration.

Other ways to know that a job ran. A mock is the best evidence when the job has a collaborator whose call is the effect. When it has none, a job listener registered on the scheduler (scheduler.getListenerManager().addJobListener(…​), with org.quartz.listeners.JobListenerSupport as a base class) can count down a CountDownLatch in jobWasExecuted, as the database test below does, and a global listener registered through a SchedulerFactoryBeanCustomizer bean can record every execution, as the cluster test does. Listeners are covered on Listeners & Plugins.

Asserting on /actuator/quartz

The Actuator quartz endpoint is what an operator uses to see the schedule, so it deserves a test that fails when someone changes a group name or the cron expression. It is not exposed over HTTP by default; the test exposes it with a property, and @AutoConfigureMockMvc provides a MockMvc that reaches the endpoint without a socket. On Boot 4 the annotation lives in org.springframework.boot.webmvc.test.autoconfigure (artifact spring-boot-starter-webmvc-test) and @SpringBootTest no longer provides MockMvc on its own.

QuartzEndpointTest.java
package bookshelf;

import static org.awaitility.Awaitility.await;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.time.Duration;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest(properties = "management.endpoints.web.exposure.include=quartz")
@AutoConfigureMockMvc
class QuartzEndpointTest {

    @Autowired
    MockMvc mvc;

    @MockitoBean
    ReminderSender sender;

    @Test
    void listsTheBookshelfJobs() throws Exception {
        mvc.perform(get("/actuator/quartz/jobs"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.groups.bookshelf.jobs", containsInAnyOrder("overdueLoanReminders", "weeklyStatistics")));
    }

    @Test
    void describesTheReminderTrigger() throws Exception {
        mvc.perform(get("/actuator/quartz/triggers/bookshelf/overdueLoanRemindersTrigger"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.type").value("cron"))
                .andExpect(jsonPath("$.state").value("NORMAL"))
                .andExpect(jsonPath("$.cron.expression").value("0 0 2 * * ?"))
                .andExpect(jsonPath("$.cron.timeZone").value("Europe/Madrid"));
    }

    @Test
    void jobDataIsMaskedUnlessShowValuesIsOn() throws Exception {
        mvc.perform(get("/actuator/quartz/jobs/bookshelf/overdueLoanReminders"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.className").value("bookshelf.OverdueReminderJob"))
                .andExpect(jsonPath("$.durable").value(true))
                .andExpect(jsonPath("$.data.maxReminders").value("******"));
    }

    @Test
    void anUnknownJobIsA404() throws Exception {
        mvc.perform(get("/actuator/quartz/jobs/bookshelf/nope")).andExpect(status().isNotFound());
    }

    @Test
    void postingStateRunningTriggersTheJobNow() throws Exception {
        mvc.perform(post("/actuator/quartz/jobs/bookshelf/overdueLoanReminders")
                        .contentType(MediaType.APPLICATION_JSON).content("{\"state\":\"running\"}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("overdueLoanReminders"));

        await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> verify(sender).send(10, 1, 3));
    }

    @Test
    void anyOtherStateIsRejected() throws Exception {
        mvc.perform(post("/actuator/quartz/jobs/bookshelf/overdueLoanReminders")
                        .contentType(MediaType.APPLICATION_JSON).content("{\"state\":\"paused\"}"))
                .andExpect(status().isBadRequest());
    }
}

All six tests passed (0.4 s as reported by Surefire). What they establish about the endpoint, all measured on Boot 4.1.1 and consistent with the endpoint page of Dynamic Scheduling & Management:

  • GET /actuator/quartz/jobs returns the jobs grouped under groups.bookshelf.jobs; the test uses containsInAnyOrder because the order is not part of the contract.

  • A trigger’s detail has type cron, a state, and a cron object with the expression and the time zone, which makes it the way to assert that YAML reached the scheduler.

  • Job data is masked as unless management.endpoint.quartz.show-values is changed, so a test cannot read job data over HTTP by default; this is the right default and the test pins it.

  • An unknown job is a 404; a POST whose body sets the state to running triggers the job at once (the test then waits for the mocked sender), and any other state is a 400.

A Testcontainers test for the JDBC store, parameterized over the databases

A memory-store test cannot find the bugs that matter on a database: a column type, a missing delegate, a script that fails on an empty schema, a JobDataMap that cannot be serialized. The test below runs the same scenario on every database page of the section. A JUnit @ParameterizedTest with an @EnumSource receives one enum constant per database; the constant carries the container and the two settings that differ between databases, the Quartz delegate and the schema script. The scenario starts a container, starts the Bookshelf application against it with the JDBC store (the always schema mode is acceptable in a test, because the container is empty and thrown away), checks that the jobs and triggers are in the tables, runs a job from the stored definition, and then starts a second application with initialize-schema=never to prove that the stored schedule is found again.

Credentials are the ones the container generates (getJdbcUrl(), getUsername(), getPassword()), passed to the application as -- arguments of run, so nothing is typed into the test. Arguments are used instead of the builder’s properties(…​) because those are default properties, which application.yml overrides.

JdbcJobStoreIT.java
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

import javax.sql.DataSource;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.listeners.JobListenerSupport;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.containers.JdbcDatabaseContainer;
import org.testcontainers.mariadb.MariaDBContainer;
import org.testcontainers.mssqlserver.MSSQLServerContainer;
import org.testcontainers.mysql.MySQLContainer;
import org.testcontainers.oracle.OracleContainer;
import org.testcontainers.postgresql.PostgreSQLContainer;

class JdbcJobStoreIT {

    private static final String QUARTZ = "spring.quartz.properties.org.quartz.";
    private static final JobKey REMINDERS = JobKey.jobKey("overdueLoanReminders", "bookshelf");

    /** One row per database page of the section: the container and the two settings that differ. */
    enum StoreDatabase {
        POSTGRESQL(() -> new PostgreSQLContainer("postgres:17"), "org.quartz.impl.jdbcjobstore.PostgreSQLDelegate", null, false),
        MYSQL(() -> new MySQLContainer("mysql:9"), null, null, false),
        MARIADB(() -> new MariaDBContainer("mariadb:11"), null, null, false),
        ORACLE(() -> new OracleContainer("gvenzl/oracle-free:slim-faststart"), null,
                "classpath:org/quartz/impl/jdbcjobstore/tables_oracle23.sql", true),
        SQL_SERVER(() -> new MSSQLServerContainer("mcr.microsoft.com/mssql/server:2022-latest").acceptLicense(),
                "org.quartz.impl.jdbcjobstore.MSSQLDelegate", "classpath:tables_sqlServer_boot.sql", false);

        final Supplier<JdbcDatabaseContainer<?>> container;
        final String delegate;                  // null = Quartz's StdJDBCDelegate
        final String schemaScript;              // null = the script Boot picks for the platform
        final boolean tolerateScriptErrors;     // the Oracle script starts with DELETE/DROP of tables that do not exist yet

        StoreDatabase(Supplier<JdbcDatabaseContainer<?>> container, String delegate, String schemaScript, boolean tolerate) {
            this.container = container;
            this.delegate = delegate;
            this.schemaScript = schemaScript;
            this.tolerateScriptErrors = tolerate;
        }
    }

    private ConfigurableApplicationContext start(StoreDatabase db, JdbcDatabaseContainer<?> c, String initializeSchema) {
        List<String> props = new ArrayList<>(List.of(
                "spring.datasource.url=" + c.getJdbcUrl(),
                "spring.datasource.username=" + c.getUsername(),
                "spring.datasource.password=" + c.getPassword(),         // supplied by the container, never typed by us
                "spring.quartz.job-store-type=jdbc",
                "spring.quartz.jdbc.initialize-schema=" + initializeSchema,
                "spring.quartz.scheduler-name=BookshelfTest"));
        if (!db.tolerateScriptErrors) {
            props.add("spring.quartz.jdbc.continue-on-error=false");     // make a broken script fail the test
        }
        if (db.delegate != null) {
            props.add(QUARTZ + "jobStore.driverDelegateClass=" + db.delegate);
        }
        if (db.schemaScript != null) {
            props.add("spring.quartz.jdbc.schema=" + db.schemaScript);
        }
        // run(args) beats application.yml; properties(...) on the builder would not
        return new SpringApplicationBuilder(BookshelfApplication.class).web(WebApplicationType.NONE)
                .run(props.stream().map(p -> "--" + p).toArray(String[]::new));
    }

    @ParameterizedTest(name = "{0}")
    @EnumSource(StoreDatabase.class)
    void theBookshelfScheduleLivesInTheDatabaseAndSurvivesARestart(StoreDatabase db) throws Exception {
        try (JdbcDatabaseContainer<?> database = db.container.get()) {
            database.start();

            try (ConfigurableApplicationContext first = start(db, database, "always")) {
                Scheduler scheduler = first.getBean(Scheduler.class);
                assertThat(scheduler.getMetaData().isJobStoreSupportsPersistence()).isTrue();

                JdbcTemplate jdbc = new JdbcTemplate(first.getBean(DataSource.class));
                assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_JOB_DETAILS", Integer.class)).isEqualTo(2);
                assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_CRON_TRIGGERS", Integer.class)).isEqualTo(2);

                CountDownLatch executed = new CountDownLatch(1);
                scheduler.getListenerManager().addJobListener(new JobListenerSupport() {
                    @Override
                    public String getName() {
                        return "test-latch";
                    }

                    @Override
                    public void jobWasExecuted(JobExecutionContext context, JobExecutionException exception) {
                        executed.countDown();
                    }
                });
                scheduler.triggerJob(REMINDERS);                         // a job really runs from the stored definition
                assertThat(executed.await(15, TimeUnit.SECONDS)).isTrue();
            }

            // a second application start must find the jobs without creating the tables again
            try (ConfigurableApplicationContext second = start(db, database, "never")) {
                assertThat(second.getBean(Scheduler.class).checkExists(REMINDERS)).isTrue();
            }
        }
    }
}
Table 3. JdbcJobStoreIT on five databases (Testcontainers 2.0.5, 5 October 2026)
Enum constant Image What differs (all verified on the database pages) Time

POSTGRESQL

postgres:17

PostgreSQLDelegate is mandatory (the BLOB columns are bytea)

2.2 s

MYSQL

mysql:9

Nothing: Boot maps MySQL to tables_mysql_innodb.sql, default delegate

7.4 s

MARIADB

mariadb:11

Nothing: Boot also uses tables_mysql_innodb.sql (MariaDB has no script of its own)

5.5 s

ORACLE

gvenzl/oracle-free:slim-faststart

tables_oracle23.sql instead of the script Boot picks, and script errors tolerated because the script starts with statements that fail on an empty schema

9.4 s

SQL_SERVER

mcr.microsoft.com/mssql/server:2022-latest

An edited copy of the script on the test classpath (USE line removed, every GO line replaced by a semicolon) and MSSQLDelegate

9.1 s

Source: measured in a test run (Surefire report); see Testcontainers: JDBC support and the database pages of this section.

All five passed, 33.7 s in total on a laptop with the images already pulled. SQL Server ran under Rosetta emulation because its image is amd64 only; the others ran natively. The enum is the place to add a sixth database, and it documents what is special about each in code. The continue-on-error=false setting for the other four is deliberate: with Boot’s default of true a broken schema script is skipped silently and the failure surfaces later as a missing table, which is a worse place to find it. The per-database details are on Quartz on PostgreSQL, MySQL and MariaDB, Oracle, SQL Server and Embedded and Other SQL Databases; the general Testcontainers set-up (container reuse, CI runners, @ServiceConnection for a single database) is on Spring Boot Integration Tests with Testcontainers.

With one database, @ServiceConnection on a static @Container field is shorter than the enum, and the PostgreSQL page uses it. The enum form is for the case in which the same scenario must run on several databases, because @ServiceConnection binds a field of a test class to one container.

A two-scheduler cluster test

The questions a cluster test answers are two: does each fire time run on exactly one node, and does a surviving node run the job of a node that died. Both can be answered in automation, with one design decision that the clustering page already explains: the two schedulers may live in one JVM as long as nobody has to be killed, because Spring registers the data source under the scheduler name and both nodes use the same database; the node that has to be killed with kill -9 must be its own JVM.

flowchart LR subgraph TESTJVM["JVM of the test (JUnit under Surefire)"] T["Test method
awaits, asserts, kills"] A["Node A: Spring context
scheduler BookshelfCluster, instance id AUTO"] B["Node B: Spring context
same scheduler name, own instance id"] R["Executions.ALL
filled by a global JobListener"] end subgraph CHILD["Second JVM, started with ProcessBuilder"] V["Victim node: ClusterNode.main
runs slowReport with requestRecovery true"] end subgraph DOCKER["Docker"] DB[("postgres:17 container
QRTZ_ tables, QRTZ_LOCKS, QRTZ_SCHEDULER_STATE
URL, user and password come from the container")] end A -->|JDBC| DB B -->|JDBC| DB V -->|JDBC| DB A -.->|"records every execution"| R B -.->|"records every execution"| R T -->|"destroyForcibly = kill -9"| V T -->|"reads"| R T -->|"starts and stops"| A T -->|"starts and stops"| B
Figure 2. The topology of the cluster test

The test needs a way to see who ran what. A global JobListener registered through a SchedulerFactoryBeanCustomizer bean (Boot registers no listener beans by itself) appends an Execution for every job that is about to run: the instance id of the scheduler, the job name, the scheduled fire time and whether Quartz says it is a recovery run.

Executions.java (src/test): a global listener that records every execution
package bookshelf;

import java.time.Instant;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

import org.quartz.JobExecutionContext;
import org.quartz.listeners.JobListenerSupport;
import org.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;

/** Records which node started which scheduled fire time; registered as a global job listener. */
public final class Executions {

    public record Execution(String node, String job, Instant scheduledFireTime, boolean recovering) { }

    public static final List<Execution> ALL = new CopyOnWriteArrayList<>();

    private Executions() { }

    @TestConfiguration(proxyBeanMethods = false)
    public static class Recording {
        @Bean
        SchedulerFactoryBeanCustomizer executionRecorder() {
            return factory -> factory.setGlobalJobListeners(new JobListenerSupport() {
                @Override
                public String getName() {
                    return "execution-recorder";
                }

                @Override
                public void jobToBeExecuted(JobExecutionContext context) {
                    try {
                        ALL.add(new Execution(context.getScheduler().getSchedulerInstanceId(),
                                context.getJobDetail().getKey().getName(),
                                context.getScheduledFireTime().toInstant(), context.isRecovering()));
                    } catch (Exception e) {
                        throw new IllegalStateException(e);
                    }
                }
            });
        }
    }
}

The victim needs a job that is slow when it starts and quick when it is re-run, and a main class that starts a node and schedules it. requestRecovery(true) is what makes Quartz re-run the job on a survivor.

SlowReportJob.java and ClusterNode.java (src/test)
package bookshelf;

import org.quartz.JobExecutionContext;
import org.springframework.scheduling.quartz.QuartzJobBean;

/** A long job that asks Quartz to run it again if its node dies; the second run is quick. */
public class SlowReportJob extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext context) {
        System.out.println("SLOW-STARTED recovering=" + context.isRecovering());
        System.out.flush();
        try {
            Thread.sleep(context.isRecovering() ? 100 : 60_000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

package bookshelf;

import java.util.Date;

import org.quartz.JobBuilder;
import org.quartz.Scheduler;
import org.quartz.TriggerBuilder;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;

/** One cluster node as its own JVM, so that a test can kill -9 it. Arguments are Spring Boot properties. */
public class ClusterNode {
    public static void main(String[] args) throws Exception {
        ConfigurableApplicationContext context = new SpringApplicationBuilder(BookshelfApplication.class)
                .web(WebApplicationType.NONE).run(args);
        Scheduler scheduler = context.getBean(Scheduler.class);
        scheduler.scheduleJob(
                JobBuilder.newJob(SlowReportJob.class).withIdentity("slowReport", "bookshelf").requestRecovery(true).build(),
                TriggerBuilder.newTrigger().withIdentity("slowReportTrigger", "bookshelf").startAt(new Date()).build());
        Thread.currentThread().join();
    }
}
ClusterIT.java
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.quartz.Scheduler;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@Testcontainers
class ClusterIT {

    @Container
    static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:17");

    private static final String QUARTZ = "--spring.quartz.properties.org.quartz.";

    @BeforeEach
    void forgetEarlierExecutions() {
        Executions.ALL.clear();
    }

    /** The properties that make a node a member of the cluster (the secrets come from the environment or the container). */
    private static List<String> clusterArguments(String initializeSchema, String... more) {
        List<String> args = new ArrayList<>(List.of(
                "--spring.datasource.url=" + POSTGRES.getJdbcUrl(),
                "--spring.datasource.username=" + POSTGRES.getUsername(),
                "--spring.datasource.password=" + POSTGRES.getPassword(),
                "--spring.quartz.job-store-type=jdbc",
                "--spring.quartz.jdbc.initialize-schema=" + initializeSchema,
                "--spring.quartz.scheduler-name=BookshelfCluster",
                QUARTZ + "scheduler.instanceId=AUTO",
                QUARTZ + "jobStore.isClustered=true",
                QUARTZ + "jobStore.clusterCheckinInterval=2000",
                QUARTZ + "jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.PostgreSQLDelegate",
                QUARTZ + "threadPool.threadCount=3"));
        args.addAll(List.of(more));
        return args;
    }

    private static ConfigurableApplicationContext startNode(String initializeSchema, String... more) {
        return new SpringApplicationBuilder(BookshelfApplication.class, Executions.Recording.class)
                .web(WebApplicationType.NONE)
                .run(clusterArguments(initializeSchema, more).toArray(String[]::new));
    }

    @Test
    void everyFireTimeRunsOnExactlyOneNode() throws Exception {
        String everyTwoSeconds = "--bookshelf.reminders.cron=0/2 * * * * ?";
        try (ConfigurableApplicationContext nodeA = startNode("always", everyTwoSeconds);
                ConfigurableApplicationContext nodeB = startNode("never", everyTwoSeconds)) {
            assertThat(nodeA.getBean(Scheduler.class).getMetaData().isJobStoreClustered()).isTrue();
            assertThat(nodeA.getBean(Scheduler.class).getSchedulerInstanceId())
                    .isNotEqualTo(nodeB.getBean(Scheduler.class).getSchedulerInstanceId());

            await().atMost(Duration.ofSeconds(30)).until(() -> Executions.ALL.size() >= 5);

            Map<Instant, List<String>> nodesPerFireTime = Executions.ALL.stream()
                    .filter(e -> e.job().equals("overdueLoanReminders"))
                    .collect(Collectors.groupingBy(Executions.Execution::scheduledFireTime, TreeMap::new,
                            Collectors.mapping(Executions.Execution::node, Collectors.toList())));
            System.out.println("CLUSTER fire time -> nodes " + nodesPerFireTime);
            assertThat(nodesPerFireTime.values()).allSatisfy(nodes -> assertThat(nodes).hasSize(1));
        }
    }

    @Test
    void aSurvivorRerunsTheJobOfANodeThatWasKilled() throws Exception {
        try (ConfigurableApplicationContext survivor = startNode("always")) {          // the default cron is 02:00: nothing else fires
            Scheduler scheduler = survivor.getBean(Scheduler.class);
            scheduler.standby();                       // keep the survivor from taking the job before the victim does

            List<String> command = new ArrayList<>(List.of(System.getProperty("java.home") + "/bin/java",
                    "-cp", System.getProperty("java.class.path"), "bookshelf.ClusterNode"));
            command.addAll(clusterArguments("never"));
            Process victim = new ProcessBuilder(command).redirectErrorStream(true).start();
            List<String> output = new CopyOnWriteArrayList<>();
            Thread reader = new Thread(() -> {
                try (BufferedReader in = new BufferedReader(new InputStreamReader(victim.getInputStream()))) {
                    in.lines().forEach(output::add);
                } catch (Exception ignored) {
                    // the process was killed
                }
            });
            reader.setDaemon(true);
            reader.start();

            await().atMost(Duration.ofSeconds(60)).until(() -> output.stream().anyMatch(l -> l.contains("SLOW-STARTED")));
            long killedAt = System.currentTimeMillis();
            victim.destroyForcibly().waitFor();        // kill -9: no shutdown hook, no clean check-out
            scheduler.start();

            await().atMost(Duration.ofSeconds(60)).until(() -> Executions.ALL.stream()
                    .anyMatch(e -> e.job().equals("slowReport") && e.recovering()));
            System.out.println("FAILOVER recovered after " + (System.currentTimeMillis() - killedAt) + " ms: "
                    + Executions.ALL);
            assertThat(Executions.ALL).filteredOn(e -> e.job().equals("slowReport"))
                    .singleElement().satisfies(e -> {
                        assertThat(e.recovering()).isTrue();
                        assertThat(e.node()).isEqualTo(scheduler.getSchedulerInstanceId());
                    });
        }
    }
}

Both tests passed (24.1 s including the container). The numbers they printed:

Printed by ClusterIT (PostgreSQL 17, clusterCheckinInterval 2000 ms, 5 October 2026)
CLUSTER fire time -> nodes {2026-10-05T11:30:06Z=[MacBook-Air-2.local1791199805919], 2026-10-05T11:30:08Z=[MacBook-Air-2.local1791199805919], 2026-10-05T11:30:10Z=[MacBook-Air-2.local1791199805919], 2026-10-05T11:30:12Z=[MacBook-Air-2.local1791199805919], 2026-10-05T11:30:14Z=[MacBook-Air-2.local1791199805919]}
FAILOVER recovered after 10601 ms: [Execution[node=MacBook-Air-2.local1791199814764, job=slowReport, scheduledFireTime=2026-10-05T11:30:16.273Z, recovering=true]]

Reading them correctly matters. In the first test five consecutive fire times each ran on exactly one node, which is the assertion; that all five ran on the same node is not a bug of the test but the behaviour documented on the clustering page: Quartz does not balance load, the first node to acquire a trigger keeps getting it. A test must therefore never assert that work is shared. In the second test the survivor started the job again with recovering=true 10.60 seconds after the kill -9, inside the window the clustering page derives from the check-in interval (the 7.5 s grace plus the check-in cycles); a fail-over test needs a timeout comfortably above that window, here 60 s, and a short clusterCheckinInterval of its own, here 2 s. The survivor is put in standby before the victim starts so that it cannot take the job first, and is started right after the kill.

sequenceDiagram participant T as Test method participant S as Survivor, in the test JVM participant V as Victim JVM participant DB as PostgreSQL container T->>S: start node with initialize-schema always, then standby() T->>V: ProcessBuilder starts ClusterNode with initialize-schema never V->>DB: store slowReport with requestRecovery true and fire it V-->>T: prints SLOW-STARTED (the test reads the process output) T->>V: destroyForcibly (kill -9) Note over V,DB: QRTZ_FIRED_TRIGGERS keeps the EXECUTING row, QRTZ_SCHEDULER_STATE keeps V's row T->>S: start() S->>DB: check-in finds V late after about 7.5 s plus the check-in cycle S->>DB: recover V's fired row into a recovery trigger S->>S: fire slowReport with isRecovering true, listener records it T->>T: await until an execution with recovering true appears (10.60 s)
Figure 3. The fail-over test as a sequence

Rules the cluster tests of this section follow, all of them learned from runs:

  • One scheduler name per test class, or a database per class. All nodes of one test must share the scheduler name, but a different test class that uses the same name against the same tables would see this one’s rows; a non-clustered scheduler with that name would delete the fired-trigger rows of running nodes (see Clustering). With a container per class (static @Container) the problem does not arise.

  • Build cluster nodes by hand, as ClusterIT does with SpringApplicationBuilder, rather than as a shared @SpringBootTest context: a cluster node is not something to leave in the test context cache (a @SpringBootTest class that must start one marks itself @DirtiesContext).

  • Only the first node gets initialize-schema=always; the others use never, otherwise the second node’s script drops the tables under the first.

  • Never assert how the work was shared, only that no fire time ran twice and that recovery happened.

  • The victim JVM is started with the java.class.path of the test JVM; the test project sets Surefire’s useManifestOnlyJar to false so that this property lists the real classpath entries.

Testing @Scheduled with ShedLock, for comparison

The standard alternative to Quartz clustering, @Scheduled plus ShedLock, is covered on Scheduling & ShedLock and compared in Standard Scheduling vs Quartz. Its tests are lighter, because there is no scheduler to configure, and they split in three: the method is plain Java, the lock is a LockProvider that can be exercised directly, and the combination is two application contexts that share one lock table.

RetentionCleanup.java (src/main): a @Scheduled method guarded by ShedLock
package bookshelf;

import java.time.Instant;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

import net.javacrumbs.shedlock.core.LockAssert;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/** The standard-scheduling counterpart: @Scheduled guarded by ShedLock. */
@Component
public class RetentionCleanup {

    private final List<Instant> runs = new CopyOnWriteArrayList<>();

    @Scheduled(fixedRateString = "${bookshelf.retention.rate:PT1S}")
    @SchedulerLock(name = "retentionCleanup", lockAtMostFor = "PT30S", lockAtLeastFor = "PT3S")
    public void purgeOldLoans() {
        LockAssert.assertLocked();      // fails if the method is ever called without the lock
        runs.add(Instant.now());
    }

    public List<Instant> runs() {
        return runs;
    }
}

Source: ShedLock

LockAssert.assertLocked() makes the method fail if it is ever called without the lock, which is its value in production and its nuisance in a unit test; LockAssert.TestHelper.makeAllAssertsPass(true) switches the assertion off for a test, and makeAllAssertsPass(false) puts it back.

RetentionCleanupTest.java
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import javax.sql.DataSource;

import net.javacrumbs.shedlock.core.LockAssert;
import net.javacrumbs.shedlock.core.LockConfiguration;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.core.SimpleLock;
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.scheduling.annotation.EnableScheduling;

class RetentionCleanupTest {

    @Configuration(proxyBeanMethods = false)
    @EnableScheduling
    @EnableSchedulerLock(defaultLockAtMostFor = "PT30S")
    static class NodeConfig {
        @Bean
        DataSource dataSource() {
            return new DriverManagerDataSource("jdbc:h2:mem:shedlock-test;DB_CLOSE_DELAY=-1", "sa", "");
        }

        @Bean
        LockProvider lockProvider(DataSource dataSource) {
            return new JdbcTemplateLockProvider(dataSource);
        }

        @Bean
        RetentionCleanup retentionCleanup() {
            return new RetentionCleanup();
        }
    }

    private static void createShedLockTable() {
        new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:shedlock-test;DB_CLOSE_DELAY=-1", "sa", ""))
                .execute("CREATE TABLE IF NOT EXISTS shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL, "
                        + "locked_at TIMESTAMP(3) NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name))");
    }

    @Test
    void theMethodItselfIsPlainJavaOnceTheLockAssertionIsSwitchedOff() {
        LockAssert.TestHelper.makeAllAssertsPass(true);
        try {
            RetentionCleanup cleanup = new RetentionCleanup();
            cleanup.purgeOldLoans();
            assertThat(cleanup.runs()).hasSize(1);
        } finally {
            LockAssert.TestHelper.makeAllAssertsPass(false);
        }
    }

    @Test
    void withoutTheLockTheAssertionFails() {
        org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, new RetentionCleanup()::purgeOldLoans);
    }

    @Test
    void aSecondNodeCannotTakeTheLockWhileTheFirstHoldsIt() {
        createShedLockTable();
        LockProvider provider = new JdbcTemplateLockProvider(
                new DriverManagerDataSource("jdbc:h2:mem:shedlock-test;DB_CLOSE_DELAY=-1", "sa", ""));
        LockConfiguration config = new LockConfiguration(Instant.now(), "unit-lock", Duration.ofMinutes(1), Duration.ZERO);

        Optional<SimpleLock> first = provider.lock(config);
        Optional<SimpleLock> second = provider.lock(config);

        assertThat(first).isPresent();
        assertThat(second).isEmpty();
        first.get().unlock();
        assertThat(provider.lock(new LockConfiguration(Instant.now(), "unit-lock", Duration.ofMinutes(1), Duration.ZERO))).isPresent();
    }

    @Test
    void twoNodesRunTheTaskOncePerLockAtLeastForWindow() throws Exception {
        createShedLockTable();
        try (AnnotationConfigApplicationContext nodeA = new AnnotationConfigApplicationContext(NodeConfig.class);
                AnnotationConfigApplicationContext nodeB = new AnnotationConfigApplicationContext(NodeConfig.class)) {
            RetentionCleanup a = nodeA.getBean(RetentionCleanup.class);
            RetentionCleanup b = nodeB.getBean(RetentionCleanup.class);
            // both nodes try every second; lockAtLeastFor is three seconds
            await().atMost(Duration.ofSeconds(10)).until(() -> a.runs().size() + b.runs().size() >= 3);

            List<Instant> all = new ArrayList<>(a.runs());
            all.addAll(b.runs());
            all.sort(Instant::compareTo);
            System.out.println("SHEDLOCK runs A=" + a.runs().size() + " B=" + b.runs().size() + " at " + all);
            for (int i = 1; i < all.size(); i++) {
                assertThat(Duration.between(all.get(i - 1), all.get(i))).isGreaterThanOrEqualTo(Duration.ofMillis(2900));
            }
        }
    }
}

All four tests passed (6.2 s). Without the lock, the assertion fails with an IllegalStateException (second test). With a lock held on the shared H2 table, the second lock(…​) call returns an empty Optional until the first is unlocked (third test). And in the combined test two nodes, both trying every second, ran the method three times in about ten seconds, A=2 and B=1, at 05.351, 08.337 and 11.342 seconds past 11:31 UTC: every gap is at least the lockAtLeastFor of three seconds, which two unsynchronized one-second schedules could not produce. The test asserts the gaps, not the counts, because which node wins is not deterministic. This test uses H2 for speed; the lock table DDL and the other providers (Mongo, Redis) are on the ShedLock page, and the same test runs against a Testcontainers database by replacing the data source.

The difference to Quartz is what the test cannot show: a ShedLock test proves that overlapping runs are prevented, but there is no persisted trigger, no misfire handling and no recovery of a killed node’s run, so there is nothing equivalent to the fail-over test above.

The Bookshelf test suite

Put together, the Bookshelf scheduling tests form a pyramid with the cheap tests at the base. What runs where in a build:

Table 4. A build layout for the tests of this page
Phase and command Classes Why there

Every commit, mvn test (20 s wall clock for the 27 tests of the Test classes, from a cold start)

OverdueReminderJobTest, BookshelfTriggersTest, BookshelfSchedulerTest, QuartzEndpointTest, RetentionCleanupTest, LeakATest, LeakBTest, TransactionalTestTest

No Docker; they find logic, wiring and endpoint regressions

Every pull request, mvn verify with Failsafe

JdbcJobStoreIT, ClusterIT

They need Docker and take a minute; they find DDL, delegate, serialization and cluster regressions

Before a Quartz, Boot or JDBC driver upgrade

The same classes, plus a run on the production database image tag

An upgrade changes SQL scripts, delegates and defaults; the enum makes it one run for every database in use

Source: measured in a test run (Surefire results); see Maven Surefire Plugin and Maven Failsafe Plugin.

Keep the in-memory tests honest about what they do not prove: the memory store has no tables and no cluster, so a green BookshelfSchedulerTest says nothing about the production store. The first time the JDBC tests are added to the build is usually the first time someone learns that the production application.yml and the test application.yml configured different job stores.

Two traps that deserve a test of their own

Two behaviours of the test infrastructure itself changed how schedulers behave in a test suite, and both were measured with small classes. The first pair shows what happens to the scheduler of a test context after its test class has finished:

LeakATest.java and LeakBTest.java: the scheduler of an earlier test class
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.quartz.Scheduler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest(properties = {"bookshelf.reminders.cron=0/1 * * * * ?", "spring.quartz.scheduler-name=leakA"})
class LeakATest {
    static Scheduler scheduler;
    @Autowired Scheduler injected;

    @Test
    void firesEverySecond() throws Exception {
        scheduler = injected;
        assertThat(scheduler.isStarted()).isTrue();
    }
}

package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest(properties = {"spring.quartz.scheduler-name=leakB"})
class LeakBTest {
    @Test
    void theSchedulerOfTheEarlierTestClassIsStillRunning() throws Exception {
        long before = LeakATest.scheduler.getMetaData().getNumberOfJobsExecuted();
        Thread.sleep(3000);
        long after = LeakATest.scheduler.getMetaData().getNumberOfJobsExecuted();
        System.out.println("LEAK pause=" + System.getProperty("spring.test.context.cache.pause") + " A started=" + LeakATest.scheduler.isStarted()
                + " standby=" + LeakATest.scheduler.isInStandbyMode() + " shutdown=" + LeakATest.scheduler.isShutdown()
                + " jobsExecuted " + before + " -> " + after);
        assertThat(LeakATest.scheduler.isInStandbyMode()).isTrue();      // paused when the test context switched
        assertThat(after).isEqualTo(before);
    }
}

Surefire ran the classes in name order (junit-platform.properties sets junit.jupiter.testclass.order.default to org.junit.jupiter.api.ClassOrderer$ClassName). With the default settings LeakBTest passed: the scheduler of LeakATest, which fires every second, was started=true standby=true shutdown=false and its executed-jobs counter stayed at 1 → 1 over three seconds, because Spring Framework paused the inactive cached context when the next class used a different one. With -Dspring.test.context.cache.pause=never the same measurement printed standby=false and jobsExecuted 3 → 6: the scheduler of a finished test class kept firing for the rest of the build. The property is read by Spring’s SpringProperties, so it can also be set in a spring.properties file on the test classpath.

The second trap is the transaction of the test method. @Transactional on a test method wraps the method in a transaction that is rolled back at the end, and a Quartz worker thread is not part of it:

TransactionalTestTest.java
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;

import org.junit.jupiter.api.Test;
import org.quartz.JobBuilder;
import org.quartz.JobExecutionContext;
import org.quartz.Scheduler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.quartz.QuartzJobBean;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
class TransactionalTestTest {

    static final AtomicInteger SEEN_BY_JOB = new AtomicInteger(-1);

    public static class CountLoansJob extends QuartzJobBean {
        private final JdbcTemplate jdbc;
        public CountLoansJob(JdbcTemplate jdbc) { this.jdbc = jdbc; }
        @Override
        protected void executeInternal(JobExecutionContext context) {
            SEEN_BY_JOB.set(jdbc.queryForObject("SELECT COUNT(*) FROM loan_probe", Integer.class));
        }
    }

    @Autowired Scheduler scheduler;
    @Autowired JdbcTemplate jdbc;

    @Test
    @Transactional
    void aJobDoesNotSeeTheRowsOfTheTestTransaction() throws Exception {
        jdbc.execute("CREATE TABLE IF NOT EXISTS loan_probe(id INT)");
        jdbc.execute("DELETE FROM loan_probe");
        jdbc.update("INSERT INTO loan_probe VALUES (1)");          // uncommitted: rolled back when the test ends
        assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM loan_probe", Integer.class)).isEqualTo(1);

        scheduler.addJob(JobBuilder.newJob(CountLoansJob.class).withIdentity("probe", "bookshelf").storeDurably().build(), true);
        scheduler.triggerJob(org.quartz.JobKey.jobKey("probe", "bookshelf"));

        await().atMost(Duration.ofSeconds(5)).until(() -> SEEN_BY_JOB.get() >= 0);
        System.out.println("TX test sees 1 row, job sees " + SEEN_BY_JOB.get());
        assertThat(SEEN_BY_JOB.get()).isZero();
    }
}

The test passed with the output TX test sees 1 row, job sees 0: the test thread read its own uncommitted row, the job, on its own connection and transaction, read none. A job test that prepares data therefore commits it first (a TransactionTemplate, or no @Transactional on the test) and removes it afterwards.

Pitfalls

Table 5. Testing pitfalls and what actually happens (measured on Quartz 2.5.2, Boot 4.1.1, Spring Framework 7.0.9)
Mistake What happens What to do

triggerJob on a scheduler started with spring.quartz.auto-startup=false

Nothing runs: the test waited two seconds and the mock was never called; the job runs as soon as the scheduler is started

Call scheduler.start() in the test that needs execution, and stop it again

Calling QuartzJobBean.execute with a bare mock context

NullPointerException, because execute reads context.getScheduler().getContext()

Call executeInternal for logic, or stub getScheduler(), getContext() and getMergedJobDataMap() to test the setter binding

Building a trigger in a test and asserting fire times without pinning the start

The builder starts the trigger "now", so the expected dates are right only on the day the test was written

trigger.getTriggerBuilder().startAt(fixedDate).build() before computeFireTimes

Expecting the cached context of an earlier test class to keep running

Spring Framework 7.0.3 and later pause an inactive cached context when the next test uses a different one: after a class with a one-second cron finished, its scheduler was in standby and its job-executed counter did not move over a three-second wait (1 to 1)

Rely on it, but know that with spring.test.context.cache.pause=never (a system property or spring.properties entry) the same scheduler kept running (counter 3 to 6 in three seconds) and fires into your other tests

A @Transactional test method that inserts rows and then triggers a job

The job runs on a Quartz thread in its own transaction and does not see the uncommitted rows: the test saw 1 row and the job counted 0

Commit the data before triggering the job (a TransactionTemplate, or no @Transactional on the test), and clean up in @AfterEach

Asserting that the nodes of a cluster shared the work

Quartz is first come, first served: five consecutive fire times all ran on the first node

Assert that each fire time ran once, not where

Running the node to be killed in the test JVM

Nothing can be killed without killing the test; a clean close() is not a crash and leaves nothing to recover

Start the victim as a second JVM with ProcessBuilder and destroyForcibly

A fail-over timeout of a few seconds

Detection takes the 7.5 s grace plus the check-in cycles; the measured recovery with a 2 s interval was 10.60 s

Wait up to a minute with Awaitility, and shorten clusterCheckinInterval in tests only

Passing test properties with SpringApplicationBuilder.properties(…​) while application.yml sets the same key

The builder’s properties are default properties with the lowest precedence, so the YAML wins and the test silently runs on the memory store (isJobStoreSupportsPersistence was false)

Pass run("--key=value") arguments, or use @SpringBootTest(properties = …​)

Leaving Boot’s continue-on-error=true in a schema test

A script that fails halfway is skipped and the failure shows up later as a missing table

Set spring.quartz.jdbc.continue-on-error=false and tolerate errors only where the script is known to fail harmlessly (Oracle)

@MockBean or @SpyBean on Boot 4

The annotations are gone from spring-boot-test 4.1.1; the code does not compile

@MockitoBean and @MockitoSpyBean from org.springframework.test.context.bean.override.mockito

thread.sleep(5000) instead of Awaitility

The test is slow when the job is quick and flaky when the machine is busy

await().atMost(…​).untilAsserted(…​), and during(…​) to prove that something does not happen

Passing a real password through a command line or a checked-in file

Visible in the process list, in logs and in version control

Use the container’s generated credentials, as the tests above do, or an environment variable; never a real secret in a test

Expecting spring-boot-starter-quartz-test to add test utilities

The JAR contains no classes; the starter only brings spring-boot-starter-quartz and spring-boot-starter-test

Use it as a naming convention, and write the helpers (listener, recorder) yourself

Source: this page’s measured results, Spring Framework: Context Pausing and Quartz Best Practices.

What changed since Spring Boot 3.5

Most of the page is unchanged between Boot 3.5 and 4: Quartz is 2.5.2 on both, and QuartzJobBean, TriggerUtils, CronExpression and the Quartz API are identical. What moved is the test infrastructure around them. The 3.5 column below was checked against the published Boot 3.5.16, Spring Framework 6.2.19 and Testcontainers 1.21.4 artifacts and BOMs, not by re-running the tests.

Table 6. Test-related differences between Spring Boot 3.5.16 and 4.1.1
Topic Boot 3.5.16 (Spring Framework 6.2.19) Boot 4.1.1 (Spring Framework 7.0.9)

Test starter for Quartz

None; spring-boot-starter-test plus spring-boot-starter-quartz. The artifact spring-boot-starter-quartz-test has no 3.x version

spring-boot-starter-quartz-test (empty JAR, brings spring-boot-starter-quartz and spring-boot-starter-test)

@AutoConfigureMockMvc

org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc in spring-boot-test-autoconfigure; @SpringBootTest provides MockMvc support without it

org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc in spring-boot-webmvc-test (starter spring-boot-starter-webmvc-test); @SpringBootTest alone no longer provides MockMvc, and MockMvcTester is available in Spring Test

Replacing a bean with a mock

@MockBean and @SpyBean (org.springframework.boot.test.mock.mockito, still present in spring-boot-test 3.5.16) or @MockitoBean (present in Spring Framework 6.2.19)

@MockitoBean and @MockitoSpyBean only; @MockBean and @SpyBean were removed

Quartz Boot types used in tests

org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer, QuartzDataSource; endpoint class org.springframework.boot.actuate.quartz.QuartzEndpoint

org.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer, QuartzDataSource; endpoint class org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint

Test context cache

Inactive cached contexts keep running, so the scheduler of an earlier test class keeps firing

Contexts are paused when the next test uses a different one (Spring Framework 7.0.3 and later); controlled by spring.test.context.cache.pause with always, on_context_switch (the default) and never

JUnit

JUnit Jupiter 5.12.2

JUnit Jupiter 6.0.3 (the org.junit.jupiter.api and org.junit.jupiter.params annotations used here are unchanged)

Testcontainers

1.21.4: artifacts org.testcontainers:postgresql, org.testcontainers:junit-jupiter; classes org.testcontainers.containers.PostgreSQLContainer<?> (generic)

2.0.5: artifacts org.testcontainers:testcontainers-postgresql, testcontainers-junit-jupiter; class org.testcontainers.postgresql.PostgreSQLContainer (not generic); the old org.testcontainers.containers names remain as deprecated aliases in the module JARs. The same applies to MySQL (org.testcontainers.mysql), MariaDB (org.testcontainers.mariadb), Oracle Free (org.testcontainers.oracle.OracleContainer, artifact testcontainers-oracle-free) and SQL Server (org.testcontainers.mssqlserver.MSSQLServerContainer)

Awaitility, Mockito

4.2.2, 5.17.0

4.3.0, 5.23.0

Source: Spring Boot 4.0 Migration Guide, Testcontainers 2.0.0 release notes, JUnit 6.0.3 release notes and the Maven Central artifacts and BOMs named in the table.

For a migrating project the mechanical edits are: change the AutoConfigureMockMvc import and add spring-boot-starter-webmvc-test; replace @MockBean with @MockitoBean (and the import); rename the Testcontainers artifacts and imports; add spring-boot-starter-quartz-test if the build follows the new naming; and check whether any test relied on a cached context’s scheduler still running, since that no longer holds by default. The migration of production code and configuration is on Migration & What’s Changed.

Where to go next