TestClock
In most cases, we want our unit tests to run as quickly as possible. Waiting for real time to pass can slow down our tests significantly. Effect provides a handy tool called TestClock that allows us to control time during testing. This means we can efficiently and predictably test code that involves time without having to wait for the actual time to pass.
How TestClock Works
Imagine TestClock as a wall clock that only moves forward when we adjust it manually using the TestClock.adjust and TestClock.setTime functions. The clock time does not progress on its own.
When we adjust the clock time, any effects scheduled to run at or before that time will execute. This allows us to simulate time passage in tests without waiting for real time.
Example (Simulating a Timeout with TestClock)
import { Effect, TestClock, Fiber, Option, TestContext } from "effect"import * as assert from "node:assert"
const test = Effect.gen(function* () { // Create a fiber that sleeps for 5 minutes and then times out // after 1 minute const fiber = yield* Effect.sleep("5 minutes").pipe( Effect.timeoutTo({ duration: "1 minute", onSuccess: Option.some, onTimeout: () => Option.none<void>(), }), Effect.fork, )
// Adjust the TestClock by 1 minute to simulate the passage of time yield* TestClock.adjust("1 minute")
// Get the result of the fiber const result = yield* Fiber.join(fiber)
// Check if the result is None, indicating a timeout assert.ok(Option.isNone(result))}).pipe(Effect.provide(TestContext.TestContext))
Effect.runPromise(test)A key point is forking the fiber where Effect.sleep is invoked. Calls to Effect.sleep and related methods wait until the clock time matches or exceeds the scheduled time for their execution. By forking the fiber, we retain control over the clock time adjustments.
Testing Recurring Effects
Here’s an example demonstrating how to test an effect that runs at fixed intervals using the TestClock:
Example (Testing an Effect with Fixed Intervals)
In this example, we test an effect that runs at regular intervals. An unbounded queue is used to manage the effects, and we verify the following:
- No effect occurs before the specified recurrence period.
- An effect occurs after the recurrence period.
- The effect executes exactly once.
import { Effect, Queue, TestClock, Option, TestContext } from "effect"import * as assert from "node:assert"
const test = Effect.gen(function* () { const q = yield* Queue.unbounded()
yield* Queue.offer(q, undefined).pipe( // Delay the effect for 60 minutes and repeat it forever Effect.delay("60 minutes"), Effect.forever, Effect.fork, )
// Check if no effect is performed before the recurrence period const a = yield* Queue.poll(q).pipe(Effect.andThen(Option.isNone))
// Adjust the TestClock by 60 minutes to simulate the passage of time yield* TestClock.adjust("60 minutes")
// Check if an effect is performed after the recurrence period const b = yield* Queue.take(q).pipe(Effect.as(true))
// Check if the effect is performed exactly once const c = yield* Queue.poll(q).pipe(Effect.andThen(Option.isNone))
// Adjust the TestClock by another 60 minutes yield* TestClock.adjust("60 minutes")
// Check if another effect is performed const d = yield* Queue.take(q).pipe(Effect.as(true)) const e = yield* Queue.poll(q).pipe(Effect.andThen(Option.isNone))
// Ensure that all conditions are met assert.ok(a && b && c && d && e)}).pipe(Effect.provide(TestContext.TestContext))
Effect.runPromise(test)It’s important to note that after each recurrence, the next occurrence is scheduled to happen at the appropriate time. Adjusting the clock by 60 minutes places exactly one value in the queue; adjusting by another 60 minutes adds another value.
Testing Clock
This example demonstrates how to test the behavior of the Clock using the TestClock:
Example (Simulating Time Passage with TestClock)
import { Effect, Clock, TestClock, TestContext } from "effect"import * as assert from "node:assert"
const test = Effect.gen(function* () { // Get the current time using the Clock const startTime = yield* Clock.currentTimeMillis
// Adjust the TestClock by 1 minute to simulate the passage of time yield* TestClock.adjust("1 minute")
// Get the current time again const endTime = yield* Clock.currentTimeMillis
// Check if the time difference is at least // 60,000 milliseconds (1 minute) assert.ok(endTime - startTime >= 60_000)}).pipe(Effect.provide(TestContext.TestContext))
Effect.runPromise(test)Testing Deferred
The TestClock also impacts asynchronous code scheduled to run after a specific time.
Example (Simulating Delayed Execution with Deferred and TestClock)
import { Effect, Deferred, TestClock, TestContext } from "effect"import * as assert from "node:assert"
const test = Effect.gen(function* () { // Create a deferred value const deferred = yield* Deferred.make<number, void>()
// Run two effects concurrently: sleep for 10 seconds and succeed // the deferred with a value of 1 yield* Effect.all([Effect.sleep("10 seconds"), Deferred.succeed(deferred, 1)], { concurrency: "unbounded", }).pipe(Effect.fork)
// Adjust the TestClock by 10 seconds yield* TestClock.adjust("10 seconds")
// Await the value from the deferred const readRef = yield* Deferred.await(deferred)
// Verify the deferred value is correctly set assert.ok(readRef === 1)}).pipe(Effect.provide(TestContext.TestContext))
Effect.runPromise(test)