Test Quartz code at the cheapest level that answers the question. A test that starts a scheduler, sleeps two seconds and checks a counter passes locally and fails in CI; a longer sleep only makes it slower. Most questions are answered at levels 0 and 1, which need no scheduler or no clock.
| 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; call it directly. A fake clock works fully here, and most schedule bugs are found here.
[Test]
public void CronScheduleSkipsWeekends()
{
FakeTimeProvider clock = new(new DateTimeOffset(2026, 3, 6, 0, 0, 0, TimeSpan.Zero)); // a Friday
ITrigger trigger = 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 |
- Each has an
ITriggeroverload and anIOperableTriggerone. TheITriggerform casts for you, and throws anArgumentExceptionnaming the type for a trigger of your own that cannot be advanced. - They clone the trigger and prime it themselves: you need not call
ComputeFirstFireTimeUtcfirst, and the trigger you passed is untouched. - For a single step,
ITrigger.GetFireTimeAfter(DateTimeOffset?)computes from the schedule, not from stored state, so it works on a trigger that was never 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.
The built trigger keeps the clock, so every later "now" it reads (a cron trigger's past-due clamp in ComputeFirstFireTimeUtc, and all of UpdateAfterMisfire) comes from it, not from the machine.
Calendars are testable the same way: ICalendar.IsTimeIncluded(when) needs nothing but the calendar.
Crossing a daylight-saving transition
To test what a schedule does on the two days a year the local clock is not monotonic: set the fake clock a day before a transition, put the trigger in a real time zone, and compute the window. No scheduler and no waiting.
[Test]
public void DailyCronKeepsItsLocalTimeAcrossSpringForward()
{
// Europe/Helsinki springs forward at 03:00 local on 2026-03-29
TimeZoneInfo helsinki = TimeZoneInfo.FindSystemTimeZoneById("Europe/Helsinki");
FakeTimeProvider clock = new(new DateTimeOffset(2026, 3, 27, 0, 0, 0, TimeSpan.Zero));
ITrigger trigger = TriggerBuilder.Create(clock)
.WithIdentity("nightly")
.StartAt(clock.GetUtcNow())
.WithCronSchedule("0 30 2 * * ?", x => x.InTimeZone(helsinki))
.Build();
List<DateTimeOffset> fires = TriggerFireTimes.ComputeBetween(
trigger,
calendar: null,
from: new DateTimeOffset(2026, 3, 27, 0, 0, 0, TimeSpan.Zero),
to: new DateTimeOffset(2026, 3, 31, 0, 0, 0, TimeSpan.Zero));
// 02:30 local every day: +02:00 before the transition, +03:00 after it
fires.Select(fire => TimeZoneInfo.ConvertTime(fire, helsinki).TimeOfDay)
.Should().AllBeEquivalentTo(new TimeSpan(2, 30, 0),
"a cron trigger keeps its local wall-clock time, so the UTC instant moves instead");
}
Test these three cases the same way:
- A time that does not exist, in the hour spring-forward skips:
0 30 3 * * ?in the zone above. Assert on the instant the trigger picks. On 2026-03-29 that schedule fires at 04:00 local, when the clock jumps, not at 03:30 of either offset. - A time that happens twice, in the hour fall-back repeats. Assert on the count of firings in the window: one or two.
- An interval schedule across the boundary. Interval triggers count elapsed time by default, so a 24-hour
SimpleTriggerthat fired at 02:30 fires at 03:30 local afterwards. So does a one-dayCalendarIntervalTrigger, unless you callPreserveHourOfDayAcrossDaylightSavings(), which keeps 02:30. The test shows which of the two your schedule does.
TimeZoneInfo.FindSystemTimeZoneById("Europe/Helsinki") resolves IANA ids on Windows too since .NET 6. Use Quartz.Plugins.TimeZoneConverter where it still cannot.
Level 1: one job, one context
A job's Execute takes an IJobExecutionContext. Build one with JobExecutionContextBuilder, from Quartz.Extensibility, and call the job:
IJobDetail detail = JobBuilder.Create<ImportJob>()
.WithIdentity("import", "sync")
.UsingJobData("source", "orders")
.Build();
ImportJob job = new(importer);
using JobExecutionContextImpl context = JobExecutionContextBuilder.For(job)
.WithJob(detail)
.FiredAt(new DateTimeOffset(2026, 3, 6, 9, 0, 0, TimeSpan.Zero))
.Build();
await job.Execute(context, CancellationToken.None);
Then assert on what the job did: importer.LastSource.Should().Be("orders").
| Member | Sets | When left out |
|---|---|---|
For(job) | the instance whose Execute runs | required |
WithJob(detail) | context.JobDetail and its job data | a job detail of the job's type |
WithTrigger(trigger) | context.Trigger, whose job data wins in MergedJobDataMap | a trigger that fires once |
FiredAt(fireTime, scheduledFireTime) | FireTimeUtc and ScheduledFireTimeUtc | now, on time |
WithInput(input) | what an IJob<TInput> is handed and GetInput<T>() reads | no input |
WithScheduler(scheduler) | context.Scheduler | null |
- The trigger is copied, as a job store copies the trigger it fires, so the one you pass is unchanged.
NextFireTimeUtcis the next time the trigger's schedule gives, as a job store reports it.context.Schedulerisnullunless you pass one. Fake the scheduler when the job uses it.- Dispose the context: it owns a cancellation source once the job reads its token.
Warning
context.JobRunTime while a job is running is computed from DateTimeOffset.UtcNow, not from the scheduler's TimeProvider. Under a fake clock set to another instant the mid-execution value is meaningless and can be negative. The value recorded after the job completes uses a monotonic timestamp and is always correct.
Keeping jobs testable
- Inject dependencies through the constructor. A job that creates its own
HttpClientcannot be tested without a network. - Read inputs from
MergedJobDataMap, or let the job factory set properties. Either way the test supplies them as data. - Forward the cancellation token. It is a parameter of
Execute(context, cancellationToken)so thatCA2016flags a job that drops it; such a job cannot be tested for cancellation.
Jobs the container builds
When a job takes constructor dependencies, resolve it the way the scheduler will. A job factory takes the TriggerFiredBundle a job store hands the scheduler, so build that as well:
ServiceCollection services = new();
services.AddSingleton<IImporter, FakeImporter>();
services.AddTransient<ImportJob>();
ServiceProvider provider = services.BuildServiceProvider();
DateTimeOffset firedAt = new(2026, 3, 6, 9, 0, 0, TimeSpan.Zero);
TriggerFiredBundle bundle = new()
{
JobDetail = detail,
Trigger = (IOperableTrigger) TriggerBuilder.Create().ForJob(detail).StartAt(firedAt).Build(),
Recovering = false,
FireTimeUtc = firedAt,
ScheduledFireTimeUtc = firedAt,
PreviousFireTimeUtc = null,
NextFireTimeUtc = null,
};
MicrosoftDependencyInjectionJobFactory factory = new(provider);
JobScope scope = await factory.CreateJob(bundle, scheduler);
try
{
using JobExecutionContextImpl context = JobExecutionContextBuilder.For(scope.Job)
.WithJob(detail)
.FiredAt(firedAt)
.Build();
await scope.Job.Execute(context, CancellationToken.None);
}
finally
{
await factory.ReturnJob(scope);
}
TriggerFiredBundle is a required-init record: write null for the three nullable members explicitly.
This exercises the whole instantiation path: the scope, property injection, and any ConfigureJobScope hook. Test a per-firing AsyncLocal here: the hook is synchronous so that values it sets flow into Execute, and this test 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
To test the wiring (a trigger reaches a job, a listener vetoes, [DisallowConcurrentExecution] works), run a real scheduler in memory:
await using StandaloneSchedulerFactory factory = QuartzSchedulerBuilder
.Create(q => q
.UseInMemoryStore()
.ConfigureScheduler(o => o.InstanceName = $"test-{Guid.NewGuid():N}"))
.Build();
IScheduler scheduler = await factory.GetScheduler();
await scheduler.Start();
In a test, hold the factory rather than using BuildScheduler(): disposing it shuts the scheduler down and releases its container.
Signal completion; never sleep
The job tells the test when it is done. Use a TaskCompletionSource on a listener. IJobListener has a default implementation for every member, so write only 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();
RunContinuationsAsynchronously. Without it the continuation runs on the scheduler's thread, inside the notification, and a test that then blocks deadlocks the scheduler.- A generous deadline, never a timing assertion. Thirty seconds means "if still waiting, something is broken", not "the job takes thirty seconds".
- A listener with no matchers hears every job.
IJobListener.Namedefaults to the type's name. A second instance of the same type registered with one scheduler replaces the first; overrideNameto register both.- The same shape works for
ITriggerListener(whoseVetoJobExecutiondefaults to vetoing nothing) andISchedulerListener.
Changed in 4.x
JobListenerSupport, TriggerListenerSupport and SchedulerListenerSupport are gone. The interfaces have default implementations, so implement the interface directly. The shipped listeners' namespace is Quartz.Listeners, plural.
Asserting on the outcome
// what state did the trigger end in?
PagedResult<TriggerHeader> triggers = await scheduler.QueryTriggersInError();
triggers.Items.Should().BeEmpty();
// what is running right now?
PagedResult<FireInstance> running = await scheduler.QueryFireInstances();
// what did the job produce?
context.Result.Should().Be(42);
Queries page: Take defaults to 250, so an assertion on a large result set needs Take = PagedQuery.All or a loop. See Querying Jobs and Triggers.
Changed in 4.x
GetCurrentlyExecutingJobs() is gone. QueryFireInstances() replaces it and lists firings across the cluster, not only on the node that answered.
Controlling time
With a FakeTimeProvider, the scheduler computes and waits on the fake clock:
FakeTimeProvider clock = new(new DateTimeOffset(2026, 3, 6, 8, 0, 0, TimeSpan.Zero));
await using StandaloneSchedulerFactory factory = QuartzSchedulerBuilder
.Create(q => q
.UseInMemoryStore()
.UseTimeProvider(clock))
.Build();
Advancing the clock wakes the scheduler. The scheduling loop's waits are timers on the TimeProvider, so Advance fires the ones that came due, and a trigger that came due fires:
ITrigger trigger = TriggerBuilder.Create(clock)
.ForJob(detail)
.StartAt(clock.GetUtcNow().AddHours(1))
.Build();
await scheduler.ScheduleJob(detail, trigger);
clock.Advance(TimeSpan.FromHours(1));
JobExecutionException? failure = await listener.Completed.WaitAsync(TimeSpan.FromSeconds(30));
- The job still runs on a pool thread. Wait for its completion signal with a real deadline; the advance makes the trigger due, it does not run the job.
- The misfire handler's scan and the cluster check-in sleep on the
TimeProvidertoo, soAdvancedrives misfire recovery and check-in. - Advance in steps. A jump past a fire time by more than the misfire threshold is a misfire, which the trigger's misfire instruction handles, not a replay of every firing in between.
- A shutdown never waits for the clock: it cancels every wait.
Testing misfire behaviour
A misfire compares a trigger's scheduled time with now, so a fake clock and a small threshold produce one:
QuartzSchedulerBuilder.Create(q => q
.UseInMemoryStore(o => o.MisfireThreshold = TimeSpan.FromMilliseconds(50))
.UseTimeProvider(clock))
| Store | MisfireThreshold default | Minimum |
|---|---|---|
| in-memory | five seconds | one millisecond |
| ADO | one minute | one millisecond |
Put the scheduler in standby, move the clock past the fire time, then start it: the trigger is late, with no sleeping.
Fault injection
DelegatingJobStore is public, non-sealed and virtual throughout, for this purpose:
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(q => q
.UseJobStore(sp => new FlakyJobStore(ActivatorUtilities.CreateInstance<RAMJobStore>(sp))))
Count, stall and fail store calls to test retry and backoff without a database. DelegatingScheduler does the same 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, including configuration binding and hosted-service ordering:
await using WebApplicationFactory<Program> app = new();
IScheduler scheduler = app.Services.GetRequiredService<IScheduler>();
- Set
WaitForJobsToComplete = trueonQuartzHostedServiceOptions, so teardown does not race a running job. AwaitApplicationStartedandStartDelaychange when jobs first become eligible; set them explicitly in tests.
Against a persistent store, without Docker
Testing persistence (a job survives a restart, job data round-trips through the serializer, your trigger's persistence delegate writes what it reads) needs no server. A file SQLite database plus ProvisionSchema() gives a real ADO job store in milliseconds, with no container:
string databasePath = Path.Combine(Path.GetTempPath(), $"quartz-{Guid.NewGuid():N}.db");
await using StandaloneSchedulerFactory factory = QuartzSchedulerBuilder
.Create(q =>
{
q.ConfigureScheduler(options => options.InstanceName = $"test-{Guid.NewGuid():N}");
q.UsePersistentStore(store =>
{
store.UseSqlite($"Data Source={databasePath}");
// creates the twelve tables in the empty file; see Creating the schema
store.ProvisionSchema();
});
})
.Build();
- Use a file, not
:memory:. An in-memory SQLite database lives as long as its connection, and the store opens one per operation, so the tables vanish between operations. Delete the file in teardown. - It tells you nothing dialect-specific: the SQL a
SqlServerDelegateemits, how Postgres locks a row, whether an index is used. - It cannot be clustered: SQLite locks in process, and
UseClustering()with it is refused at startup.
For those, use a real database.
Against a real database
A test of SQL (a driver delegate, a lock handler, a migration, an index) needs the engine it is written for.
- Provision one per fixture with Testcontainers, not per test: starting SQL Server takes longer than every test in the class.
- Create the schema from the shipped DDL,
database/tables/tables_<dialect>.sql, not from a hand-maintained copy. - Apply it with the engine's own client, which handles the dialect's batch separator (
GO,/,SET TERM); a plainExecuteNonQueryover the whole file does not.
Isolation rules
- A unique instance name per test. The scheduler repository indexes by name, and binding a second scheduler with the same name and instance id throws.
$"test-{Guid.NewGuid():N}"is enough. - One container per test.
QuartzSchedulerBuilder.Build()creates its own service provider and scheduler repository, so two builders never see each other's schedulers. Parallel tests are safe, and a test cannot look up another test's scheduler. await usingthe factory. DisposingStandaloneSchedulerFactoryshuts the scheduler down and disposes its container. A leaked scheduler keeps a scheduling loop running for the rest of the run.Shutdown(waitForJobsToComplete: true)when a job may still be running and must finish before assertions or cleanup.
With a persistent store, state outlives the process, so also:
SCHED_NAMEis the partition. Every Quartz table has it and every store statement filters on it, so the uniqueInstanceNameper test also isolates tests inside one database. That makes a container per fixture affordable.- Give each test its own database file, or clean up. A shared file with unique names works but grows; a file per test is simpler and cheap with SQLite. For a shared fixture,
IScheduler.Clear()resets that scheduler name: it deletes the jobs, the triggers of every type, the calendars, the paused job and trigger groups, and the fired-trigger rows. It leaves the node's ownQRTZ_SCHEDULER_STATEcheck-in row, so a test asserting onQueryClusterNodes()needs a name nothing else has used.
Anti-patterns
- Sleeping for a fire.
await Task.Delay(2000)fails at random on a loaded CI agent. Signal. - Sharing one scheduler across tests. State (a paused group, a stored job, a listener) leaks into the next test, and the failure appears in whichever test runs second.
- Asserting on wall-clock times.
firedAt.Should().BeCloseTo(expected, 100.Milliseconds())fails on a slow day. Assert on the fire times the trigger computes (level 0), and on order and counts elsewhere. - Testing Quartz. Quartz's own tests cover that a
SimpleTriggerrepeats or that pausing a group stops it firing. Test your schedule and your job.
The clock is covered in Time and TimeProvider, and the builder these tests use in Building a Scheduler Without a Host.
