A minimal, async-safe wrapper around DateTime.utc_now/0 that lets tests freeze
and travel time in the calling process — no behaviours, no mocks, no struct
threaded through your function signatures.
Replace DateTime.utc_now/0 with FrozenClock.utc_now/0 in production code, and
freeze time in tests. State lives in the process dictionary, so async: true
tests are isolated out of the box.
Installation
def deps do
[
{:frozen_clock, "~> 0.1.0"}
]
endUsage
In production code, call FrozenClock.utc_now/0 where you would have called
DateTime.utc_now/0:
def expired?(token) do
DateTime.compare(FrozenClock.utc_now(), token.expires_at) == :gt
endIn tests, freeze and travel time:
import FrozenClock.TestHelpers
test "expires after 1 hour" do
freeze_time ~U[2026-01-01 00:00:00Z] do
token = create_token()
travel(1, :hour)
assert expired?(token)
end
endfreeze_time/2 guarantees the clock is unfrozen after the block, even if it
raises.
API
FrozenClock.utc_now() # frozen value or real DateTime
FrozenClock.freeze() # freeze at the current real time
FrozenClock.freeze(~U[2026-01-01 00:00:00Z]) # freeze at a specific instant
FrozenClock.travel(~U[2026-06-01 12:00:00Z]) # jump to an instant (freezes if needed)
FrozenClock.travel(1, :hour) # shift by a duration (raises if not frozen)
FrozenClock.frozen?() # => true | false
FrozenClock.unfreeze() # restore real time (safe if not frozen)Isolation: spawned processes see real time
FrozenClock freezes time in the calling process only. Code under test that
spawns processes — Task.async/1, sending to a GenServer, Phoenix channels —
will see the real system time in those children.
For the common case (testing a pure function or a single module) this is exactly
what you want and keeps async tests safe. If you need cross-process freeze, use
klotho.
Comparison
| Need | Use |
|---|---|
Freeze utc_now in a test (same process) | FrozenClock |
| Minimal API, no magic, no extra arguments in production code | FrozenClock |
| Explicit DI: a clock struct + protocol passed as a function argument | clock |
Control Process.send_after/3, :timer, scheduled work | klotho |
Cross-process freeze (Task, GenServer) | klotho |
A mock-first workflow with an explicit Mock namespace | klotho |
FrozenClock isn't "better than klotho" or "better than clock" — it solves a different problem: freezing time via the process dictionary without changing your production function signatures.
License
MIT. See LICENSE.