Time Control
Test scheduler-driven behavior by freezing time and advancing it manually.
The canonical sequence for any time-control test is:
from whenever import Instant
from hassette.test_utils import AppTestHarness
from my_apps.reminder import ReminderApp
async def test_reminder_fires_after_one_hour():
async with AppTestHarness(ReminderApp, config={}) as harness:
# 1. Freeze time at a known point
start = Instant.from_utc(2024, 1, 15, 9, 0, 0) # 2024-01-15 09:00 UTC
harness.freeze_time(start)
# 2. Schedule the job (app registers it in on_initialize, but you
# can also trigger registration logic via simulate_* here)
# 3. Advance the frozen clock
harness.advance_time(hours=1)
# 4. Fire any jobs whose due time is now <= frozen clock
count = await harness.trigger_due_jobs()
assert count == 1
# 5. Assert your app made the expected API call
harness.api_recorder.assert_called("fire_event", event_type="reminder_fired")
whenever is installed automatically
Time control examples on this page import from whenever — Hassette's date/time library. It's a direct dependency of hassette, so it's installed automatically. No separate install needed.
freeze_time(instant)
Freezes hassette.utils.date_utils.now at the given time. Accepts an Instant or ZonedDateTime from the whenever library. No stdlib datetime — the scheduler uses whenever types throughout.
from whenever import Instant, ZonedDateTime
from hassette.test_utils import AppTestHarness
from my_apps.reminder import ReminderApp
async def test_freeze_time_variants():
async with AppTestHarness(ReminderApp, config={}) as harness:
# From a UTC instant (most portable)
harness.freeze_time(Instant.from_utc(2024, 6, 1, 8, 0, 0))
# From a ZonedDateTime (when local time matters)
harness.freeze_time(ZonedDateTime(2024, 6, 1, 8, 0, 0, tz="America/Chicago"))
freeze_time is idempotent — calling it again replaces the frozen time. The clock is automatically unfrozen when the async with block exits.
advance_time
Advances the frozen clock by the given delta.
from whenever import Instant
from hassette.test_utils import AppTestHarness
from my_apps.reminder import ReminderApp
async def test_advance_time_variants():
async with AppTestHarness(ReminderApp, config={}) as harness:
harness.freeze_time(Instant.from_utc(2024, 1, 15, 9, 0, 0))
harness.advance_time(seconds=30)
harness.advance_time(minutes=5)
harness.advance_time(hours=1)
harness.advance_time(hours=1, minutes=30) # combined
advance_time alone has no effect on scheduled jobs
Moving the clock forward does not trigger any jobs. You must call trigger_due_jobs() explicitly after advancing time — otherwise jobs accumulate silently and your assertions will fail.
trigger_due_jobs
Fires all jobs whose scheduled time is at or before the current frozen time. Returns the number of jobs dispatched.
from whenever import Instant
from hassette.test_utils import AppTestHarness
from my_apps.reminder import ReminderApp
async def test_trigger_due_jobs():
async with AppTestHarness(ReminderApp, config={}) as harness:
harness.freeze_time(Instant.from_utc(2024, 1, 15, 9, 0, 0))
harness.advance_time(hours=1)
count = await harness.trigger_due_jobs()
assert count == 1
Jobs re-enqueued during dispatch (repeating jobs) are not re-triggered in the same call — only the snapshot of due jobs at the moment of the call is processed. This prevents infinite loops when the clock is frozen.
Next Steps
- Concurrency & pytest-xdist: Understand how the time-control lock interacts with parallel test runners
- Quick Start: Back to the harness basics