Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/core/test-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,60 @@ def executeEmbed[A](

If you ignore the messy `map` and `mapK` lifting within `Outcome`, this is actually a relatively simple bit of functionality. The `tickAll` effect causes `TestControl` to `tick` until a `sleep` boundary, then `advance` by the necessary `nextInterval`, and then repeat the process until either `isDeadlocked` is `true` or `results` is `Some`. These results are then retrieved and embedded within the outer `IO`, with cancelation and non-termination being reflected as exceptions.

### Setting Absolute Time

In addition to advancing time by relative amounts using `advance`, `TestControl` provides methods to set the clock to absolute time values. This is particularly useful when you need to test behavior at specific points in time.

```scala
test("verify token expiration at specific time") {
val expirationTime = 1618884475.seconds
val program = for {
token <- createToken(validFor = 1.hour)
_ <- IO.sleep(30.minutes)
currentTime <- IO.realTime
isValid <- validateToken(token)
} yield (currentTime, isValid)

TestControl.execute(program) flatMap { control =>
for {
_ <- control.setTime(expirationTime - 1.minute)
_ <- control.tick
_ <- control.advanceAndTick(30.minutes)
_ <- control.advanceTo(expirationTime + 1.minute)
_ <- control.tick

result <- control.results
_ <- IO {
val (timestamp, isValid) = result.get.fold(throw _, identity, _ => ???)
assertEquals(timestamp, expirationTime + 1.minute)
assert(!isValid)
}
} yield ()
}
}
```

The key methods for absolute time control are:

- **`setTime(targetTime)`**: Sets the clock to the specified absolute time. Fails if the target time is before the current time since time cannot move backwards.
- **`advanceTo(targetTime)`**: An alias for `setTime` with a more descriptive name that emphasizes forward movement.

Both methods calculate the difference between the target time and current time, then use the underlying `advance` method. If the target time equals the current time, the operation is a no-op.

```scala
// These are equivalent when current time is 1.hour:
control.advance(30.minutes)
control.setTime(1.hour + 30.minutes)
control.advanceTo(90.minutes)
```

Note that attempting to set time backwards will result in an `IllegalArgumentException`:

```scala
control.advance(2.hours) *>
control.setTime(1.hour)
```

## Gotchas

It is very important to remember that `TestControl` is a *mock* runtime, and thus some programs may behave very differently under it than under a production runtime. Always *default* to testing using the production runtime unless you absolutely need an artificial time control mechanism for exactly this reason.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,31 @@ final class TestControl[A] private (
def advance(time: FiniteDuration): IO[Unit] =
IO(ctx.advance(time))

/**
* Sets the runtime clock to the specified absolute time. If the target time is before the
* current time, this method will fail with an IllegalArgumentException since time cannot move
* backwards. Does not execute any fibers, though may result in some previously-sleeping
* fibers to become pending and eligible for execution in the next [[tick]].
*/
def setTime(targetTime: FiniteDuration): IO[Unit] =
IO {
val currentTime = ctx.now()
val diff = targetTime - currentTime
if (diff < Duration.Zero) {
throw new IllegalArgumentException(
s"Cannot set time backwards from $currentTime to $targetTime")
} else if (diff > Duration.Zero) {
ctx.advance(diff)
}
}

/**
* Advances the runtime clock to the specified absolute time. This is an alias for [[setTime]]
* with a more descriptive name. If the target time is before the current time, this method
* will fail with an IllegalArgumentException since time cannot move backwards.
*/
def advanceTo(targetTime: FiniteDuration): IO[Unit] = setTime(targetTime)

/**
* A convenience effect which advances time by the specified amount and then ticks once. Note
* that this method is very subtle and will often ''not'' do what you think it should. For
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,99 @@ class TestControlSuite extends BaseSuite {
}
}

real("execute - setTime advances to absolute time") {
val targetTime = 1.hour
val program = for {
time1 <- IO.realTime
_ <- IO.sleep(1.second)
time2 <- IO.realTime
} yield (time1, time2)

TestControl.execute(program) flatMap { control =>
for {
_ <- control.setTime(targetTime)
_ <- control.tick
_ <- control.advanceAndTick(1.second)
result <- control.results
_ <- IO(assertEquals(result, Some(beSucceeded((targetTime, targetTime + 1.second)))))
} yield ()
}
}

real("execute - advanceTo is alias for setTime") {
val targetTime = 42.minutes
val program = for {
time1 <- IO.realTime
_ <- IO.sleep(5.minutes)
time2 <- IO.realTime
} yield (time1, time2)

TestControl.execute(program) flatMap { control =>
for {
_ <- control.advanceTo(targetTime)
_ <- control.tick
_ <- control.advanceAndTick(5.minutes)
result <- control.results
_ <- IO(assertEquals(result, Some(beSucceeded((targetTime, targetTime + 5.minutes)))))
} yield ()
}
}

real("execute - setTime with same time is no-op") {
val program = IO.realTime

TestControl.execute(program) flatMap { control =>
for {
_ <- control.tick
result1 <- control.results
_ <- IO(assertEquals(result1, Some(beSucceeded(Duration.Zero))))

_ <- control.setTime(Duration.Zero)
_ <- control.tick
result2 <- control.results
_ <- IO(assertEquals(result2, Some(beSucceeded(Duration.Zero))))
} yield ()
}
}

real("execute - setTime fails when going backwards") {
val program = IO.realTime

TestControl.execute(program) flatMap { control =>
for {
_ <- control.advance(1.hour)
_ <- control.tick
result1 <- control.results
_ <- IO(assertEquals(result1, Some(beSucceeded(1.hour))))

setTimeResult <- control.setTime(30.minutes).attempt
_ <- IO(assert(setTimeResult.isLeft))
_ <- IO(assert(setTimeResult.left.exists(_.isInstanceOf[IllegalArgumentException])))
} yield ()
}
}

real("execute - setTime with sleep progression") {
val sleepDuration = 30.minutes
val targetTime = 2.hours
val program = for {
start <- IO.realTime
_ <- IO.sleep(sleepDuration)
end <- IO.realTime
} yield (start, end)

TestControl.execute(program) flatMap { control =>
for {
_ <- control.setTime(targetTime)
_ <- control.tick
_ <- control.advanceAndTick(sleepDuration)
result <- control.results
_ <- IO(
assertEquals(result, Some(beSucceeded((targetTime, targetTime + sleepDuration)))))
} yield ()
}
}

private def beSucceeded[A](value: A): Outcome[Id, Throwable, A] =
Outcome.succeeded[Id, Throwable, A](value)
}
Loading