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.
| Level | What it exercises | Cost |
|---|---|---|
| 0 | schedule arithmetic — when would this fire? | microseconds, fully deterministic |
| 1 | one job's Execute, against a context you build | microseconds, fully deterministic |
| 2 | a real in-memory scheduler | milliseconds, needs a completion signal |
| 3 | the whole host, or a real database | seconds |
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:
| Member | Answers |
|---|---|
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
HttpClientcannot 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 thatCA2016flags 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 usingthe factory. DisposingStandaloneSchedulerFactorydisposes 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
SimpleTriggerrepeats, 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
