From ccdc38837968a5f73c2547177c10972225df1a79 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Mon, 13 Oct 2025 21:58:54 +0300 Subject: [PATCH 1/4] feat: testcontrol-settime --- docs/core/test-runtime.md | 54 +++++++++++ .../cats/effect/testkit/TestControl.scala | 24 +++++ .../effect/testkit/TestControlSuite.scala | 92 +++++++++++++++++++ 3 files changed, 170 insertions(+) diff --git a/docs/core/test-runtime.md b/docs/core/test-runtime.md index 5b16226c73..2755a1c0d5 100644 --- a/docs/core/test-runtime.md +++ b/docs/core/test-runtime.md @@ -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. diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala index 6bfd27e228..cc3158c2f4 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -142,6 +142,30 @@ 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 diff --git a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSuite.scala b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSuite.scala index bfb251188b..2ac057693a 100644 --- a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSuite.scala @@ -223,6 +223,98 @@ 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) } From a28a512041002a634620abc3603f8b306932faaa Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Thu, 16 Oct 2025 23:04:10 +0300 Subject: [PATCH 2/4] testkit: add setTime and advanceTo methods to TestControl --- .../src/main/scala/cats/effect/testkit/TestControl.scala | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala index cc3158c2f4..3b55002b25 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -144,8 +144,8 @@ final class TestControl[A] private ( /** * 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 + * 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] = @@ -153,7 +153,8 @@ final class TestControl[A] private ( val currentTime = ctx.now() val diff = targetTime - currentTime if (diff < Duration.Zero) { - throw new IllegalArgumentException(s"Cannot set time backwards from $currentTime to $targetTime") + throw new IllegalArgumentException( + s"Cannot set time backwards from $currentTime to $targetTime") } else if (diff > Duration.Zero) { ctx.advance(diff) } From a1b43e6f969c5f5ecf2f0c556d5bc42240e15e31 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Fri, 17 Oct 2025 00:23:08 +0300 Subject: [PATCH 3/4] fix: scalafmt formatting --- .../test/scala/cats/effect/testkit/TestControlSuite.scala | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSuite.scala b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSuite.scala index 2ac057693a..6a91e3f500 100644 --- a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSuite.scala @@ -269,7 +269,7 @@ class TestControlSuite extends BaseSuite { _ <- control.tick result1 <- control.results _ <- IO(assertEquals(result1, Some(beSucceeded(Duration.Zero)))) - + _ <- control.setTime(Duration.Zero) _ <- control.tick result2 <- control.results @@ -287,7 +287,7 @@ class TestControlSuite extends BaseSuite { _ <- 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]))) @@ -310,7 +310,8 @@ class TestControlSuite extends BaseSuite { _ <- control.tick _ <- control.advanceAndTick(sleepDuration) result <- control.results - _ <- IO(assertEquals(result, Some(beSucceeded((targetTime, targetTime + sleepDuration))))) + _ <- IO( + assertEquals(result, Some(beSucceeded((targetTime, targetTime + sleepDuration))))) } yield () } } From c4244ead96be5f6e1d516dec268eca6dbd5c62b4 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Fri, 17 Oct 2025 02:05:03 +0300 Subject: [PATCH 4/4] trigger CI