30 - TestClock: Deterministic Time in Tests
TestClock replaces the real clock with one you control. Time advances only when you call TestClock.adjust or TestClock.setTime.
A test for an Effect.sleep("1 hour") finishes in microseconds instead of hours, with no flakiness.
What you’ll work through
index.test.ts contains four beats. Two are read-run — they show TestClock.adjust and TestClock.setTime in isolation.
Two are interactive composites: a sleeping fiber and a retry-with-backoff, each asking for an Effect.gen body that follows the fork → adjust → join discipline.
By the end you should reach for TestClock whenever the code under test contains Effect.sleep, an Effect.retry with delays, or any schedule-driven loop — and recognise that without TestContext.TestContext, the clock APIs silently fall back to the real wall clock.
Concept
Real clock: time advances at wall-clock rate.TestClock: frozen until you move it.
TestClock.adjust("5 minutes") ──▶ jump forward 5 minutes every fiber sleeping until then wakes up
The fork → adjust → join discipline:
1. yield* Effect.forkChild(effect) ─▶ start the sleeping effect on a child fiber 2. yield* TestClock.adjust("…") ─▶ move the virtual clock; the child wakes 3. yield* Fiber.join(fiber) ─▶ collect the resultWithout the fork, TestClock.adjust cannot run, the calling fiber is asleep on the same clock.
Providing the TestContext
TestClock.adjust only has an effect inside the TestContext.TestContext layer. Provide it at the boundary of every test that uses it.
import { Effect, TestContext } from "effect"
const test = Effect.gen(function* () { // ...}).pipe(Effect.provide(TestContext.TestContext))The layer also installs TestRandom and a console implementation suited to tests.
TestClock.adjust
Advances the virtual clock by a Duration.DurationInput.
// ┌─── Effect<void>// ▼const tick = TestClock.adjust("5 minutes")TestClock.setTime
TestClock.adjust moves the virtual clock forward by a duration; TestClock.setTime jumps to an absolute point. It accepts a DateTime.Input, a number of epoch milliseconds, a Date, or any other DateTime input.
Use it when a test needs to assert behavior at a specific instant — a scheduled job firing at midnight, a token expiring at a specific timestamp — rather than “N seconds from now.”
import { Effect, TestClock } from "effect"
// ┌─── Effect<void>// ▼const setToNoon = TestClock.setTime(new Date("2024-01-01T12:00:00Z"))
const test = Effect.gen(function* () { yield* setToNoon // Anything inside this effect that reads Clock.currentTimeMillis // now sees 2024-01-01T12:00:00Z — even though wall-clock time hasn't moved.})Rule of thumb. Reach for setTime when “what time is it?” is part of the assertion (a token issued-at, a cron tick), and adjust when “how much time has elapsed?” is what you care about (retries, schedules).
Your turn call `TestClock.setTime` with a fixed `Date`, then read `Clock.currentTimeMillis` and assert it equals the milliseconds you set.
Composite Example: Sleep
import { Effect, Fiber, TestClock, TestContext } from "effect"
// ┌─── Effect<void>// ▼const test = Effect.gen(function* () { const fiber = yield* Effect.forkChild( Effect.sleep("1 hour").pipe(Effect.as("woke up")), )
yield* TestClock.adjust("1 hour")
const result = yield* Fiber.join(fiber) if (result !== "woke up") throw new Error("expected to wake")}).pipe(Effect.provide(TestContext.TestContext))The whole test runs instantly. The fork-then-adjust order is what makes it work.
Why this matters. A test that sleeps for a real hour is a test nobody runs.
TestClockmakes time-based logic — backoff, polling, debounce, timeouts — first-class testable without the flakiness of real timers.
Composite Example: Retry with Backoff
// ┌─── Effect<{ result: string; attempts: number }>// ▼const test = Effect.gen(function* () { let attempts = 0 const flaky = Effect.gen(function* () { attempts++ if (attempts < 3) yield* Effect.fail("transient") return "ok" })
const fiber = yield* Effect.forkChild( flaky.pipe(Effect.retry(Schedule.exponential("1 second"))), )
yield* TestClock.adjust("1 second") yield* TestClock.adjust("2 seconds")
const result = yield* Fiber.join(fiber) return { result, attempts }}).pipe(Effect.provide(TestContext.TestContext))Two clock adjustments cover the 1s + 2s backoff. No real time elapses.
Common Pitfalls
1, Forgetting to fork. Calling TestClock.adjust on the same fiber that is sleeping deadlocks the fiber. Always fork the sleeping effect first, adjust, then join.
2, Forgetting TestContext.TestContext. Without it, TestClock.adjust is a no-op and Effect.sleep runs against the real clock. The test reverts to its slow, flaky form.
3, Adjusting by less than the sleep duration. The clock moves; the fiber stays asleep. Move the clock at least as far as the sleep.
4, Mixing real-time operations. setTimeout, Date.now(), and other real-time sources are not virtualized. Use Effect’s clock APIs throughout the code under test.
one hour, then join the fiber. Provide `TestContext.TestContext` at the pipe boundary. - [ ] In the retry-with-backoff composite, fork the retried effect and use two `TestClock.adjust` calls to cover the 1s + 2s schedule. - [ ] Remove `Effect.provide(TestContext.TestContext)` from one beat and watch the test hang against the real clock — then put it back.
See Also
- Previous,
29-test-layers: the layer pattern that wiresTestContext. - Next,
31-schedules: the recurrence policiesTestClocklets you test cheaply. - Related,
32-retry-and-timeout: retry and timeout under deterministic time. - Later,
48-cause-and-sandbox: full failure inspection.
Run the tests: pnpm test 30-test-clock
The full test suite
The index.test.ts for this lesson, verbatim from steps/30-test-clock/.
import { describe, expect, it } from "vite-plus/test"import { Effect, Fiber } from "effect"import { TestClock } from "effect/testing"
describe("TestClock", () => { it("advances virtual time deterministically", async () => { const program = Effect.gen(function*() { const fiber = yield* Effect.sleep("1 hour").pipe(Effect.as("done"), Effect.forkChild({ startImmediately: true })) yield* TestClock.adjust("1 hour") return yield* Fiber.join(fiber) }) expect(await Effect.runPromise(program.pipe(Effect.provide(TestClock.layer())))).toBe("done") })})