kykylekatarnls/carbonite

使用 Carbon 冻结、加速、减速时间以及更多功能

2.0.0 2024-02-21 10:39 UTC

This package is auto-updated.

Last update: 2024-09-21 12:27:11 UTC


README

使用 Carbon 冻结、加速、减速时间以及更多功能。

您可以使用它与任何 PSR 兼容的时钟系统或框架一起使用,或者与任何时间模拟系统一起使用。

Latest Stable Version GitHub Actions Code Climate Test Coverage Issue Count StyleCI

Carbonite 允许您编写关于时间相关性的单元测试,就像讲述一个故事一样。

专业支持的 nesbot/carbon 现已可用

安装

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 的框架。

Laravel 单元测试示例

Laravel 功能测试示例

原始 PHPUnit 测试示例

可用方法

冻结

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

如果您有其他时间模拟系统,您可以使用 freezejumpTo 属性通过在测试的引导文件中使用 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时,您可以在参数中插入FreezeJumpToReleaseSpeed,它们将用于在开始测试前配置时间模拟,然后从传递的参数中移除:

#[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);
}