An overridable wall clock.
In production, utc_now/0 delegates to DateTime.utc_now/0. In tests, a
per-process override set via set/1 or advance/1 lets time-dependent code
(interval growth, same-day vs cross-day rollover, expiry, aging) be driven
deterministically – without Process.sleep or hand-rolled date math.
Inheritance
The override lives in Ambient.ProcessOverride, so any child process spawned
by the test inherits the clock through:
- the implicit
$callerschain (Task/Agent– no setup needed), and - explicit
Ambient.Clock.allow/2for long-lived processes (GenServers, Oban workers).
So async: true tests stay isolated, and a scenario that fans out to a
background worker still observes the frozen test clock.
Production code
Ambient.Clock.utc_now()
Ambient.Clock.utc_today()Use these instead of direct DateTime.utc_now/0 / Date.utc_today/0 calls
anywhere time must be testable. A custom Credo check can enforce the
convention project-wide.
Test usage
Ambient.Clock.set(~U[2026-01-01 09:00:00Z])
Ambient.Clock.advance(days: 1)
Ambient.Clock.reset()
# For a long-lived process that must read the test clock:
Ambient.Clock.allow(genserver_pid)
Summary
Functions
Advance the clock and return the new time.
Authorise child_pid to read owner_pid's overrides. For long-lived
processes that don't appear in the $callers chain.
Drop every override this process owns in this module's table.
Drop this process's override for key. No-op if there isn't one.
Current UTC time as a NaiveDateTime, derived from utc_now/0. Use instead
of NaiveDateTime.utc_now/0.
Current DateTime in the given timezone, derived from utc_now/0. Mirrors
the return shape of DateTime.now/1 (requires a configured time zone
database) so it's a drop-in replacement.
Whether a clock override is currently in effect for this process.
Whether an override for key is in scope for the calling process.
Set a process-local override for key. Auto-cleaned when the process exits.
Freeze the clock at dt for the current process (and its children).
Return this module's table to private, process-scoped mode.
Make owner_pid's overrides the ones every process reads. async: false
only – see Ambient.ProcessOverride.set_shared/2.
Current UTC time. Delegates to DateTime.utc_now/0 unless overridden – and
in a build without overrides compiled in, is DateTime.utc_now/0, with no
lookup at all.
Today's UTC Date, derived from utc_now/0 so the clock override applies.
Use instead of Date.utc_today/0.
Functions
@spec advance(integer() | keyword()) :: DateTime.t()
Advance the clock and return the new time.
Accepts a keyword list with any of :seconds, :minutes, :hours, :days
(summed) – or a bare integer treated as seconds. Values may be negative to
move backwards.
Ambient.Clock.advance(days: 1)
Ambient.Clock.advance(hours: 1, minutes: 30) # 5400 seconds
Ambient.Clock.advance(-90) # back 90 seconds
Authorise child_pid to read owner_pid's overrides. For long-lived
processes that don't appear in the $callers chain.
@spec delete_all() :: :ok
Drop every override this process owns in this module's table.
@spec delete_override(term()) :: :ok
Drop this process's override for key. No-op if there isn't one.
@spec naive_utc_now() :: NaiveDateTime.t()
Current UTC time as a NaiveDateTime, derived from utc_now/0. Use instead
of NaiveDateTime.utc_now/0.
@spec now(Calendar.time_zone()) :: {:ok, DateTime.t()} | {:error, term()}
Current DateTime in the given timezone, derived from utc_now/0. Mirrors
the return shape of DateTime.now/1 (requires a configured time zone
database) so it's a drop-in replacement.
@spec overridden?() :: boolean()
Whether a clock override is currently in effect for this process.
Whether an override for key is in scope for the calling process.
Set a process-local override for key. Auto-cleaned when the process exits.
@spec reset() :: :ok
Drop the override; utc_now/0 returns to the real clock.
@spec set(DateTime.t()) :: DateTime.t()
Freeze the clock at dt for the current process (and its children).
@spec set_private() :: :ok
Return this module's table to private, process-scoped mode.
@spec utc_now() :: DateTime.t()
Current UTC time. Delegates to DateTime.utc_now/0 unless overridden – and
in a build without overrides compiled in, is DateTime.utc_now/0, with no
lookup at all.
@spec utc_today() :: Date.t()
Today's UTC Date, derived from utc_now/0 so the clock override applies.
Use instead of Date.utc_today/0.