---
title: "Testing — Refresh Tokens for Laravel | Roundly"
description: "RefreshTokens::fake() records every issue, redeem, rotate, revoke, enrich and prune; FakeAccessTokenRevoker and the factory cover the rest."
url: https://roundly-consulting.com/open-source/docs/refresh-tokens-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Refresh Tokens for Laravel](https://roundly-consulting.com/open-source/docs/refresh-tokens-for-laravel.md)

# Testing

RefreshTokens::fake() swaps the manager for a recording fake and returns it. Calls still run for real — tokens are issued, redeemed and revoked in your test database, because a stub that answered redeem() differently from the real store would let a broken refresh flow pass — and every call is recorded, whether it came through the facade, an injected RefreshTokensManager, for()->issue(), sessions() / session() or the HasRefreshTokens trait:

```php
use RoundlyConsulting\RefreshTokens\Facades\RefreshTokens;

$fake = RefreshTokens::fake();

// … exercise your login / refresh / logout endpoints …

$fake->assertIssued(for: $user);
$fake->assertIssued($user, fn (IssueContext $context): bool => $context->accessReference === $jti);
$fake->assertRedeemed(by: $user);
$fake->assertRotated(for: $user);
$fake->assertRevoked(for: $user, reason: RevocationReason::CredentialsChanged);
$fake->assertEnriched(for: $user);
$fake->assertPruned();

$fake->assertNothingIssued();   // …and assertNothingRedeemed/Rotated/Revoked/Enriched/Pruned()
```

| Assertion | Matches |
| --- | --- |
| `assertIssued(?Model $for = null, ?Closure $where = null) / assertNothingIssued()` | issue(), for()->issue() and $owner->issueRefreshToken(); $where receives the IssueContext. |
| `assertRedeemed(?Model $by = null) / assertNothingRedeemed()` | redeem() — a failed redeem is recorded with no owner. |
| `assertRotated(?Model $for = null) / assertNothingRotated()` | rotate() — recorded as a rotation only, not also as a redeem and an issue. |
| `assertRevoked(?Model $for = null, ?RevocationReason $reason = null) / assertNothingRevoked()` | revoke($plain), every sessions()->revoke…(), session()->revoke() and the trait’s revoke verbs. |
| `assertEnriched(?Model $for = null) / assertNothingEnriched()` | session()->enrich(). |
| `assertPruned() / assertNothingPruned()` | prune() and the refresh-tokens:prune command. |
| `recorded()` | Every call as a RecordedOperation (operation, owner, reason, context, count). |

A call that throws is not recorded. The assertions use PHPUnit’s Assert.

## Access-token revocation

Bind the shipped FakeAccessTokenRevoker in place of your real revoker and assert exactly which access references the package asked to deny — no hand-rolled spy required:

```php
use RoundlyConsulting\RefreshTokens\Contracts\AccessTokenRevoker;
use RoundlyConsulting\RefreshTokens\Testing\FakeAccessTokenRevoker;

$revoker = new FakeAccessTokenRevoker();
$this->app->instance(AccessTokenRevoker::class, $revoker);

// … exercise reuse detection / session revocation …

$revoker->assertRevoked($jti);
$revoker->assertNotRevoked($otherJti);
$revoker->assertRevokedCount(2);
$revoker->assertNothingRevoked();   // when nothing should have been denied
```

Its assertions throw RevokerAssertionFailedException rather than a PHPUnit assertion, so it works under any runner.

## A full reuse-detection test

```php
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\RefreshTokens\Contracts\AccessTokenRevoker;
use RoundlyConsulting\RefreshTokens\DataTransferObjects\IssueContext;
use RoundlyConsulting\RefreshTokens\DataTransferObjects\RotationContext;
use RoundlyConsulting\RefreshTokens\Events\RefreshTokenReuseDetected;
use RoundlyConsulting\RefreshTokens\Facades\RefreshTokens;
use RoundlyConsulting\RefreshTokens\Testing\FakeAccessTokenRevoker;

it('revokes the whole family when a rotated token is reused', function () {
    Event::fake([RefreshTokenReuseDetected::class]);
    $revoker = new FakeAccessTokenRevoker();
    $this->app->instance(AccessTokenRevoker::class, $revoker);

    $user = User::factory()->create();
    $a = RefreshTokens::issue($user, new IssueContext(accessReference: 'acc-a'));
    RefreshTokens::rotate($a->plainText, new RotationContext(accessReference: 'acc-b'));

    expect(RefreshTokens::redeem($a->plainText))->toBeNull(); // replay = theft signal

    $revoker->assertRevoked('acc-b');
    $revoker->assertRevokedCount(1);
    Event::assertDispatchedTimes(RefreshTokenReuseDetected::class, 1);
});
```

## Factory

The RefreshToken model ships a factory with expired(), revoked($reason), forFamily($id) and forOwner($owner) states:

```php
use RoundlyConsulting\RefreshTokens\Enums\RevocationReason;
use RoundlyConsulting\RefreshTokens\Models\RefreshToken;

RefreshToken::factory()->forOwner($user)->create();                         // a live session row
RefreshToken::factory()->forOwner($user)->expired()->create();
RefreshToken::factory()->forOwner($user)->revoked(RevocationReason::Logout)->create();
RefreshToken::factory()->forOwner($user)->forFamily($familyId)->create();
```

Factory rows hold a random digest, never a real plaintext, so they can’t be redeemed — use them for listing, revoking and pruning tests, and issue() when you need a redeemable token.

## The package’s own suite

```bash
composer test
composer test-coverage
```

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/refresh-tokens-for-laravel.md)
