kykylekatarnls / carbonite
使用 Carbon 冻结、加速、减速时间以及更多功能
Requires
- php: ^8.2
- nesbot/carbon: ^3.0.2
- psr/clock: ^1.0
Requires (Dev)
- phan/phan: ^5.4.3
- phpmd/phpmd: ^2.15.0
- phpstan/phpstan: ^1.10.58
- phpunit/phpunit: ^11.0.3
- squizlabs/php_codesniffer: ^3.8.1
README
使用 Carbon 冻结、加速、减速时间以及更多功能。
您可以使用它与任何 PSR 兼容的时钟系统或框架一起使用,或者与任何时间模拟系统一起使用。
Carbonite 允许您编写关于时间相关性的单元测试,就像讲述一个故事一样。
安装
composer require --dev kylekatarnls/carbonite
我们使用 --dev
安装 Carbonite,因为它是为测试设计的。您已经看过足够的科幻电影,知道时间旅行悖论对于生产来说太危险了。
如果您的配置符合要求
- PHP >= 8.2
- Carbon >= 3.0.2
它将安装此包的最新版本。如果您需要支持旧版本的 PHP(最高 7.2)、Carbon 2 或使用属性而不是注解,则请安装 1.x 版本。
composer require --dev kylekatarnls/carbonite:^1
然后您可以浏览相应的 Carbonite v1 文档。
用法
<?php use Carbon\Carbon; use Carbon\Carbonite; function scanEpoch() { switch (Carbon::now()->year) { case 1944: return 'WW2'; case 1946: return 'War is over'; case 2255: return 'The Discovery is flying!'; } } Carbonite::freeze('1944-05-05'); echo scanEpoch(); // output: WW2 Carbonite::elapse('2 years'); echo scanEpoch(); // output: War is over Carbonite::jumpTo('2255-01-01 00:00:00'); echo scanEpoch(); // output: The Discovery is flying! Carbonite::speed(3); // Times passes now thrice as fast sleep(15); // If 15 seconds passes in the real time // Then 45 seconds passed in our fake timeline: echo Carbon::now(); // output: 2255-01-01 00:00:45
您还可以使用 CarbonImmutable
,两者将同步。
并且由于 Carbonite
直接处理使用 Carbon
创建的任何日期,因此它非常适合 Laravel 模型中的 created_at、updated_at 或任何自定义日期字段,或者任何使用 Carbon
的框架。
可用方法
冻结
Carbonite::freeze($toMoment = 'now', float $speed = 0.0): void
将时间冻结到指定的时刻(默认为现在)。
// Assuming now is 2005-04-01 15:56:23 Carbonite::freeze(); // Freeze our fake timeline on current moment 2005-04-01 15:56:23 // You run a long function, so now is 2005-04-01 15:56:24 echo Carbon::now(); // output: 2005-04-01 15:56:23 // Time is frozen for any relative time in Carbon Carbonite::freeze('2010-05-04'); echo Carbon::now()->isoFormat('MMMM [the] Do'); // output: May the 4th
这特别有用,可以避免单元测试中随机出现的小微秒/秒差距,当您进行日期时间比较时。
例如
$now = Carbon::now()->addSecond(); echo (int) Carbon::now()->diffInSeconds($now); // output: 0 // because first Carbon::now() is a few microseconds before the second one, // so the final diff is a bit less than 1 second (for example 0.999262) Carbonite::freeze(); $now = Carbon::now()->addSecond(); echo Carbon::now()->diffInSeconds($now); // output: 1 // Time is frozen so the whole thing behaves as if it was instantaneous
第一个参数可以是字符串、DateTime/DateTimeImmutable、Carbon/CarbonImmutable 实例。但它也可以是 DateInterval/CarbonInterval(添加到现在)或 DatePeriod/CarbonPeriod 以跳转到该周期的开始。
作为第二个可选参数,您可以选择冻结后的新时间速度(默认为 0)。
Carbonite::freeze('2010-05-04', 2); // Go to 2010-05-04 then make the time pass twice as fast.
请参阅 speed() 方法。
速度
Carbonite::speed(float $speed = null): float
没有参数的 Carbonite::speed()
调用会返回伪造时间线的当前速度(冻结时为 0)。
如果有参数,它将设置速度为给定的值
// Assuming now is 19 October 1977 8pm Carbonite::freeze(); // Sit in the movie theater Carbonite::speed(1 / 60); // Now every minute flies away like it's just a second // 121 minutes later, now is 19 October 1977 10:01pm // But it's like it's just 8:02:01pm in your timeline echo Carbon::now()->isoFormat('h:mm:ssa'); // output: 8:02:01pm // Now it's 19 October 1977 11:00:00pm Carbonite::jumpTo('19 October 1977 11:00:00pm'); Carbonite::speed(3600); // and it's like every second was an hour // 4 seconds later, now it's 19 October 1977 11:00:04pm // it's like it's already 3am the next day echo Carbon::now()->isoFormat('YYYY-MM-DD h:mm:ssa'); // output: 1977-10-20 3:00:00am
伪造
Carbonite::fake(CarbonInterface $realNow): CarbonInterface
从真实现在实例获取伪造现在实例。
// Assuming now is 2020-03-14 12:00 Carbonite::freeze('2019-12-23'); // Set fake timeline to last December 23th (midnight) Carbonite::speed(1); // speed = 1 means each second elapsed in real file, elpase 1 second in the fake timeline // Then we can see what date and time it would be in the fake time line // if we were let's say March the 16th in real life: echo Carbonite::fake(Carbon::parse('2020-03-16 14:00')); // output: 2019-12-25 02:00:00 // Cool it would be Christmas (2am) in our fake timeline
加速
accelerate(float $factor): float
通过给定的因子加速伪造时间线中的时间;并返回新的速度。 accelerate(float $factor): float
Carbonite::speed(2); echo Carbonite::accelerate(3); // output: 6
减速
decelerate(float $factor): float
通过给定的因子减速伪造时间线中的时间;并返回新的速度。 decelerate(float $factor): float
Carbonite::speed(5); echo Carbonite::decelerate(2); // output: 2.5
取消冻结
unfreeze(): void
取消伪造时间线。
// Now it's 8:00am Carbonite::freeze(); echo Carbonite::speed(); // output: 0 // Now it's 8:02am // but time is frozen echo Carbon::now()->format('g:i'); // output: 8:00 Carbonite::unfreeze(); echo Carbonite::speed(); // output: 1 // Our timeline restart where it was paused // so now it's 8:03am echo Carbon::now()->format('g:i'); // output: 8:01
跳转至
jumpTo($moment, float $speed = null): void
在伪造时间线中跳转到给定的时刻,同时保持当前速度。
Carbonite::freeze('2000-06-30'); Carbonite::jumpTo('2000-09-01'); echo Carbon::now()->format('Y-m-d'); // output: 2000-09-01 Carbonite::jumpTo('1999-12-20'); echo Carbon::now()->format('Y-m-d'); // output: 1999-12-20
可以通过传递第二个参数来改变跳跃后的速度。默认情况下,速度不会改变。
elapse
elapse($duration, float $speed = null): void
将指定的时间段添加到假时间轴,保持当前速度。
Carbonite::freeze('2000-01-01'); Carbonite::elapse('1 month'); echo Carbon::now()->format('Y-m-d'); // output: 2000-02-01 Carbonite::elapse(CarbonInterval::year()); echo Carbon::now()->format('Y-m-d'); // output: 2001-02-01 Carbonite::elapse(new DateInterval('P1M3D')); echo Carbon::now()->format('Y-m-d'); // output: 2001-03-04
可以通过传递第二个参数来改变跳跃后的速度。默认情况下,速度不会改变。
rewind
rewind($duration, float $speed = null): void
从假时间轴中减去指定的时间段,保持当前速度。
Carbonite::freeze('2000-01-01'); Carbonite::rewind('1 month'); echo Carbon::now()->format('Y-m-d'); // output: 1999-12-01 Carbonite::rewind(CarbonInterval::year()); echo Carbon::now()->format('Y-m-d'); // output: 1998-12-01 Carbonite::rewind(new DateInterval('P1M3D')); echo Carbon::now()->format('Y-m-d'); // output: 1998-10-29
可以通过传递第二个参数来改变跳跃后的速度。默认情况下,速度不会改变。
do
do($moment, callable $action)
在冻结的瞬间 $testNow 触发给定的 $action。一旦完成,无论是成功还是抛出错误或异常,都会恢复先前的时刻和速度。
返回给定 $action 返回的值。
Carbonite::freeze('2000-01-01', 1.5); Carbonite::do('2020-12-23', static function () { echo Carbon::now()->format('Y-m-d H:i:s.u'); // output: 2020-12-23 00:00:00.000000 usleep(200); // Still the same output as time is frozen inside the callback echo Carbon::now()->format('Y-m-d H:i:s.u'); // output: 2020-12-23 00:00:00.000000 echo Carbonite::speed(); // output: 0 }); // Now the speed is 1.5 on 2000-01-01 again echo Carbon::now()->format('Y-m-d'); // output: 2000-01-01 echo Carbonite::speed(); // output: 1.5
Carbonite::do()
是一种隔离测试和将特定日期作为 "现在" 使用的好方法,然后务必恢复先前的状态。如果没有先前的 Carbonite 状态(如果您没有进行任何冻结、跳跃、速度等操作),那么 Carbon::now()
将不再被模拟。
doNow
doNow(callable $action)
在冻结的当前瞬间触发给定的 $action。一旦完成,无论是成功还是抛出错误或异常,都会恢复先前的速度。
返回给定 $action 返回的值。
// Assuming now is 17 September 2020 8pm Carbonite::doNow(static function () { echo Carbon::now()->format('Y-m-d H:i:s.u'); // output: 2020-09-17 20:00:00.000000 usleep(200); // Still the same output as time is frozen inside the callback echo Carbon::now()->format('Y-m-d H:i:s.u'); // output: 2020-09-17 20:00:00.000000 echo Carbonite::speed(); // output: 0 }); // Now the speed is 1 again echo Carbonite::speed(); // output: 1
实际上,它是 Carbonite::do('now', callable $action)
的快捷方式。
Carbonite::doNow()
是一种隔离测试、停止测试时间并确保恢复先前状态的好方法。如果没有先前的 Carbonite 状态(如果您没有进行任何冻结、跳跃、速度等操作),那么 Carbon::now()
将不再被模拟。
release
release(): void
回到现在和正常速度。
// Assuming now is 2019-05-24 Carbonite::freeze('2000-01-01'); echo Carbon::now()->format('Y-m-d'); // output: 2000-01-01 echo Carbonite::speed(); // output: 0 Carbonite::release(); echo Carbon::now()->format('Y-m-d'); // output: 2019-05-24 echo Carbonite::speed(); // output: 1
addSynchronizer
addSynchronizer(callable $synchronizer): void
注册一个回调,每当模拟值发生变化时都会执行。
回调接收默认的 \Carbon\FactoryImmutable
作为参数。
removeSynchronizer
removeSynchronizer(callable $synchronizer): void
删除使用 addSynchronizer()
注册的回调。
mock
mock($testNow): void
设置 "真实" 的现在时刻,这是一个模拟的起源。这意味着当您调用 release()
时,您将不再回到现在,而是回退到模拟的现在。模拟的现在还将确定要考虑的基速度。如果这个模拟实例是静态的,那么无论您选择什么速度,“真实”时间都将被冻结,因此假时间轴也将被冻结。
这是一个用于 Carbonite 的内部单元测试的非常低级的功能,您可能不需要在自己的代码和测试中使用这些方法,您更有可能需要 freeze()
或 jumpTo()
方法。
与 PSR-20 时钟和如 Symfony 等框架的示例
Symfony 7 的 DatePoint
或使用任何具有可模拟时钟系统的框架的服务可以与 Carbon\FactoryImmutable
同步。
use Carbon\Carbonite; use Carbon\FactoryImmutable; use Psr\Clock\ClockInterface; use Symfony\Component\Clock\DatePoint; // \Symfony\Component\Clock\Clock is automatically synchronized // So DatePoint and services linked to it will be mocked Carbonite::freeze('2000-01-01'); $date = new DatePoint(); echo $date->format('Y-m-d'); // output: 2000-01-01 // Having a service using PSR Clock, you can also test it // With any Carbonite method by passing Carbonite::getClock() class MyService { private $clock; public function __construct(ClockInterface $clock) { $this->clock = $clock; } public function getDate() { return $this->clock->now()->format('Y-m-d'); } } $service = new MyService(Carbonite::getClock()); Carbonite::freeze('2025-12-20'); echo $service->getDate(); // output: 2025-12-20
如果您有其他时间模拟系统,您可以使用 freeze
和 jumpTo
属性通过在测试的引导文件中使用 addSynchronizer
来与它们同步,例如如果您使用 Timecop-PHP
use Carbon\Carbonite; use Carbon\FactoryImmutable; Carbonite::addSynchronizer(function (FactoryImmutable $factory) { Timecop::travel($factory->now()->timestamp); });
PHPUnit 示例
use Carbon\BespinTimeMocking; use Carbon\Carbonite; use Carbon\CarbonPeriod; use PHPUnit\Framework\TestCase; class MyProjectTest extends TestCase { // Will handle attributes on each method before running it // and release the time after each test use BespinTimeMocking; public function testHolidays() { $holidays = CarbonPeriod::create('2019-12-23', '2020-01-06', CarbonPeriod::EXCLUDE_END_DATE); Carbonite::jumpTo('2019-12-22'); $this->assertFalse($holidays->isStarted()); Carbonite::elapse('1 day'); $this->assertTrue($holidays->isInProgress()); Carbonite::jumpTo('2020-01-05 22:00'); $this->assertFalse($holidays->isEnded()); Carbonite::elapse('2 hours'); $this->assertTrue($holidays->isEnded()); Carbonite::rewind('1 microsecond'); $this->assertFalse($holidays->isEnded()); } }
PHP 8 属性也可以用于方便起见。通过使用 BespinTimeMocking
特性或给定的测试套件上的 Bespin::up()
启用它
PHP 属性
use Carbon\BespinTimeMocking; use Carbon\Carbon; use Carbon\Carbonite; use Carbon\Carbonite\Attribute\Freeze; use Carbon\Carbonite\Attribute\JumpTo; use Carbon\Carbonite\Attribute\Speed; use PHPUnit\Framework\TestCase; class PHP8Test extends TestCase { // Will handle attributes on each method before running it // and release the time after each test use BespinTimeMocking; #[Freeze("2019-12-25")] public function testChristmas() { // Here we are the 2019-12-25, time is frozen. self::assertSame('12-25', Carbon::now()->format('m-d')); self::assertSame(0.0, Carbonite::speed()); } #[JumpTo("2021-01-01")] public function testJanuaryFirst() { // Here we are the 2021-01-01, but time is NOT frozen. self::assertSame('01-01', Carbon::now()->format('m-d')); self::assertSame(1.0, Carbonite::speed()); } #[Speed(10)] public function testSpeed() { // Here we start from the real date-time, but during // the test, time elapse 10 times faster. self::assertSame(10.0, Carbonite::speed()); } #[Release] public function testRelease() { // If no attributes have been used, Bespin::up() will use: // Carbonite::freeze('now') // But you can still use #[Release] to get a test with // real time } }
有关注解支持,请参阅 Carbonite v1 文档
fakeAsync()
用于 PHP
如果您熟悉 Angular 测试工具中的 fakeAsync()
和 tick()
,那么您可以使用以下方法在 PHP 测试中获得相同的语法
use Carbon\Carbonite; function fakeAsync(callable $fn): void { Carbonite::freeze(); $fn(); Carbonite::release(); } function tick(int $milliseconds): void { Carbonite::elapse("$milliseconds milliseconds"); }
如下所示使用:
use Carbon\Carbon; fakeAsync(function () { $now = Carbon::now(); tick(2000); echo $now->diffForHumans(); // output: 2 seconds ago });
数据提供者
当在PHPUnit TestCase上应用use BespinTimeMocking;
并使用#[DataProvider]
、@dataProvider
、#[TestWith]
或@testWith
时,您可以在参数中插入Freeze
、JumpTo
、Release
或Speed
,它们将用于在开始测试前配置时间模拟,然后从传递的参数中移除:
#[TestWith([new Freeze('2024-05-25'), '2024-05-24'])] #[TestWith([new Freeze('2023-01-01'), '2022-12-31'])] public function testYesterday(string $date): void { self::assertSame($date, Carbon::yesterday()->format('Y-m-d')); } #[DataProvider('getDataSet')] public function testNow(string $date): void { self::assertSame($date, Carbon::now()->format('Y-m-d')); } public static function getDataSet(): array { return [ ['2024-05-25', new Freeze('2024-05-25')], ['2023-12-14', new Freeze('2024-05-25')], ]; }
您可以将其与时间段结合使用,例如测试某事在每月的每一天都能工作:
#[DataProvider('getDataSet')] public function testDataProvider(): void { $now = CarbonImmutable::now(); self::assertSame($now->day, $now->addMonth()->day); } public static function getDataSet(): iterable { yield from Carbon::parse('2023-01-01') ->daysUntil('2023-01-31') ->map(static fn ($date) => [new Freeze($date)]); }
上述测试将针对1月份的每一天进行,如果在29日、30日或31日失败,则是因为它从2月溢出到3月。
DataGroup
辅助器允许您使用相同的时间模拟构建具有多个集合的数据提供器:
#[DataProvider('getDataSet')] public function testDataProvider(string $date, int $days): void { self::assertSame( $date, Carbon::now()->addDays($days)->format('Y-m-d') ); } public static function getDataSet(): iterable { yield from DataGroup::for(new Freeze('2024-05-25'), [ ['2024-05-27', 2], ['2024-06-01', 7], ['2024-06-08', 14], ]); yield from DataGroup::for(new Freeze('2023-12-30'), [ ['2023-12-31', 1], ['2024-01-06', 7], ['2024-02-03', 35], ]); yield from DataGroup::matrix([ new Freeze('2024-05-25'), new Freeze('2023-12-14'), ], [ 'a' => ['2024-05-25'], 'bb' => ['2023-12-14'], ]); }
并且还可以构建一个矩阵来测试每个时间配置与每个集合:
#[DataProvider('getDataSet')] public function testDataProvider(string $text): void { // This test will be run 4 times: // - With current time mocked to 2024-05-25 and $text = "abc" // - With current time mocked to 2024-05-25 and $text = "def" // - With current time mocked to 2023-12-14 and $text = "abc" // - With current time mocked to 2023-12-14 and $text = "def" } public static function getDataSet(): DataGroup { return DataGroup::matrix([ new Freeze('2024-05-25'), new Freeze('2023-12-14'), ], [ ['abc'], ['def'], ]); }
提供了默认的DataGroup::withVariousDates()
,以模拟在已知会触发边缘情况的时间点的时间,例如每天结束时、2月底等:
#[DataProvider('getDataSet')] public function testDataProvider(): void { } public static function getDataSet(): DataGroup { return DataGroup::withVariousDates(); }
它可以与数据集交叉使用(因此可以测试每个集合与每个日期),可以更改要使用的时间区域(使用单个或多个时间区域列表,以测试每个区域),并可以添加额外的日期和时间:
#[DataProvider('getDataSet')] public function testDataProvider(string $text, int $number): void { } public static function getDataSet(): DataGroup { return DataGroup::withVariousDates( [ ['abc', 4], ['def', 6], ], ['America/Chicago', 'Pacific/Auckland'], ['2024-12-25', '2024-12-26'], ['12:00', '02:30'] ); }
您还可以选择在两个界限之间随机模拟时间的日期:
#[DataProvider('getDataSet')] public function testDataProvider(): void { // Will run 5 times, each time with now randomly picked between // 2024-06-01 00:00 and 2024-09-20 00:00 // For instance: 2024-07-16 22:45:12.251637 } public static function getDataSet(): DataGroup { return DataGroup::between('2024-06-01', '2024-09-20', 5); }
随机日期选择也可以与数据集一起使用:
#[DataProvider('getDataSet')] public function testDataProvider(string $letter): void { // Will run with $letter = 'a' and now picked randomly // Will run with $letter = 'b' and now picked randomly // Will run with $letter = 'c' and now picked randomly } public static function getDataSet(): DataGroup { return DataGroup::between('2024-06-01', '2024-09-20', ['a', 'b', 'c']); }
自定义属性:
您可以通过实现UpInterface
来创建自己的时间模拟属性:
use Carbon\Carbonite; use Carbon\Carbonite\Attribute\UpInterface; #[\Attribute] final class AtUserCreation implements UpInterface { public function __construct(private string $username) {} public function up() : void { // Let's assume as an example that the code below is how to get // user creation as a Carbon or DateTime from a username in your app. $creationDate = User::where('Name', $username)->first()->created_at; Carbonite::freeze($creationDate); } }
然后您可以在测试中使用以下属性,例如:
#[AtUserCreation('Robin')] public function testUserAge(): void { Carbon::sleep(3); $ageInSeconds = (int) User::where('Name', $username)->first()->created_at->diffInSeconds(); self::assertSame(3, $ageInSeconds); }