CI License: MIT

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"}
  ]
end

Usage

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
end

In 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
end

freeze_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

NeedUse
Freeze utc_now in a test (same process)FrozenClock
Minimal API, no magic, no extra arguments in production codeFrozenClock
Explicit DI: a clock struct + protocol passed as a function argumentclock
Control Process.send_after/3, :timer, scheduled workklotho
Cross-process freeze (Task, GenServer)klotho
A mock-first workflow with an explicit Mock namespaceklotho

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.