Skip to content

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