Skip to content

Latest commit

 

History

History
346 lines (271 loc) · 11.3 KB

100-testclock.mdx

File metadata and controls

346 lines (271 loc) · 11.3 KB
title excerpt bottomNavigation
TestClock
Utilize Effect's `TestClock` to control time during testing. Learn how to simulate the passage of time and efficiently test time-related functionality. Examples include testing `Effect.timeout`, recurring effects, `Clock`, and `Deferred`.
pagination

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.

The TestClock in Effect allows us to control time for testing purposes. It lets us schedule effects to run at specific times, making it ideal for testing time-related functionality.

Instead of waiting for real time to pass, we use the TestClock to set the clock time to a specific point. Any effects scheduled to run at or before that time will be executed in order.

How TestClock Works

Imagine TestClock as a wall clock, but with a twist—it doesn't tick on its own. Instead, it only changes when we manually adjust it using the TestClock.adjust and TestClock.setTime functions. The clock time never progresses on its own.

When we adjust the clock time, any effects scheduled to run at or before the new time will be executed. This allows us to simulate the passage of time in our tests without waiting for real time.

Let's look at an example of how to test Effect.timeout using the TestClock:

<Tabs items={["Using Effect.gen", "Using pipe"]}>

// @types: node
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)
// @types: node
import { Effect, TestClock, Fiber, Option, TestContext, pipe } from "effect"
import * as assert from "node:assert"

const test = pipe(
  Effect.sleep("5 minutes"),
  Effect.timeoutTo({
    duration: "1 minute",
    onSuccess: Option.some,
    onTimeout: () => Option.none<void>()
  }),
  Effect.fork,
  Effect.tap(() =>
    // Adjust the TestClock by 1 minute to simulate the passage of time
    TestClock.adjust("1 minute")
  ),
  Effect.andThen((fiber) =>
    // Get the result of the fiber
    Fiber.join(fiber)
  ),
  Effect.andThen((result) => {
    // Check if the result is None, indicating a timeout
    assert.ok(Option.isNone(result))
  }),
  Effect.provide(TestContext.TestContext)
)

Effect.runPromise(test)

In this example, we create a test scenario involving a fiber that sleeps for 5 minutes and then times out after 1 minute. Instead of waiting for the full 5 minutes to elapse in real time, we utilize the TestClock to instantly advance time by 1 minute.

A critical point to note is the forking of the fiber where Effect.sleep is invoked. Calls to Effect.sleep and related methods wait until the clock time matches or surpasses the scheduled time for their execution. By forking the fiber, we ensure that we have control over the clock time adjustment.

The recommended pattern when using the `TestClock` involves forking the effect being tested, adjusting the clock time as needed, and then verifying that the expected effects have occurred.

More Examples

Testing Recurring Effects

Let's explore an example demonstrating how to test an effect that runs at fixed intervals using the TestClock:

<Tabs items={["Using Effect.gen", "Using pipe"]}>

// @types: node
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)
// @types: node
import { Effect, Queue, TestClock, Option, TestContext, pipe } from "effect"
import * as assert from "node:assert"

const test = pipe(
  Queue.unbounded(),
  Effect.andThen((q) =>
    pipe(
      Queue.offer(q, undefined),
      // Delay the effect for 60 minutes and repeat it forever
      Effect.delay("60 minutes"),
      Effect.forever,
      Effect.fork,
      Effect.andThen(
        pipe(
          Effect.Do,
          // Check if no effect is performed before the recurrence period
          Effect.bind("a", () =>
            pipe(Queue.poll(q), Effect.andThen(Option.isNone))
          ),
          // Adjust the TestClock by 60 minutes to simulate the passage of time
          Effect.tap(() => TestClock.adjust("60 minutes")),
          // Check if an effect is performed after the recurrence period
          Effect.bind("b", () => pipe(Queue.take(q), Effect.as(true))),
          // Check if the effect is performed exactly once
          Effect.bind("c", () =>
            pipe(Queue.poll(q), Effect.andThen(Option.isNone))
          ),
          // Adjust the TestClock by another 60 minutes
          Effect.tap(() => TestClock.adjust("60 minutes")),
          // Check if another effect is performed
          Effect.bind("d", () => pipe(Queue.take(q), Effect.as(true))),
          Effect.bind("e", () =>
            pipe(Queue.poll(q), Effect.andThen(Option.isNone))
          )
        )
      ),
      Effect.andThen(({ a, b, c, d, e }) => {
        // Ensure that all conditions are met
        assert.ok(a && b && c && d && e)
      })
    )
  ),
  Effect.provide(TestContext.TestContext)
)

Effect.runPromise(test)

In this example, we aim to test an effect that runs at regular intervals. We use an unbounded queue to manage the effects. We verify that:

  1. No effect is performed before the specified recurrence period.
  2. An effect is performed after the recurrence period.
  3. The effect is executed exactly once.

It's crucial to note that after each recurrence, the next occurrence is scheduled to happen at the appropriate time in the future. When we adjust the clock by 60 minutes, precisely one value is placed in the queue, and when we adjust the clock by another 60 minutes, another value is added to the queue.

Testing Clock

Let's explore an example that demonstrates how to test the behavior of the Clock using the TestClock:

<Tabs items={["Using Effect.gen", "Using pipe"]}>

// @types: node
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)
// @types: node
import { Effect, Clock, TestClock, TestContext, pipe } from "effect"
import * as assert from "node:assert"

const test = pipe(
  // Get the current time using the Clock
  Clock.currentTimeMillis,
  Effect.andThen((startTime) =>
    // Adjust the TestClock by 1 minute to simulate the passage of time
    TestClock.adjust("1 minute").pipe(
      // Get the current time again
      Effect.andThen(Clock.currentTimeMillis),
      Effect.andThen((endTime) => {
        // Check if the time difference is at least 60,000 milliseconds (1 minute)
        assert.ok(endTime - startTime >= 60_000)
      })
    )
  ),
  Effect.provide(TestContext.TestContext)
)

Effect.runPromise(test)

Testing Deferred

TestClock also affects asynchronous code scheduled to run after a certain time:

<Tabs items={["Using Effect.gen", "Using pipe"]}>

// @types: node
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)

  assert.ok(1 === readRef)
}).pipe(Effect.provide(TestContext.TestContext))

Effect.runPromise(test)
// @types: node
import { Effect, Deferred, TestClock, TestContext, pipe } from "effect"
import * as assert from "node:assert"

const test = pipe(
  // Create a deferred value
  Deferred.make<number, void>(),
  Effect.tap((deferred) =>
    // Run two effects concurrently: sleep for 10 seconds and succeed the deferred with a value of 1
    Effect.fork(
      Effect.all(
        [Effect.sleep("10 seconds"), Deferred.succeed(deferred, 1)],
        {
          concurrency: "unbounded"
        }
      )
    )
  ),
  // Adjust the TestClock by 10 seconds
  Effect.tap(() => TestClock.adjust("10 seconds")),
  // Await the value from the deferred
  Effect.andThen((deferred) => Deferred.await(deferred)),
  Effect.andThen((readRef) => {
    assert.ok(1 === readRef)
  }),
  Effect.provide(TestContext.TestContext)
)

Effect.runPromise(test)

In this code, we create a scenario where a value is set in a Deferred after 10 seconds asynchronously. We use Effect.fork to run this asynchronously. By adjusting the TestClock by 10 seconds, we can simulate the passage of time and test the code without waiting for the actual 10 seconds to elapse.