From afb308733a2d4acff0eccaff64d8bf2be5620c43 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 7 Feb 2026 16:58:18 +0100 Subject: [PATCH] Add withTestNow() for scoped time mocking Adds a new static method `withTestNow()` that temporarily sets the test time, executes a callback, and then restores the previous test time state. This is useful for: - Nested time mocking scenarios - Tests that need to temporarily override a global mock time set in setUp() - Clean time mocking that automatically restores state The method correctly restores the previous state even when the callback throws an exception, using a try/finally block. Inspired by Carbon's withTestNow() implementation and the fix in briannesbitt/Carbon#3283. --- src/Chronos.php | 32 ++++++++ tests/TestCase/DateTime/TestingAidsTest.php | 86 +++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/src/Chronos.php b/src/Chronos.php index a74bfb65..eadcbb12 100644 --- a/src/Chronos.php +++ b/src/Chronos.php @@ -327,6 +327,38 @@ public static function hasTestNow(): bool return static::$testNow !== null; } + /** + * Temporarily sets "now" to the given value and executes the callback. + * + * After the callback is executed, the previous value of "now" is restored. + * This is useful for testing time-sensitive code without affecting other tests. + * + * ### Example: + * + * ``` + * $result = Chronos::withTestNow('2023-06-15 12:00:00', function () { + * return Chronos::now()->format('Y-m-d'); + * }); + * // $result === '2023-06-15' + * ``` + * + * @template T + * @param \Cake\Chronos\Chronos|string|null $testNow The instance to use as "now". + * @param callable(): T $callback The callback to execute. + * @return T The return value of the callback. + */ + public static function withTestNow(Chronos|string|null $testNow, callable $callback): mixed + { + $previous = static::getTestNow(); + static::setTestNow($testNow); + + try { + return $callback(); + } finally { + static::setTestNow($previous); + } + } + /** * Determine if there is just a time in the time string * diff --git a/tests/TestCase/DateTime/TestingAidsTest.php b/tests/TestCase/DateTime/TestingAidsTest.php index e6197c73..4775b3c1 100644 --- a/tests/TestCase/DateTime/TestingAidsTest.php +++ b/tests/TestCase/DateTime/TestingAidsTest.php @@ -19,6 +19,7 @@ use Cake\Chronos\ChronosDate; use Cake\Chronos\Test\TestCase\TestCase; use DateTimeZone; +use RuntimeException; class TestingAidsTest extends TestCase { @@ -231,4 +232,89 @@ public function testSetTestNowSingular() $this->assertSame($c, Chronos::getTestNow()); } + + public function testWithTestNowSetsAndRestoresNull() + { + $this->assertNull(Chronos::getTestNow()); + + $result = Chronos::withTestNow('2023-06-15 12:00:00', function () { + $this->assertNotNull(Chronos::getTestNow()); + $this->assertSame('2023-06-15', Chronos::now()->format('Y-m-d')); + + return 'callback result'; + }); + + $this->assertSame('callback result', $result); + $this->assertNull(Chronos::getTestNow()); + } + + public function testWithTestNowRestoresPreviousTestNow() + { + $original = new Chronos('2020-01-01 00:00:00'); + Chronos::setTestNow($original); + + Chronos::withTestNow('2023-06-15 12:00:00', function () { + $this->assertSame('2023-06-15', Chronos::now()->format('Y-m-d')); + }); + + $this->assertSame($original, Chronos::getTestNow()); + $this->assertSame('2020-01-01', Chronos::now()->format('Y-m-d')); + } + + public function testWithTestNowNested() + { + Chronos::setTestNow('2020-01-01 00:00:00'); + + Chronos::withTestNow('2021-06-15 00:00:00', function () { + $this->assertSame('2021-06-15', Chronos::now()->format('Y-m-d')); + + Chronos::withTestNow('2022-12-25 00:00:00', function () { + $this->assertSame('2022-12-25', Chronos::now()->format('Y-m-d')); + }); + + $this->assertSame('2021-06-15', Chronos::now()->format('Y-m-d')); + }); + + $this->assertSame('2020-01-01', Chronos::now()->format('Y-m-d')); + } + + public function testWithTestNowRestoresOnException() + { + $original = new Chronos('2020-01-01 00:00:00'); + Chronos::setTestNow($original); + + try { + Chronos::withTestNow('2023-06-15 12:00:00', function () { + throw new RuntimeException('Test exception'); + }); + $this->fail('Exception should have been thrown'); + } catch (RuntimeException $e) { + $this->assertSame('Test exception', $e->getMessage()); + } + + $this->assertSame($original, Chronos::getTestNow()); + } + + public function testWithTestNowWithChronosInstance() + { + $testTime = new Chronos('2023-06-15 14:30:00'); + + $result = Chronos::withTestNow($testTime, function () { + return Chronos::now()->format('Y-m-d H:i:s'); + }); + + $this->assertSame('2023-06-15 14:30:00', $result); + $this->assertNull(Chronos::getTestNow()); + } + + public function testWithTestNowWithNull() + { + Chronos::setTestNow('2020-01-01 00:00:00'); + + Chronos::withTestNow(null, function () { + $this->assertNull(Chronos::getTestNow()); + }); + + $this->assertSame('2020-01-01', Chronos::now()->format('Y-m-d')); + } }