Quartz.NETQuartz.NET
Home
Features
Discussions
NuGet
GitHub
Home
Features
Discussions
NuGet
GitHub
  • Getting Started

    • Quartz 3 Quick Start
    • Tutorial
      • Using Quartz
      • Library Overview
      • Jobs And Triggers
      • More About Jobs
      • More About Triggers
      • Execution Groups
      • Node Affinity (Preferred Node)
      • Simple Triggers
      • Cron Triggers
      • RecurrenceTrigger
      • Trigger and Job Listeners
      • Scheduler Listeners
      • Job Stores
      • Tuning the Scheduler
      • Configuration, Resource Usage and SchedulerFactory
      • Advanced (Enterprise) Features
    • Configuration Reference
    • JSON Configuration
    • Multi-Tenancy
    • Frequently Asked Questions
    • Best Practices
    • Tenancy Patterns
    • Troubleshooting
    • API Documentation
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Miscellaneous Features
  • How To's

    • One-Off Job
    • Multiple Triggers
    • Job Template
    • Using the CronTrigger
    • Rescheduling Jobs
  • Packages

    • Quartz Core Additions

      • Dashboard
      • Jobs
      • Serialization (System.Text.Json)
      • Serialization (Newtonsoft Json.NET)
      • Plugins
    • Integrations

      • ASP.NET Core Integration
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • OpenTelemetry Integration
      • OpenTracing Integration
      • Redis Lock Handler
      • TimeZoneConverter Integration
    • 3rd Party Plugins for Quartz
  • Unreleased Releases

    • Quartz 4.x
      • Quartz 4 Quick Start
      • Tutorial
        • Using Quartz
        • Jobs And Triggers
        • More About Jobs & JobDetails
        • Job Data
        • More About Triggers
        • Querying Jobs and Triggers
        • Simple Triggers
        • Cron Triggers
        • RecurrenceTrigger
        • Time and TimeProvider
        • Trigger and Job Listeners
        • Scheduler Listeners
        • Job Stores
        • Configuration, Resource Usage and Building a Scheduler
        • Building a Scheduler Without a Host
        • Clustering
        • Execution Groups
        • Node Affinity (Preferred Node)
        • Testing
      • Configuration Reference
      • JSON Configuration
      • Cron Expression Reference
      • Multi-Tenancy
      • Frequently Asked Questions
      • Best Practices
      • Tenancy Patterns
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Troubleshooting
      • API Documentation
      • How To's
        • One-Off Job
        • Rescheduling Jobs
        • Multiple Triggers
        • Job Template
        • A Job Store of Your Own
        • A Driver Delegate for a New Database
        • Persisting a Custom Trigger Type
        • A Lock Handler of Your Own
      • Packages

        • Quartz Core Additions

          • Jobs
          • Serialization (System.Text.Json)
          • JSON Serialization
          • Plugins
        • Integrations

          • ASP.NET Core Integration
          • HTTP API
          • HTTP Client
          • Dashboard
          • Hosted Services Integration
          • Microsoft DI Integration
          • Multiple Schedulers with Microsoft DI
          • Observability
          • Redis Lock Handler
          • TimeZoneConverter Integration
        • 3rd Party Plugins for Quartz
  • Old Releases

    • Quartz 2.x
      • Quartz 2 Quick Start
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
        • CronTrigger Tutorial
      • Configuration Reference
      • Migration Guide
      • API Documentation
    • Quartz 1.x
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
      • API Documentation
  • License

Scheduling code is unusually easy to test badly. A test that starts a scheduler, sleeps two seconds and asserts that a counter moved is a test that passes on your laptop and fails in CI, and the fix people reach for — a longer sleep — makes the suite slower without making it correct.

There are four levels of Quartz test, in increasing cost. Most of what you want to know can be answered at the first two, which involve no scheduler at all or no clock at all.

LevelWhat it exercisesCost
0schedule arithmetic — when would this fire?microseconds, fully deterministic
1one job's Execute, against a context you buildmicroseconds, fully deterministic
2a real in-memory schedulermilliseconds, needs a completion signal
3the whole host, or a real databaseseconds

Level 0: schedules, with no scheduler

A trigger is a pure function from a start time to a sequence of fire times, and you can call it directly. This is where a fake clock is completely effective, and it is where most schedule bugs actually live.

[Test]
public void CronScheduleSkipsWeekends()
{
    FakeTimeProvider clock = new(new DateTimeOffset(2026, 3, 6, 0, 0, 0, TimeSpan.Zero)); // a Friday

    IOperableTrigger trigger = (IOperableTrigger) TriggerBuilder.Create(clock)
        .WithIdentity("weekdays")
        .StartAt(clock.GetUtcNow())
        .WithCronSchedule("0 0 9 ? * MON-FRI", x => x.InTimeZone(TimeZoneInfo.Utc))
        .Build();

    List<DateTimeOffset> fires = TriggerFireTimes.Compute(trigger, calendar: null, numberOfTimes: 3);

    fires[0].Should().Be(new DateTimeOffset(2026, 3, 6, 9, 0, 0, TimeSpan.Zero));
    fires[1].Should().Be(new DateTimeOffset(2026, 3, 9, 9, 0, 0, TimeSpan.Zero));  // Monday
    fires[2].Should().Be(new DateTimeOffset(2026, 3, 10, 9, 0, 0, TimeSpan.Zero));
}

TriggerFireTimes lives in Quartz.Extensibility and has three members:

MemberAnswers
Compute(trigger, calendar, numberOfTimes)the next n fire times
ComputeBetween(trigger, calendar, from, to)every fire time in a window
ComputeEndTimeForCount(trigger, calendar, numberOfTimes)the EndAt that would allow exactly n firings

All three take an IOperableTrigger, so cast the trigger the builder handed you. They clone it before computing and prime it themselves, so you do not have to call ComputeFirstFireTimeUtc first, and the trigger you passed in is untouched.

For a single step, ITrigger.GetFireTimeAfter(DateTimeOffset?) answers "and then?" directly — it computes from the schedule rather than from stored state, so it works on a trigger that has never been scheduled.

Warning

TriggerBuilder.Create() with no argument defaults its start time to the wall clock, even inside a test holding a FakeTimeProvider. Pass the clock — TriggerBuilder.Create(clock) — and set StartAt explicitly. See Time and TimeProvider.

Calendars are testable the same way: ICalendar.IsTimeIncluded(when) needs nothing but the calendar.

Level 1: one job, one context

A job's Execute takes an IJobExecutionContext. Build one and call it:

[Test]
public async Task ImportJobWritesTheWatermark()
{
    IJobDetail detail = JobBuilder.Create<ImportJob>()
        .WithIdentity("import", "sync")
        .UsingJobData("source", "orders")
        .Build();

    IOperableTrigger trigger = (IOperableTrigger) TriggerBuilder.Create()
        .WithIdentity("import-trigger", "sync")
        .ForJob(detail)
        .Build();

    TriggerFiredBundle bundle = new()
    {
        JobDetail = detail,
        Trigger = trigger,
        Recovering = false,
        FireTimeUtc = new DateTimeOffset(2026, 3, 6, 9, 0, 0, TimeSpan.Zero),
        ScheduledFireTimeUtc = new DateTimeOffset(2026, 3, 6, 9, 0, 0, TimeSpan.Zero),
        PreviousFireTimeUtc = null,
        NextFireTimeUtc = null,
    };

    ImportJob job = new(importer);
    using JobExecutionContextImpl context = new(scheduler: null!, bundle, job);

    await job.Execute(context, CancellationToken.None);

    importer.LastSource.Should().Be("orders");
}

TriggerFiredBundle is a required-init record, which is what makes this level teachable — the compiler lists what a firing consists of. Seven members are required: JobDetail, Trigger, Recovering, FireTimeUtc, ScheduledFireTimeUtc, PreviousFireTimeUtc and NextFireTimeUtc. Three of those are nullable but still required, so you have to say null rather than forget them. Calendar is optional.

JobExecutionContextImpl does no null-checking in its constructor — it copies fields. Passing null for the scheduler and the job is fine as long as the code under test does not reach for them; reach for context.Scheduler with a null scheduler and you get the NullReferenceException you asked for. Fake the scheduler when the job uses it.

Warning

context.JobRunTime while a job is still running is computed from DateTimeOffset.UtcNow, not from the scheduler's TimeProvider. Under a fake clock set to a different instant the mid-execution value is meaningless and can be negative. The value the scheduler records after the job completes is measured from a monotonic timestamp and is always sane.

Keeping jobs testable

Three habits make level 1 the level you spend most of your time at:

  • Inject dependencies through the constructor. The container builds jobs; a job that news up its own HttpClient cannot be tested without a network.
  • Read inputs from MergedJobDataMap, or let the job factory set properties for you. Either way the inputs are data you can supply.
  • Forward the cancellation token. Execute(context, cancellationToken) receives it as a parameter precisely so that CA2016 flags a job that drops it — and a job that drops it cannot be tested for cancellation.

Jobs the container builds

When a job takes constructor dependencies, resolve it the way the scheduler will:

ServiceCollection services = new();
services.AddSingleton<IImporter, FakeImporter>();
services.AddTransient<ImportJob>();
ServiceProvider provider = services.BuildServiceProvider();

MicrosoftDependencyInjectionJobFactory factory = new(provider);
JobScope scope = await factory.CreateJob(bundle, scheduler);
try
{
    await scope.Job.Execute(context, CancellationToken.None);
}
finally
{
    await factory.ReturnJob(scope);
}

That exercises the whole instantiation path — the scope, the property injection, and any ConfigureJobScope hook. It is also the level at which to test a per-firing AsyncLocal: the hook is deliberately synchronous so that values it sets flow into Execute, and this is the test that proves they do.

Changed in 4.x

The scheduler context is no longer merged into the properties the job factory sets, and no longer merged into context.MergedJobDataMap. A job that read a scheduler-wide value from either now reads it from context.Scheduler.Context.

Level 2: a real in-memory scheduler

When the thing under test is the wiring — that a trigger reaches a job, that a listener vetoes, that [DisallowConcurrentExecution] does what it says — run a real scheduler in memory:

await using StandaloneSchedulerFactory factory = QuartzSchedulerBuilder.Create()
    .UseInMemoryStore()
    .ConfigureScheduler(o => o.InstanceName = $"test-{Guid.NewGuid():N}")
    .Build();

IScheduler scheduler = await factory.GetScheduler();
await scheduler.Start();

BuildScheduler() is the shortcut when you do not need the factory, but hold the factory in a test: disposing it is what shuts the scheduler down and releases its container.

Signal completion; never sleep

The one rule that makes level 2 reliable: the job tells the test when it is done. A TaskCompletionSource on a listener is the tidiest form, and because IJobListener has a default implementation for every member you only write the one you need:

internal sealed class CompletionListener : IJobListener
{
    private readonly TaskCompletionSource<JobExecutionException?> completed =
        new(TaskCreationOptions.RunContinuationsAsynchronously);

    public Task<JobExecutionException?> Completed => completed.Task;

    public ValueTask JobWasExecuted(
        IJobExecutionContext context,
        JobExecutionException? jobException,
        CancellationToken cancellationToken = default)
    {
        completed.TrySetResult(jobException);
        return default;
    }
}
CompletionListener listener = new();
scheduler.ListenerManager.AddJobListener(listener);

await scheduler.ScheduleJob(detail, trigger);

JobExecutionException? failure = await listener.Completed.WaitAsync(TimeSpan.FromSeconds(30));
failure.Should().BeNull();

Two details worth copying:

  • RunContinuationsAsynchronously. Without it the continuation runs on the scheduler's own thread, inside the notification, and a test that then blocks deadlocks the scheduler.
  • A generous deadline, never a timing assertion. Thirty seconds is not "the job takes thirty seconds"; it is "if we are still waiting after thirty seconds, something is broken". The deadline decides when it is safe to give up, not when it is correct to look.

Registering a listener with no matchers means every job. IJobListener.Name defaults to the implementing type's name, which is fine until you register two instances of the same listener type with one scheduler — the second replaces the first. Override Name when you do that.

The same shape works for ITriggerListener (whose VetoJobExecution defaults to vetoing nothing) and ISchedulerListener.

Changed in 4.x

JobListenerSupport, TriggerListenerSupport and SchedulerListenerSupport are gone. The interfaces carry default implementations now, so implement the interface directly — and note the namespace for the shipped listeners is Quartz.Listeners, plural.

Asserting on the outcome

// what state did the trigger end in?
PagedResult<TriggerHeader> triggers = await scheduler.QueryTriggers(
    new TriggerQuery { State = TriggerState.Error });
triggers.Items.Should().BeEmpty();

// what is running right now?
PagedResult<FireInstance> running = await scheduler.QueryFireInstances(new FireInstanceQuery());

// what did the job produce?
context.Result.Should().Be(42);

Remember that a query pages: Take defaults to 250, so an assertion on a large result set needs Take = int.MaxValue or a loop. See Querying Jobs and Triggers.

Changed in 4.x

GetCurrentlyExecutingJobs() is gone; QueryFireInstances(new FireInstanceQuery()) is the replacement, and it lists firings across the cluster rather than only on the node that answered.

Controlling time

Give the scheduler a FakeTimeProvider and every computation moves when you advance it:

FakeTimeProvider clock = new(new DateTimeOffset(2026, 3, 6, 8, 0, 0, TimeSpan.Zero));

await using StandaloneSchedulerFactory factory = QuartzSchedulerBuilder.Create()
    .UseInMemoryStore()
    .UseTimeProvider(clock)
    .Build();

Advancing the clock does not wake the scheduler. The scheduling loop reads the TimeProvider for every decision, but it waits on a SemaphoreSlim, which only knows about real elapsed time. So:

Advance, then signal. Move the fake clock, then do something that signals a scheduling change — scheduling, rescheduling, pausing or resuming anything releases the loop's semaphore and it re-evaluates immediately against the new "now".

clock.Advance(TimeSpan.FromHours(2));
await scheduler.ScheduleJob(detail, trigger);   // this both schedules and wakes the loop

Where there is nothing natural to signal, shorten the wait instead:

.ConfigureScheduler(o => o.IdleWaitTime = TimeSpan.FromSeconds(1))

One second is the minimum the option validator accepts; the default is thirty. The misfire handler and the cluster manager have the same property — they compute on the TimeProvider but wake on their own real delay — so misfire recovery and cluster check-in are equally undrivable by Advance alone.

Never write Advance(1h) and assert "therefore it fired". That test passes or fails on wall-clock timing, which is the thing the fake clock was supposed to remove.

Testing misfire behaviour

Misfire is a comparison between a trigger's scheduled time and now, so a fake clock plus a small threshold makes it reachable:

QuartzSchedulerBuilder.Create()
    .UseInMemoryStore(o => o.MisfireThreshold = TimeSpan.FromMilliseconds(50))
    .UseTimeProvider(clock)

The in-memory store's threshold defaults to five seconds, the ADO store's to one minute, and both must be at least one millisecond. Put the scheduler in standby, move the clock past the fire time, then start it — the trigger is late by construction, with no sleeping involved.

Fault injection

DelegatingJobStore is public, non-sealed and virtual throughout, for exactly this:

internal sealed class FlakyJobStore(IJobStore inner) : DelegatingJobStore(inner)
{
    public int AcquireCalls { get; private set; }

    public override ValueTask<List<IOperableTrigger>> AcquireNextTriggers(
        TriggerAcquisitionRequest request,
        CancellationToken cancellationToken = default)
    {
        AcquireCalls++;
        if (AcquireCalls == 1)
        {
            throw new JobPersistenceException("simulated outage");
        }

        return base.AcquireNextTriggers(request, cancellationToken);
    }
}
QuartzSchedulerBuilder.Create()
    .UseJobStore(sp => new FlakyJobStore(ActivatorUtilities.CreateInstance<RAMJobStore>(sp)))

Counting, stalling and failing store calls is how you test retry and backoff behaviour without a database. DelegatingScheduler is the same idea one layer up.

Level 3: the host, and a real database

Under a host

AddQuartz plus AddQuartzHostedService inside WebApplicationFactory<TProgram> exercises the real startup path — configuration binding, hosted-service ordering, the lot:

await using WebApplicationFactory<Program> app = new();
IScheduler scheduler = app.Services.GetRequiredService<IScheduler>();

Set WaitForJobsToComplete = true on QuartzHostedServiceOptions in tests so that teardown does not race a running job. AwaitApplicationStarted and StartDelay are the other two knobs, and both change when jobs first become eligible — worth setting explicitly rather than inheriting.

Against a real database

An ADO job store test needs a real database, because the behaviour under test is largely SQL. Provision one per fixture with Testcontainers, and create the schema from the shipped DDL — database/tables/tables_<dialect>.sql — rather than from a hand-maintained copy. Applying it with the engine's own client is what handles the dialect's batch separator (GO, /, SET TERM); a plain ExecuteNonQuery over the whole file will not.

A container per fixture, not per test: starting SQL Server takes longer than every test in the class. Isolate the tests inside it with a unique scheduler name, which is what SCHED_NAME partitions the tables by.

Isolation rules

Four things keep tests from contaminating each other:

  • A unique instance name per test. The scheduler repository indexes by name, and binding a second scheduler with the same name and the same instance id throws. $"test-{Guid.NewGuid():N}" is enough.
  • One container per test. QuartzSchedulerBuilder.Build() creates its own service provider and therefore its own scheduler repository — two builders never see each other's schedulers. That is what makes parallel tests safe, and it is also why a test cannot look up another test's scheduler.
  • await using the factory. Disposing StandaloneSchedulerFactory disposes its container and shuts the scheduler down. A leaked scheduler keeps a scheduling loop running for the rest of the run.
  • Shutdown(waitForJobsToComplete: true) when a job may still be in flight and you need it finished before assertions or cleanup.

Anti-patterns

  • Sleeping for a fire. await Task.Delay(2000) is a coin flip on a loaded CI agent. Signal.
  • Sharing one scheduler across tests. State from one test — a paused group, a stored job, a listener — leaks into the next, and the failure surfaces in whichever test happens to run second.
  • Asserting on wall-clock times. firedAt.Should().BeCloseTo(expected, 100.Milliseconds()) is a flake waiting for a slow day. Assert on the fire times the trigger computes (level 0), and on ordering and counts everywhere else.
  • Testing Quartz. That a SimpleTrigger repeats, or that pausing a group stops it firing, is tested here. Test your schedule and your job.

See also

  • Time and TimeProvider — the clock seam, and where a fake clock reaches
  • Building a Scheduler Without a Host — the builder these tests use
  • Querying Jobs and Triggers — the assertions available after a run
Help us by improving this page!
Last Updated: 8/23/26, 6:42 AM
Contributors: Marko Lahma
Prev
Node Affinity (Preferred Node)