Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Pest Plugin Test Time Laravel Package

spatie/pest-plugin-test-time

A Pest plugin for controlling time in tests. Freeze or advance Carbon’s current time with testTime()->freeze() and addMinute(), and use the toBeCarbon expectation to assert Carbon date and time values clearly.

View on GitHub
Deep Wiki
Context7

Getting Started

composer require spatie/pest-plugin-test-time --dev

Three prerequisites, check them before you start:

  1. Pest, not PHPUnit — this is a Pest plugin with no PHPUnit equivalent.
  2. Carbon in the project — the whole package is a thin wrapper around Carbon::setTestNow().
  3. A compatible version matrix. 2.3.0 targets Pest 5, requires PHP 8.4, and dropped Carbon 2. The 2.2.x line is the Pest 2/3 + PHP 8.1/8.2/8.3 + Carbon 2/3 line. Run composer show pestphp/pest carbon/carbon first.

There is nothing else to configure: no service provider, no config file, no Pest.php registration. Composer's plugin type bootstraps it.

Where to look first: the README's Usage section (four short blocks), then vendor/spatie/pest-plugin-test-time/src. The entire public surface is one function (testTime()), its freeze()/add*/sub* methods, and one expectation (toBeCarbon). You can read the source in five minutes, which is useful because the docs deliberately don't cover much beyond the happy path.

Your first use case should be a date rule you already distrust — trial expiry, proration, a "renewal notice at 30 days" job:

use function Spatie\PestPluginTestTime\testTime;

it('expires the trial 14 days after signup', function () {
    testTime()->freeze('2024-01-01 09:00:00');

    $trial = Trial::start();

    testTime()->addDays(13);
    expect($trial->refresh()->isActive())->toBeTrue();

    testTime()->addDay();
    expect($trial->refresh()->isActive())->toBeFalse();
});

If that reads clearly, you've understood the package. Everything below is variation on it.

Implementation Patterns

Freeze at a boundary, hop in domain steps. The value isn't freeze() — it's that add*/sub* let you assert the transition rather than recompute the endpoint. Pin to an awkward instant and step one unit across:

testTime()->freeze('2024-02-29 23:59:59');   // leap day, month-end, year edge
testTime()->addSecond();                      // now 2024-03-01 00:00:00

Chaining works the same as Carbon, and freeze() returns the wrapper so you can hang the first hop directly off it:

testTime()->freeze('2021-01-02 12:34:56')->subMinute()->addSeconds(2);

Assert literals, never now()->addDays(3). The most common way to write a useless test with this package is to compute the expected value from the frozen clock inside the assertion. Freeze, act, hop by a hard-coded amount, assert a hard-coded string. The test should fail if the production code changes its date math, not just re-derive it.

Use toBeCarbon for readable, greppable assertions:

expect($subscription->renews_at)->toBeCarbon('2024-02-29');
expect($invoice->issued_at)->toBeCarbon('2024-02', 'Y-m');
expect($user->created_at)->toBeCarbon('2024-01-01 09:00:00');

In anything shared or reused, pass the format explicitly (see Gotchas). It also accepts null since 1.1.1, which is what you want for nullable timestamp columns.

Freeze once, test the whole HTTP → job → notification path. Test-now state is process-global, so a single freeze() at the top of a Pest test covers the controller, the Queue::fake()d or sync job, and the mailable's rendered date strings. This is where the package pays for itself:

it('emails a renewal reminder with the correct renewal date', function () {
    testTime()->freeze('2024-03-15 08:00:00');
    Notification::fake();

    $this->post('/subscriptions/renew')->assertRedirect();

    Notification::assertSentTo($user, RenewalReminder::class, function ($notification) {
        expect($notification->renewsAt)->toBeCarbon('2025-03-15');
        return true;
    });
});

Hoist repeated freezes into named helpers in tests/Pest.php, so a domain concept has exactly one definition and one place to change:

function freezeToRenewalWindow(): void
{
    testTime()->freeze('2024-03-15 00:00:00');
}

You can also apply it wholesale to a directory:

uses()->beforeEach(fn () => testTime()->freeze('2024-01-01 00:00:00'))
    ->in('Feature/Billing');

Be deliberate about this — it makes every test in that folder implicitly time-dependent, which is usually right for a billing suite and surprising anywhere else.

Drive edge cases with datasets rather than cloning tests:

it('prorates across month ends', function (string $start, string $expected) {
    testTime()->freeze($start);
    expect(Calculator::prorate()->format('Y-m-d'))->toBe($expected);
})->with([
    ['2024-01-31 10:00:00', '2024-02-29'],
    ['2024-02-29 10:00:00', '2024-03-31'],
]);

Integration tip — assertions that depend on dispatch order. Because Queue::fake() captures the payload at dispatch time, a job's constructor arguments are frozen at the moment you dispatched. If a test needs to prove a delayed job becomes "due," testTime()->addDays(...) after the fake will not make a delayed job eligible; keep delay-timing assertions at the container/dispatch level or use Bus::fake() plus explicit assertDispatched closures.

Gotchas and Tips

Nothing resets for you. setTestNow() is static process state. One test that freezes and doesn't restore will leak into whatever runs next in that process. Put this in tests/Pest.php and never think about it again:

afterEach(fn () => \Carbon\Carbon::setTestNow());

The symptom is unmistakable once seen: passes in isolation, fails in the full suite, or vice versa.

Frozen time does not stop PHP's or the database's clock. time(), mktime(), hrtime(), Carbon::createFromTimestamp(time()), and MySQL's NOW() / DEFAULT CURRENT_TIMESTAMP all keep marching. If a model column is populated by the database rather than by PHP, your assertion will be off by however long the test took. Either push the timestamp up into PHP (->useCurrent() → explicit now() assignment) or bind the value explicitly in the test. Same class of bug applies to third-party SDKs that call time() internally.

Never sleep() under frozen time. sleep(2) really waits two seconds and Carbon::now() doesn't move, so you've paid the wall-clock cost and proved nothing. testTime()->addSeconds(2) is the entire reason this package exists.

freeze() without an argument is a determinism trap. It pins to the real "now" at that instant, so assertions depending on hour, day, or microsecond will drift across runs and timezones. Always freeze to an explicit Y-m-d H:i:s string in tests that assert dates.

The README documents freeze() as taking a Y-m-d H:i:s string. Treat that as the contract. If you need a DateTimeInterface or a CarbonImmutable, call Carbon::setTestNow($dt) directly — it's the same mechanism and you lose nothing but the chaining sugar. Relatedly: the state is Carbon's, not the plugin's, so anything routed through now(), Date::now(), or Carbon::now() sees it; add one smoke test if your codebase standardizes on CarbonImmutable.

toBeCarbon infers granularity from the string shape when you omit the format — '2024-02-29' compares date-only, '2024-02-29 09:00:00' compares date and time. Convenient in a throwaway test, a confusing failure inside a shared helper where someone later appends 09:00:00. Pass the format explicitly anywhere reusable: toBeCarbon('2024-02-29', 'Y-m-d'). Also note it expects a Carbon instance (or null) — if a date column lacks a datetime cast, you'll get a type failure, not a value mismatch.

Timezone is an implicit input. Test-now is interpreted in the app timezone, and toBeCarbon formats in whatever that is. A test that passes locally and fails in CI is usually a config('app.timezone') / .env difference, not a package bug. If the timezone matters to the assertion, set it explicitly in the test or in phpunit.xml.

The use function import is per-file friction. There's no way to have Pest autoload a function, so every test file needs use function Spatie\PestPluginTestTime\testTime;. If that bugs the team, add a tests/Helpers.php with your own globally-imported freezeTime() wrapper and autoload it via composer.json's autoload-dev.files.

You may already have this. Laravel ships $this->freezeTime(), $this->travelTo(), $this->travel(3)->days(), and $this->travelBack() — same underlying Carbon::setTestNow(). Be honest about the delta when proposing adoption: what this package adds is the fluent testTime()->add*()->sub*() chaining and the toBeCarbon expectation. It does not add capability Laravel lacks. If your team is mixed PHPUnit/Pest, or you're on a Laravel version with those helpers, standardize on the framework helpers and skip the extra dependency.

Version coupling is the real adoption risk. 2.3.0 requires PHP 8.4 and Pest 5; 2.2.x covers Pest 2/3. Adopting it ties your test tooling to your Pest/PHP/Carbon upgrade path, so pin the constraint deliberately in composer.json and check it whenever you bump Pest. With a small star count and a niche surface, budget for the possibility that you're the one who has to patch it.

Debugging a frozen clock: Carbon::hasTestNow() tells you whether state is set, and dump(Carbon::now()) at the top of the failing test tells you immediately whether you're fighting a leak, a timezone, or your own date math.

Extension points are thin but sufficient. There is no config to speak of; the escape hatches are (a) call Carbon::setTestNow() directly for anything the wrapper doesn't cover, and (b) add your own Pest expectations in Pest.php next to toBeCarbon for project-specific date rules (expect($date)->toBeWithinBusinessHours() etc.). Parallel test runs are fine, since freezing is per-process.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package